fix(ui): implement UI review findings — a11y, IA, and consistency fixes
Fixes the reviewed gaps: keyboard-inaccessible delete controls (SessionRail, Entities row), case-sensitive entity filter, two competing entity-detail navigation patterns (standardize on EntitySheet), non-clickable Overview KPI cards, a bare button bypassing the shared Button component, inconsistent blur-only vs live filtering, and an unenforced sanitization assumption on search snippet HTML (now using the already-present dompurify dependency). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -157,7 +157,13 @@
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Button variant="ghost" size="sm" class="justify-start gap-2" onclick={() => (drawerOpen = true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => (drawerOpen = true)}
|
||||
title="Chat over the current page without navigating away"
|
||||
>
|
||||
<PanelRightIcon />
|
||||
<span>Chat drawer</span>
|
||||
</Button>
|
||||
|
||||
@@ -44,27 +44,29 @@
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="flex flex-col gap-1 pr-2">
|
||||
{#each $sessions as session (session.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||
onclick={() => handleClick(session.id)}
|
||||
>
|
||||
<span class="flex w-full items-center justify-between gap-1">
|
||||
<span class="min-w-0 truncate font-medium">{session.title || 'Untitled'}</span>
|
||||
<span
|
||||
class="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
||||
onclick={(e) => handleDelete(e, session.id)}
|
||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
>
|
||||
{#if confirmDelete === session.id}
|
||||
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
||||
{:else}
|
||||
<Trash2Icon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||
</button>
|
||||
<div class="group relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start gap-0.5 rounded-md border py-1.5 pl-2 pr-7 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||
onclick={() => handleClick(session.id)}
|
||||
>
|
||||
<span class="min-w-0 max-w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
||||
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1 top-1.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
||||
onclick={(e) => handleDelete(e, session.id)}
|
||||
aria-label={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
>
|
||||
{#if confirmDelete === session.id}
|
||||
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
||||
{:else}
|
||||
<Trash2Icon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
||||
{/each}
|
||||
|
||||
@@ -21,6 +21,16 @@ export function relativeTime(iso: string | null | undefined): string {
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
// debounce wraps fn so rapid calls (e.g. keystrokes in a filter input)
|
||||
// collapse into one invocation after `wait`ms of silence.
|
||||
export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300): T {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return ((...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { fetchAgentActivity, type AgentActivity } from '$lib/api'
|
||||
import { debounce } from '$lib/utils'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
|
||||
@@ -19,6 +21,8 @@
|
||||
})
|
||||
}
|
||||
|
||||
const loadDebounced = debounce(load, 300)
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
@@ -56,7 +60,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input placeholder="Filter by agent_id…" bind:value={agentFilter} class="max-w-xs" onchange={load} />
|
||||
<Input placeholder="Filter by agent_id…" bind:value={agentFilter} class="max-w-xs" oninput={loadDebounced} />
|
||||
<Select.Root type="single" bind:value={typeFilter} onvalueChange={() => load()}>
|
||||
<Select.Trigger class="w-40">
|
||||
{typeFilter === 'all' ? 'All types' : typeFilter}
|
||||
@@ -70,7 +74,7 @@
|
||||
<Select.Item value="escalation">Escalation</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<button type="button" class="rounded-md border px-3 py-1.5 text-xs" onclick={load}>Refresh</button>
|
||||
<Button variant="outline" onclick={load}>Refresh</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden rounded-md border">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fetchAudit, type AuditEntry } from '$lib/api'
|
||||
import { debounce } from '$lib/utils'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
@@ -21,6 +22,8 @@
|
||||
})
|
||||
}
|
||||
|
||||
const loadDebounced = debounce(load, 300)
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
const interval = setInterval(load, 30000)
|
||||
@@ -68,8 +71,8 @@
|
||||
<Select.Item value="scheduler">Scheduler</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Input placeholder="Action…" bind:value={actionFilter} class="max-w-32" onchange={load} />
|
||||
<Input placeholder="Entity…" bind:value={entityFilter} class="max-w-48" onchange={load} />
|
||||
<Input placeholder="Action…" bind:value={actionFilter} class="max-w-32" oninput={loadDebounced} />
|
||||
<Input placeholder="Entity…" bind:value={entityFilter} class="max-w-48" oninput={loadDebounced} />
|
||||
<Button variant="outline" onclick={load}>Refresh</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,13 +44,14 @@
|
||||
|
||||
const types = $derived(Array.from(new Set(entities.map((e) => e.type))).sort())
|
||||
|
||||
const filtered = $derived(
|
||||
entities.filter((e) => {
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return entities.filter((e) => {
|
||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false
|
||||
if (query && !e.slug.includes(query) && !e.name.includes(query)) return false
|
||||
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
||||
if (!state) return 'outline'
|
||||
@@ -116,7 +117,10 @@
|
||||
{#each filtered as entity (entity.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer"
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onclick={() => openEntity(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEntity(entity.slug) } }}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fetchEvents } from '$lib/api'
|
||||
import { debounce } from '$lib/utils'
|
||||
import { liveEvents, connectionState, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
@@ -21,6 +22,8 @@
|
||||
history = await fetchEvents({ type: typeFilter || undefined, severity: severityFilter || undefined })
|
||||
}
|
||||
|
||||
const loadHistoryDebounced = debounce(loadHistory, 300)
|
||||
|
||||
onMount(() => {
|
||||
loadHistory()
|
||||
const unsubscribe = subscribeEvents()
|
||||
@@ -89,8 +92,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Input placeholder="Type prefix (e.g. entity.)" bind:value={typeFilter} class="max-w-xs" onchange={loadHistory} />
|
||||
<Input placeholder="Severity" bind:value={severityFilter} class="max-w-32" onchange={loadHistory} />
|
||||
<Input placeholder="Type prefix (e.g. entity.)" bind:value={typeFilter} class="max-w-xs" oninput={loadHistoryDebounced} />
|
||||
<Input placeholder="Severity" bind:value={severityFilter} class="max-w-32" oninput={loadHistoryDebounced} />
|
||||
<Button variant={paused ? 'default' : 'outline'} onclick={() => (paused = !paused)}>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
||||
|
||||
interface Node extends Entity {
|
||||
@@ -289,6 +290,14 @@
|
||||
search = ''
|
||||
load()
|
||||
}
|
||||
|
||||
let entitySheetOpen = $state(false)
|
||||
let entitySheetSlug = $state<string | null>(null)
|
||||
|
||||
function openEntityDetail(slug: string) {
|
||||
entitySheetSlug = slug
|
||||
entitySheetOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-3 p-4">
|
||||
@@ -461,7 +470,7 @@
|
||||
</Sheet.Header>
|
||||
<div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => (location.hash = '#/entity/' + encodeURIComponent(selected!.slug))}>
|
||||
<Button variant="outline" size="sm" onclick={() => openEntityDetail(selected!.slug)}>
|
||||
View entity detail
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
|
||||
@@ -504,3 +513,5 @@
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<EntitySheet slug={entitySheetSlug} bind:open={entitySheetOpen} />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify'
|
||||
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge, type KnowledgeItem } from '$lib/api'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import BotIcon from '@lucide/svelte/icons/bot'
|
||||
@@ -54,8 +56,12 @@
|
||||
return `${Math.floor(s / 86400)}d ago`
|
||||
}
|
||||
|
||||
let sheetOpen = $state(false)
|
||||
let selectedSlug = $state<string | null>(null)
|
||||
|
||||
function openEntity(slug: string) {
|
||||
location.hash = '#/entity/' + encodeURIComponent(slug)
|
||||
selectedSlug = slug
|
||||
sheetOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -117,8 +123,10 @@
|
||||
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
|
||||
</div>
|
||||
{#if hit.snippet}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — server-sanitized ts_headline -->
|
||||
<Card.Description class="text-xs">{@html hit.snippet}</Card.Description>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
|
||||
<Card.Description class="text-xs"
|
||||
>{@html DOMPurify.sanitize(hit.snippet, { ALLOWED_TAGS: ['b'], ALLOWED_ATTR: [] })}</Card.Description
|
||||
>
|
||||
{/if}
|
||||
{#if hit.linked_entities?.length}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
@@ -174,3 +182,5 @@
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
@@ -47,10 +47,6 @@
|
||||
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
|
||||
)
|
||||
|
||||
function formatEventLabel(ev: OikosEvent) {
|
||||
return ev.type
|
||||
}
|
||||
|
||||
const totalEntities = $derived(
|
||||
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
@@ -144,53 +140,57 @@
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Open signals</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{totalSignals}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if worstSeverity === 'critical'}
|
||||
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
|
||||
{:else if worstSeverity === 'warning'}
|
||||
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
||||
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground">Unresolved right now</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
<button type="button" class="text-left" onclick={() => (location.hash = '#/signals')}>
|
||||
<Card.Root class="@container/card transition-colors hover:border-primary/50">
|
||||
<Card.Header>
|
||||
<Card.Description>Open signals</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{totalSignals}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if worstSeverity === 'critical'}
|
||||
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
|
||||
{:else if worstSeverity === 'warning'}
|
||||
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
||||
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground">Unresolved right now</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</button>
|
||||
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Pending approvals</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{summary.approvals_pending}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if summary.approvals_pending > 0}
|
||||
<Badge variant="destructive">needs review</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
{executionsRunning} running · {executionsFailed} failed
|
||||
</div>
|
||||
<div class="text-muted-foreground">Executions in the last 24h</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
<button type="button" class="text-left" onclick={() => (location.hash = '#/ops')}>
|
||||
<Card.Root class="@container/card transition-colors hover:border-primary/50">
|
||||
<Card.Header>
|
||||
<Card.Description>Pending approvals</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{summary.approvals_pending}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if summary.approvals_pending > 0}
|
||||
<Badge variant="destructive">needs review</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
{executionsRunning} running · {executionsFailed} failed
|
||||
</div>
|
||||
<div class="text-muted-foreground">Executions in the last 24h</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if degradedTypes.length}
|
||||
@@ -239,7 +239,7 @@
|
||||
>{ev.severity}</Badge
|
||||
>
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
||||
<span>{formatEventLabel(ev)}</span>
|
||||
<span>{ev.type}</span>
|
||||
<span class="truncate text-muted-foreground">{ev.source}</span>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user