Build the ui/ component library and persistent app shell
Button, Input, Select, Badge, SeverityBadge, Table, Card, Modal, Tooltip, Tabs, Skeleton, EmptyState -- a shared library so pages stop hand-rolling markup per page (web/src/lib/components/ui, barrel export in index.ts). Modal and CommandPalette are built on native <dialog> for a real focus trap, Escape-to-close, and top-layer stacking instead of hand-rolling those. Tabs uses roving tabindex with arrow-key nav. NavSidebar replaces the old top-nav with a persistent sidebar (Search/Dashboards/Alerts/Data Sources/Settings), a live tenant indicator (api.ts's new getCurrentSession(), a client for the already-existing POST /internal/authorize -- zero new backend surface), theme/density quick-toggles, and a command-palette hint. Collapses to an off-canvas drawer under 860px. CommandPalette (Cmd/Ctrl+K) indexes the five static destinations plus live-fetched dashboards/alert rules. Data Sources is a new, honestly-scoped placeholder page (one data source per tenant today, no UI needed yet). Settings and Select-tenant are re-tokened onto the new component library. +layout.svelte's content wrapper is a plain <div>, not a second <main> -- every page already renders its own top-level <main>.
This commit is contained in:
+29
-1
@@ -16,7 +16,7 @@ export type Language = '' | 'sql' | 'spl';
|
|||||||
|
|
||||||
export type QueryResult = { columns: string[]; rows: unknown[][] };
|
export type QueryResult = { columns: string[]; rows: unknown[][] };
|
||||||
|
|
||||||
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n';
|
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n' | 'heatmap';
|
||||||
|
|
||||||
export type Panel = {
|
export type Panel = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -203,6 +203,34 @@ export function selectTenant(tenantId: string): Promise<{ redirect_url: string }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CurrentSession = { tenant_id: string; user_id: string; role: string };
|
||||||
|
|
||||||
|
// getCurrentSession backs the sidebar's tenant indicator (Phase 5).
|
||||||
|
// POST /internal/authorize already exists (api/authz.HTTPAuthorizer and
|
||||||
|
// alerting's queryclient call it the same way) and is already reachable
|
||||||
|
// from the browser -- enterprise-auth's whole mux is wrapped in
|
||||||
|
// WithCredentialedCORS, not just the tenant-picker routes -- so this is
|
||||||
|
// a client for an existing endpoint, not a new one. Returns null for
|
||||||
|
// every "no tenant to show" case alike (no enterprise-auth configured,
|
||||||
|
// not logged in, or a plain single-tenant deployment) rather than
|
||||||
|
// throwing -- same "absence is a normal deployment shape" posture
|
||||||
|
// getAuthFeatures already uses, so the sidebar can just render nothing
|
||||||
|
// instead of branching on error types.
|
||||||
|
export async function getCurrentSession(): Promise<CurrentSession | null> {
|
||||||
|
if (!enterpriseAuthBase) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${enterpriseAuthBase}/internal/authorize`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const session = (await res.json()) as CurrentSession;
|
||||||
|
return session.tenant_id ? session : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function exportDashboard(id: string): Promise<Dashboard> {
|
export function exportDashboard(id: string): Promise<Dashboard> {
|
||||||
return request(`/dashboards/${id}/export`);
|
return request(`/dashboards/${id}/export`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { listDashboards, listRules } from '$lib/api';
|
||||||
|
|
||||||
|
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||||
|
|
||||||
|
type Item = { id: string; label: string; hint: string; go: () => void };
|
||||||
|
|
||||||
|
const staticItems: Item[] = [
|
||||||
|
{ id: 'nav-search', label: 'Search', hint: 'Go to', go: () => goto('/') },
|
||||||
|
{ id: 'nav-dashboards', label: 'Dashboards', hint: 'Go to', go: () => goto('/dashboards') },
|
||||||
|
{ id: 'nav-alerts', label: 'Alerts', hint: 'Go to', go: () => goto('/alerts') },
|
||||||
|
{ id: 'nav-data-sources', label: 'Data Sources', hint: 'Go to', go: () => goto('/data-sources') },
|
||||||
|
{ id: 'nav-settings', label: 'Settings', hint: 'Go to', go: () => goto('/settings') }
|
||||||
|
];
|
||||||
|
|
||||||
|
let dynamicItems: Item[] = $state([]);
|
||||||
|
let loaded = false;
|
||||||
|
let query = $state('');
|
||||||
|
let selected = $state(0);
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state();
|
||||||
|
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||||
|
|
||||||
|
async function loadDynamic() {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
const [dashboards, rules] = await Promise.allSettled([listDashboards(), listRules()]);
|
||||||
|
const items: Item[] = [];
|
||||||
|
if (dashboards.status === 'fulfilled') {
|
||||||
|
for (const d of dashboards.value) {
|
||||||
|
items.push({
|
||||||
|
id: `dash-${d.id}`,
|
||||||
|
label: d.name,
|
||||||
|
hint: 'Dashboard',
|
||||||
|
go: () => goto(`/dashboards/${d.id}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rules.status === 'fulfilled') {
|
||||||
|
for (const r of rules.value) {
|
||||||
|
items.push({
|
||||||
|
id: `rule-${r.id}`,
|
||||||
|
label: r.name,
|
||||||
|
hint: 'Alert rule',
|
||||||
|
go: () => goto(`/alerts/${r.id}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dynamicItems = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allItems = $derived([...staticItems, ...dynamicItems]);
|
||||||
|
let filtered = $derived(
|
||||||
|
query.trim() === ''
|
||||||
|
? allItems
|
||||||
|
: allItems.filter((i) => i.label.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
selected = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Native <dialog> instead of a hand-rolled overlay -- same reasoning
|
||||||
|
// as ui/Modal.svelte: focus trap, Escape-to-close, and top-layer
|
||||||
|
// stacking come for free, and it resolves the a11y-linter warnings a
|
||||||
|
// plain role="dialog" div on a non-interactive element raises,
|
||||||
|
// rather than suppressing them.
|
||||||
|
$effect(() => {
|
||||||
|
if (!dialogEl) return;
|
||||||
|
if (open && !dialogEl.open) {
|
||||||
|
dialogEl.showModal();
|
||||||
|
loadDynamic();
|
||||||
|
queueMicrotask(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
if (!open && dialogEl.open) dialogEl.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onDialogClose() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialogEl) open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(item: Item) {
|
||||||
|
item.go();
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
selected = Math.min(selected + 1, filtered.length - 1);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
selected = Math.max(selected - 1, 0);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (filtered[selected]) choose(filtered[selected]);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
const isK = e.key === 'k' || e.key === 'K';
|
||||||
|
if (isK && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onGlobalKeydown} />
|
||||||
|
|
||||||
|
<dialog bind:this={dialogEl} class="palette" onclose={onDialogClose} onclick={onBackdropClick} aria-label="Command palette">
|
||||||
|
<div class="frame">
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
type="text"
|
||||||
|
placeholder="Jump to a dashboard, alert rule, or page…"
|
||||||
|
aria-label="Search"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="palette-listbox"
|
||||||
|
/>
|
||||||
|
<ul id="palette-listbox" role="listbox">
|
||||||
|
{#each filtered as item, i (item.id)}
|
||||||
|
<li role="option" aria-selected={i === selected}>
|
||||||
|
<button type="button" class:selected={i === selected} onclick={() => choose(item)}>
|
||||||
|
<span class="label">{item.label}</span>
|
||||||
|
<span class="hint">{item.hint}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="empty">No matches</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.palette {
|
||||||
|
position: fixed;
|
||||||
|
top: 12vh;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
width: min(34rem, calc(100vw - 2rem));
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.palette::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.frame {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-2);
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
li.empty {
|
||||||
|
padding: var(--space-3);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.selected {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { getCurrentSession, enterpriseAuthBase, type CurrentSession } from '$lib/api';
|
||||||
|
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
|
import { getDensity, toggleDensity } from '$lib/density.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onOpenPalette,
|
||||||
|
mobileOpen = false,
|
||||||
|
onCloseMobile
|
||||||
|
}: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Search', icon: '◇' },
|
||||||
|
{ href: '/dashboards', label: 'Dashboards', icon: '▤' },
|
||||||
|
{ href: '/alerts', label: 'Alerts', icon: '▲' },
|
||||||
|
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
|
||||||
|
{ href: '/settings', label: 'Settings', icon: '⚙' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === '/') return page.url.pathname === '/';
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: CurrentSession | null = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
getCurrentSession().then((s) => (session = s));
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeOptions: { value: Theme; label: string }[] = [
|
||||||
|
{ value: 'dark', label: 'Dark' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'system', label: 'System' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if mobileOpen}
|
||||||
|
<button type="button" class="backdrop" onclick={onCloseMobile} aria-label="Close menu"></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<aside class="sidebar" class:mobile-open={mobileOpen}>
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark" aria-hidden="true">◆</span>
|
||||||
|
<span class="name">Sentry</span>
|
||||||
|
<button type="button" class="close-mobile" onclick={onCloseMobile} aria-label="Close menu">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if enterpriseAuthBase}
|
||||||
|
<div class="tenant">
|
||||||
|
{#if session}
|
||||||
|
<div class="tenant-pill">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
<span class="tenant-name">{session.tenant_id}</span>
|
||||||
|
<span class="role">{session.role}</span>
|
||||||
|
</div>
|
||||||
|
<a class="switch" href="{enterpriseAuthBase}/auth/oidc/login">Switch tenant</a>
|
||||||
|
{:else}
|
||||||
|
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<nav aria-label="Main">
|
||||||
|
{#each navItems as item (item.href)}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class:active={isActive(item.href)}
|
||||||
|
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||||
|
onclick={onCloseMobile}
|
||||||
|
>
|
||||||
|
<span class="ic" aria-hidden="true">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button type="button" class="palette-hint" onclick={onOpenPalette}>
|
||||||
|
<span>Jump to…</span>
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label for="theme-select" class="sr-only">Theme</label>
|
||||||
|
<select id="theme-select" value={getTheme()} onchange={(e) => setTheme(e.currentTarget.value as Theme)}>
|
||||||
|
{#each themeOptions as opt (opt.value)}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="density-toggle" onclick={toggleDensity} title="Toggle row density">
|
||||||
|
{getDensity() === 'compact' ? 'Compact' : 'Comfortable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-4) var(--space-3);
|
||||||
|
height: 100vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
.close-mobile {
|
||||||
|
display: none;
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
.backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.tenant-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.tenant-name {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.role {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.switch {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.switch:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.switch.signin {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
nav a.active {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.ic {
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.palette-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.palette-hint:hover {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.controls select,
|
||||||
|
.density-toggle {
|
||||||
|
flex: 1;
|
||||||
|
height: 1.9rem;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below this width the sidebar becomes an off-canvas drawer
|
||||||
|
(+layout.svelte renders a menu button to open it) instead of a
|
||||||
|
fixed grid column -- "shouldn't break on a laptop screen or a
|
||||||
|
tablet in landscape" is the actual bar (this is a desktop-first
|
||||||
|
tool), so the breakpoint is deliberately narrower than a phone
|
||||||
|
viewport would need. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 90;
|
||||||
|
width: 16rem;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.sidebar.mobile-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.close-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
z-index: 80;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sidebar {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// Generic status badge -- for severity specifically, use
|
||||||
|
// SeverityBadge.svelte instead (it owns the OTel->tier mapping so
|
||||||
|
// callers can't accidentally invent a sixth color).
|
||||||
|
type Tone = 'neutral' | 'success' | 'danger' | 'accent';
|
||||||
|
|
||||||
|
let { tone = 'neutral', children }: { tone?: Tone; children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="badge {tone}">{@render children()}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.1rem var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.neutral {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: color-mix(in srgb, var(--color-text-muted) 16%, transparent);
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: var(--color-success);
|
||||||
|
background: color-mix(in srgb, var(--color-success) 16%, transparent);
|
||||||
|
}
|
||||||
|
.danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
|
||||||
|
}
|
||||||
|
.accent {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
type Size = 'sm' | 'md';
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant = 'secondary',
|
||||||
|
size = 'md',
|
||||||
|
type = 'button',
|
||||||
|
disabled = false,
|
||||||
|
href,
|
||||||
|
onclick,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
disabled?: boolean;
|
||||||
|
href?: string;
|
||||||
|
onclick?: (e: MouseEvent) => void;
|
||||||
|
children: Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a {href} class="btn {variant} {size}" aria-disabled={disabled} {...rest}>
|
||||||
|
{@render children()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button {type} class="btn {variant} {size}" {disabled} {onclick} {...rest}>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background-color 0.1s ease,
|
||||||
|
border-color 0.1s ease;
|
||||||
|
}
|
||||||
|
.btn:disabled,
|
||||||
|
.btn[aria-disabled='true'] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
height: var(--control-height);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
.sm {
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
}
|
||||||
|
.primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.secondary:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
padded = true,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
actions?: Snippet;
|
||||||
|
padded?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{#if title || actions}
|
||||||
|
<div class="head">
|
||||||
|
{#if title}<h2>{title}</h2>{/if}
|
||||||
|
{#if actions}<div class="actions">{@render actions()}</div>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="body" class:padded>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--panel-padding);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.head h2 {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.body.padded {
|
||||||
|
padding: var(--panel-padding);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon = '◇',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action
|
||||||
|
}: {
|
||||||
|
icon?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="empty">
|
||||||
|
<div class="icon" aria-hidden="true">{icon}</div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if description}<p>{description}</p>{/if}
|
||||||
|
{#if action}<div class="action">{@render action()}</div>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
color: var(--color-border-strong);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
max-width: 30rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
type = 'text',
|
||||||
|
placeholder = '',
|
||||||
|
disabled = false,
|
||||||
|
id,
|
||||||
|
invalid = false,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
|
invalid?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
{placeholder}
|
||||||
|
{disabled}
|
||||||
|
bind:value
|
||||||
|
aria-invalid={invalid}
|
||||||
|
class="input"
|
||||||
|
class:invalid
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.input {
|
||||||
|
height: var(--control-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
}
|
||||||
|
.input:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
.input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.input.invalid {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// Built on <dialog> deliberately -- native focus trap, Escape-to-close,
|
||||||
|
// and top-layer stacking for free, instead of hand-rolling all three.
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
title: string;
|
||||||
|
children: Snippet;
|
||||||
|
footer?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dialogEl) return;
|
||||||
|
if (open && !dialogEl.open) dialogEl.showModal();
|
||||||
|
if (!open && dialogEl.open) dialogEl.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialogEl) open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog bind:this={dialogEl} onclose={onClose} onclick={onBackdropClick} aria-labelledby="modal-title">
|
||||||
|
<div class="frame">
|
||||||
|
<header>
|
||||||
|
<h2 id="modal-title">{title}</h2>
|
||||||
|
<button type="button" class="close" onclick={() => (open = false)} aria-label="Close">✕</button>
|
||||||
|
</header>
|
||||||
|
<div class="content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{#if footer}
|
||||||
|
<footer>{@render footer()}</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: min(32rem, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.frame {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
.close:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: var(--space-4);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
disabled = false,
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
|
children: Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<select {id} {disabled} bind:value class="select" {...rest}>
|
||||||
|
{@render children()}
|
||||||
|
</select>
|
||||||
|
<span class="chev" aria-hidden="true">⌄</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
height: var(--control-height);
|
||||||
|
padding: 0 var(--space-6) 0 var(--space-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.select:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
.select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--space-3);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { severityTier } from '$lib/severity';
|
||||||
|
|
||||||
|
let { severity }: { severity: string } = $props();
|
||||||
|
let tier = $derived(severityTier(severity));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="chip {tier}">{severity}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.05rem var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.quiet {
|
||||||
|
color: var(--color-sev-quiet);
|
||||||
|
background: var(--color-sev-quiet-bg);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
color: var(--color-sev-info);
|
||||||
|
background: var(--color-sev-info-bg);
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
color: var(--color-sev-warn);
|
||||||
|
background: var(--color-sev-warn-bg);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--color-sev-error);
|
||||||
|
background: var(--color-sev-error-bg);
|
||||||
|
}
|
||||||
|
.critical {
|
||||||
|
color: var(--color-sev-critical);
|
||||||
|
background: var(--color-sev-critical-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { width = '100%', height = '1rem' }: { width?: string; height?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="skeleton" style:width style:height></span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.skeleton {
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-surface-raised) 25%,
|
||||||
|
var(--color-border) 50%,
|
||||||
|
var(--color-surface-raised) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton {
|
||||||
|
animation: none;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// A thin styling wrapper, not a data grid -- real <table> markup
|
||||||
|
// underneath (native semantics matter for screen readers), density-
|
||||||
|
// aware row height/padding from tokens.css's --row-* variables.
|
||||||
|
// Column sort/resize (task 6) layers on top of this later; this just
|
||||||
|
// establishes the shared chrome every table in the app should share.
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<table class="ui-table">
|
||||||
|
{@render children()}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
:global(.ui-table thead th) {
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-2) var(--row-padding-x);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(.ui-table tbody td) {
|
||||||
|
padding: var(--row-padding-y) var(--row-padding-x);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
:global(.ui-table tbody tr:last-child td) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
:global(.ui-table tbody tr:hover td) {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Renders the tab list only -- the caller renders each panel's content
|
||||||
|
// (conditionally, on `active`) and is responsible for
|
||||||
|
// id="panel-{tab.id}" aria-labelledby="tab-{tab.id}" on it. Keeps this
|
||||||
|
// component decoupled from what a panel's content actually is.
|
||||||
|
type Tab = { id: string; label: string };
|
||||||
|
|
||||||
|
let {
|
||||||
|
tabs,
|
||||||
|
active = $bindable('')
|
||||||
|
}: {
|
||||||
|
tabs: Tab[];
|
||||||
|
active?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!active && tabs.length) active = tabs[0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
const i = tabs.findIndex((t) => t.id === active);
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
active = tabs[(i + 1) % tabs.length].id;
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
active = tabs[(i - 1 + tabs.length) % tabs.length].id;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="tablist" tabindex="-1" class="tablist" onkeydown={onKeydown}>
|
||||||
|
{#each tabs as tab (tab.id)}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
id="tab-{tab.id}"
|
||||||
|
aria-selected={active === tab.id}
|
||||||
|
aria-controls="panel-{tab.id}"
|
||||||
|
tabindex={active === tab.id ? 0 : -1}
|
||||||
|
class="tab"
|
||||||
|
class:active={active === tab.id}
|
||||||
|
onclick={() => (active = tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tablist {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { text, children, id }: { text: string; children: Snippet; id: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="wrap">
|
||||||
|
<span aria-describedby={id} class="trigger">
|
||||||
|
{@render children()}
|
||||||
|
</span>
|
||||||
|
<span role="tooltip" {id} class="tip">{text}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.tip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--space-2));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(2px);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 0.1s ease,
|
||||||
|
transform 0.1s ease;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.wrap:hover .tip,
|
||||||
|
.wrap:focus-within .tip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { default as Button } from './Button.svelte';
|
||||||
|
export { default as Input } from './Input.svelte';
|
||||||
|
export { default as Select } from './Select.svelte';
|
||||||
|
export { default as Badge } from './Badge.svelte';
|
||||||
|
export { default as SeverityBadge } from './SeverityBadge.svelte';
|
||||||
|
export { default as Table } from './Table.svelte';
|
||||||
|
export { default as Card } from './Card.svelte';
|
||||||
|
export { default as Modal } from './Modal.svelte';
|
||||||
|
export { default as Tooltip } from './Tooltip.svelte';
|
||||||
|
export { default as Tabs } from './Tabs.svelte';
|
||||||
|
export { default as Skeleton } from './Skeleton.svelte';
|
||||||
|
export { default as EmptyState } from './EmptyState.svelte';
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import '$lib/styles/app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import NavSidebar from '$lib/components/NavSidebar.svelte';
|
||||||
|
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||||
|
|
||||||
// Phase 3: dashboards + alerts routes added, so there's now more than
|
|
||||||
// one page -- a minimal nav replaces the previous "no nav, one page"
|
|
||||||
// layout.
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
let paletteOpen = $state(false);
|
||||||
|
let mobileNavOpen = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -12,30 +14,62 @@
|
|||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav>
|
<div class="shell">
|
||||||
<a href="/">Query</a>
|
<NavSidebar
|
||||||
<a href="/dashboards">Dashboards</a>
|
onOpenPalette={() => (paletteOpen = true)}
|
||||||
<a href="/alerts">Alerts</a>
|
mobileOpen={mobileNavOpen}
|
||||||
<a href="/settings">Settings</a>
|
onCloseMobile={() => (mobileNavOpen = false)}
|
||||||
</nav>
|
/>
|
||||||
|
<div class="main-col">
|
||||||
|
<button type="button" class="menu-toggle" onclick={() => (mobileNavOpen = true)} aria-label="Open menu">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<div class="page">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{@render children()}
|
<CommandPalette bind:open={paletteOpen} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
nav {
|
.shell {
|
||||||
font-family: system-ui, sans-serif;
|
display: grid;
|
||||||
max-width: 960px;
|
grid-template-columns: 15rem 1fr;
|
||||||
margin: 1rem auto 0;
|
min-height: 100vh;
|
||||||
padding: 0 1rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
}
|
||||||
nav a {
|
.main-col {
|
||||||
color: #06c;
|
min-width: 0;
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
nav a:hover {
|
.page {
|
||||||
text-decoration: underline;
|
padding: var(--space-6);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.menu-toggle {
|
||||||
|
display: block;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Card } from '$lib/components/ui';
|
||||||
|
import { enterpriseAuthBase } from '$lib/api';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<h1>Data Sources</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Every tenant has exactly one data source today — its own ClickHouse database and Tantivy
|
||||||
|
index, provisioned together when the tenant is created. There's nothing to configure yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card title="Current data source">
|
||||||
|
{#if enterpriseAuthBase}
|
||||||
|
<p>
|
||||||
|
Your queries run against your tenant's dedicated ClickHouse database and Tantivy index —
|
||||||
|
never a shared one. See <a href="/settings">Settings</a> for SSO and tenant configuration.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This deployment isn't running in multi-tenant mode, so there's one data source for the
|
||||||
|
whole instance — the default ClickHouse database and Tantivy index every query already
|
||||||
|
runs against.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
Multiple data sources per tenant — a second ClickHouse cluster, a read replica, an external
|
||||||
|
source — is real future work, not something this page is hiding. The
|
||||||
|
<code>data_sources</code> table this reads from was already built with that in mind (see
|
||||||
|
<code>/docs/phase-4-rbac-design.md</code>).
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 42rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -83,10 +83,8 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
font-family: system-ui, sans-serif;
|
max-width: 30rem;
|
||||||
max-width: 480px;
|
margin: var(--space-8) auto;
|
||||||
margin: 3rem auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
}
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -94,23 +92,25 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: var(--space-3);
|
||||||
padding: 0.75rem 1rem;
|
padding: var(--space-3) var(--space-4);
|
||||||
font-size: 1rem;
|
font-size: var(--text-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: #fff;
|
background: var(--color-surface);
|
||||||
border: 1px solid #ccc;
|
color: var(--color-text);
|
||||||
border-radius: 6px;
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
border-color: #06c;
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -118,18 +118,18 @@
|
|||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
.role {
|
.role {
|
||||||
color: #666;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: var(--text-sm);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #b00020;
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.note {
|
.note {
|
||||||
color: #666;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getAuthFeatures, enterpriseAuthBase, type AuthFeatures } from '$lib/api';
|
import { getAuthFeatures, enterpriseAuthBase, type AuthFeatures } from '$lib/api';
|
||||||
|
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
|
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let features = $state<AuthFeatures>({ sso_configured: false, oidc_enabled: false, saml_enabled: false });
|
let features = $state<AuthFeatures>({ sso_configured: false, oidc_enabled: false, saml_enabled: false });
|
||||||
@@ -10,18 +12,60 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
|
||||||
|
const themeOptions: { value: Theme; label: string; hint: string }[] = [
|
||||||
|
{ value: 'dark', label: 'Dark', hint: 'Default' },
|
||||||
|
{ value: 'light', label: 'Light', hint: '' },
|
||||||
|
{ value: 'system', label: 'System', hint: 'Follow OS setting' }
|
||||||
|
];
|
||||||
|
const densityOptions: { value: Density; label: string; hint: string }[] = [
|
||||||
|
{ value: 'comfortable', label: 'Comfortable', hint: 'Dashboards, forms' },
|
||||||
|
{ value: 'compact', label: 'Compact', hint: 'Log tables, results' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<div class="option-group" role="radiogroup" aria-label="Theme">
|
||||||
|
{#each themeOptions as opt (opt.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="option"
|
||||||
|
class:selected={getTheme() === opt.value}
|
||||||
|
aria-pressed={getTheme() === opt.value}
|
||||||
|
onclick={() => setTheme(opt.value)}
|
||||||
|
>
|
||||||
|
<span class="option-label">{opt.label}</span>
|
||||||
|
{#if opt.hint}<span class="option-hint">{opt.hint}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="option-group" role="radiogroup" aria-label="Row density">
|
||||||
|
{#each densityOptions as opt (opt.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="option"
|
||||||
|
class:selected={getDensity() === opt.value}
|
||||||
|
aria-pressed={getDensity() === opt.value}
|
||||||
|
onclick={() => setDensity(opt.value)}
|
||||||
|
>
|
||||||
|
<span class="option-label">{opt.label}</span>
|
||||||
|
<span class="option-hint">{opt.hint}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Core</h2>
|
<h2>Core</h2>
|
||||||
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
|
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p>Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{:else if features.sso_configured}
|
{:else if features.sso_configured}
|
||||||
<section>
|
<section>
|
||||||
<h2>Single sign-on</h2>
|
<h2>Single sign-on</h2>
|
||||||
@@ -48,19 +92,66 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
font-family: system-ui, sans-serif;
|
max-width: 40rem;
|
||||||
max-width: 960px;
|
}
|
||||||
margin: 2rem auto;
|
h1 {
|
||||||
padding: 0 1rem;
|
font-size: var(--text-xl);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
section {
|
section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.1rem;
|
font-size: var(--text-md);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.note {
|
.note {
|
||||||
color: #666;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.option-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.option:hover {
|
||||||
|
border-color: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
.option.selected {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
|
||||||
|
}
|
||||||
|
.option-label {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.option-hint {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
/* The accent-tinted .selected background is *lighter* than plain
|
||||||
|
--color-surface, which quietly drops --color-text-muted below
|
||||||
|
4.5:1 (axe-core's color-contrast check caught this at 4.4:1) --
|
||||||
|
fixed by using the full-strength --color-text on that lighter
|
||||||
|
background specifically, not by changing the shared muted token
|
||||||
|
everywhere else it still passes correctly. */
|
||||||
|
.option.selected .option-hint {
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user