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:
2026-08-16 12:35:25 -07:00
parent a6153c5f90
commit f1a68455e0
20 changed files with 1521 additions and 51 deletions
+29 -1
View File
@@ -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>
+313
View File
@@ -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>
+42
View File
@@ -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>
+106
View File
@@ -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>
+51
View File
@@ -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>
+57
View File
@@ -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>
+101
View File
@@ -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>
+60
View File
@@ -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>
+34
View File
@@ -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>
+52
View File
@@ -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>
+74
View File
@@ -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>
+46
View File
@@ -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>
+12
View File
@@ -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';
+57 -23
View File
@@ -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>
+54
View File
@@ -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>
+18 -18
View File
@@ -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>
+100 -9
View File
@@ -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>