Fixes 1-3 deployed and verified live: fresh trivial Q&A sessions now reach done immediately, and a goal-bearing session that stalled was correctly nudged by the idle sweep. Fix 4 (backfill) was replaced with deletion after the operator's call — verified against the DB first that zero knowledge notes were linked to or written by any of the 53 removed sessions, so nothing was lost. Documents the pagination gap in listSessions (hardcoded LIMIT 50, no total count) that hid 6 of those sessions from the original audit. Also fixes relative links in this plan and in the UI-review plan that broke when both moved from plans/ to plans/done/ (one directory level deeper). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
UI review: information architecture, usability, and best practices
Status: Done — 2026-07-11. All fix-plan items implemented and verified live
except C2 (a11y lint enforcement — no ESLint/svelte-check is configured in
web/ at all, so there's nothing to promote from warn to error; flagged
below instead of silently adding lint infra). Verification also surfaced an
unrelated pre-existing bug (Knowledge page search results never render) —
spun off as a separate task, not fixed here.
Scope
Systematic review of web/src/ (Svelte 5 + shadcn-svelte + Tailwind v4
control-room UI): all 13 pages, the 11 shared components, the sidebar/routing
shell (App.svelte), and cross-cutting patterns (filtering, loading/empty
states, live-event wiring, accessibility). Read in full, not sampled.
Grounded in what's actually in the code — no speculative "best practice"
items without a concrete file:line instance.
Not implementation. Findings and a proposed fix plan only, mirroring
2026-07-11-nomos-agent-code-review.md's
structure — implement on a later "proceed."
Findings
A. Information architecture
A1. Entity detail has two competing UI patterns for the same content.
Entities.svelte:16-19,155 opens entity
detail as an in-page EntitySheet slide-over (no URL change, no sidebar
state change). Knowledge.svelte:57-59
and Graph.svelte:464 instead navigate via
location.hash = '#/entity/' + slug, which App.svelte's router resolves to
a full-page EntityDetail route — but 'entity' isn't in navItems
(App.svelte:68-79), so landing there leaves the
sidebar with nothing highlighted and the header showing the raw slug instead
of a section name. Same underlying view
(EntityDetailContent.svelte), three different entry points, two
different navigation models, one of which produces an orphaned page state.
A user who reaches an entity via Knowledge or Graph has no way back to
"where they were" via the sidebar — only browser back.
A2. Two chat entry points with no visual link between them.
The sidebar's "Tasks" section (board → Chat.svelte detail,
isActive={page === 'tasks' || page === 'chat'},
App.svelte:127) and the footer's "Chat drawer"
button (App.svelte:160-163, opens a Sheet
wrapping the same Chat component) are both valid, intentional ways to
reach chat — but nothing in the UI explains they're different modes (drawer
= overlay on current page, keeps your place; Tasks = full navigation). A
first-time user has no way to know which one preserves their current page.
Low-severity, but worth a tooltip/label distinction.
A3. Overview's KPI cards don't drill down.
Overview.svelte shows "Pending
approvals," "Open signals," and fleet-health counts as static cards. The
header badges for the same data (approvalsPending, openSignals,
App.svelte:185-194) ARE clickable and navigate to
Ops/Signals — so the pattern exists in the app, just not on the page whose
entire purpose is summarizing this data. A dashboard card showing a count
that doesn't lead anywhere is a standard drill-down gap.
B. Usability / interaction consistency
B1. Table-row click targets lack keyboard/screen-reader support in one
place but not others.
Entities.svelte:117-120 makes an
entire Table.Row clickable via a bare onclick, with no role,
tabindex, or onkeydown — unreachable and inoperable via keyboard, and
screen readers get no indication the row is interactive. This is a
regression against the codebase's own established pattern: Tasks.svelte
wraps its cards in real <button> elements
(Tasks.svelte:172), Events.svelte's
correlation-group headers are real <button>s
(Events.svelte:110-114), and
Graph.svelte's SVG nodes explicitly add role="button", tabindex="0",
and onkeydown (Graph.svelte:416-421).
Entities is the outlier.
B2. Filter inputs are inconsistently "live" vs. "apply-on-blur," with no
visual cue either way.
Entities.svelte's slug/name filter and Graph.svelte's search box filter
as-you-type (bound to a $derived). But Ops.svelte (implicitly, no text
filters), Audit.svelte's action/entity inputs
(Audit.svelte:71-72),
Agent.svelte's agent_id input
(Agent.svelte:59), and Events.svelte's
type/severity inputs (Events.svelte:92-93)
all use onchange, which only fires on blur — a user typing a filter value
and watching the table sees nothing happen until they click or tab away, and
nothing in the UI (placeholder text, a debounce spinner, an "Enter to
apply" hint) tells them why. Three different pages share the same
onchange-only pattern, so it's a systemic choice, not an oversight — but
it reads as broken on first use.
B3. Entity filter is case-sensitive; nothing else in the app is.
Entities.svelte:50 matches with raw
.includes(), no .toLowerCase(). Graph.svelte's equivalent search
normalizes both sides
(Graph.svelte:175-176:
n.slug.toLowerCase().includes(q)). Slugs are lowercase by convention today,
which is why this hasn't bitten anyone yet, but entity names are
free text and can be mixed-case — a name filter that silently returns zero
results for a correctly-spelled but wrong-case query is a real trap, and the
one-line fix already has a working reference implementation three files
away.
B4. {@html} on server-provided search snippets.
Knowledge.svelte:120-121 renders
hit.snippet with {@html}, justified by a comment claiming the backend's
ts_headline output is pre-sanitized. That's true for Postgres
ts_headline today (it only wraps matched terms in <b> from a
parameterized query), but there's no client-side enforcement of that
invariant — if the search query or snippet source ever changes upstream,
this becomes a stored-XSS vector with no guard at the point of use. Not an
active vulnerability, but a fragile trust boundary worth tightening
defensively (e.g. a tiny allow-list sanitizer) rather than relying on a
comment to hold forever.
C. Accessibility
C1. SessionRail.svelte's delete control is a <span>, not a button.
SessionRail.svelte:54-64
attaches onclick to a <span> for the per-session delete affordance, with
no role, tabindex, or keyboard handler — same defect class as B1, on a
destructive action this time (delete a chat session), which makes it a
notch more important: a keyboard-only user cannot delete a session from
this rail at all.
C2. Same defect, lower stakes, elsewhere.
Scan for the same "clickable non-interactive element" shape found in B1/C1
should be swept across web/src/ once — these two are the ones a full read
surfaced, but the pattern (a <div>/<span> with onclick and no
keyboard path) is exactly the kind of thing that creeps back in per-PR
without a lint rule catching it. Worth checking whether
eslint-plugin-svelte's a11y_click_events_have_key_events /
a11y_no_static_element_interactions rules are enabled and enforced in CI
(the prior summary noted these exist as warnings, not build failures — that
should be confirmed and possibly promoted to errors as part of implementing
C1/B1).
D. Visual / component consistency
D1. One page bypasses the shared Button component.
Agent.svelte's "Refresh" control is a bare
<button class="rounded-md border px-3 py-1.5 text-xs">
(Agent.svelte:73) instead of
Button (variant="outline"), which every other page's refresh/action
buttons use (Ops.svelte, Signals.svelte, Audit.svelte, Events.svelte
all use <Button variant="outline">). Cosmetically near-identical today
(both render as a bordered pill) but it'll drift the moment the design
tokens on Button change, since this one doesn't inherit them.
D2. formatEventLabel is a needless indirection.
Overview.svelte's
formatEventLabel(ev) returns ev.type verbatim — a one-line wrapper with
no formatting logic. Trivial, but noted since it reads as if formatting
were intended and never finished.
E. Loading / empty states
No real findings — this is a strength worth naming rather than "fixing." Every page reviewed (Overview, Entities, Ops, Signals, Events, Agent, Audit, Knowledge, Learning, Graph, Tasks) has both a loading state (skeletons or an implicit empty table) and an explicit, page-appropriate empty-state message (not a generic "no data"). That consistency is worth preserving as new pages get added — call it out in the PR template or a short frontend README note rather than leaving it as tribal knowledge.
Fix plan
Priority order, grounded in user impact:
- C1 (SessionRail delete button) — highest priority: it's a destructive
action that's currently unreachable by keyboard at all. Swap the
<span>for a real<button>witharia-label="Delete session", matching the patternTasks.sveltealready uses for its own delete affordance (Tasks.svelte:195-207— same feature, done correctly, in the same codebase). - B1 (Entities row click) — wrap row content in a
<button>(or addrole="button" tabindex="0" onkeydown) matchingTasks.svelte/Events.svelte's existing pattern. - B3 (case-sensitive filter) — one-line
.toLowerCase()fix on both sides of the.includes()calls inEntities.svelte:50. - A1 (dual entity-detail navigation) — pick one pattern. Recommend
standardizing on the
EntitySheet(in-page, no navigation loss) and changingKnowledge.svelte/Graph.svelte's "View entity detail" actions to open the sheet directly instead of hash-navigating to the orphaned#/entity/:slugroute. If the full-page route is kept for deep-linking (a legitimate reason to keep it), then at minimum highlight the originating section in the sidebar and give the header a real label instead of the bare slug. - A3 (Overview KPI cards not clickable) — wrap the approvals/signals cards in the same click-to-navigate pattern already used by the header badges.
- D1 (Agent.svelte bare button) — swap for
<Button variant="outline">. - B2 (inconsistent live-vs-blur filtering) — standardize on
oninput-driven, debounced (~300ms) filtering across Audit/Agent/Events, matching the already-live feel of Entities/Graph. Lower priority than the above since it's a rough edge, not a defect. - B4 (
{@html}trust boundary) — add a minimal sanitize step (strip everything but the<b>tagsts_headlineemits) at the point of render, so the safety property doesn't depend on the backend never changing. - A2 (chat drawer vs. Tasks unlabeled) and D2 (
formatEventLabel) — cosmetic, do opportunistically or skip. - C2 (a11y lint enforcement) — checked:
web/has no ESLint config and nolint/checknpm script at all (confirmed viapackage.jsonand directory listing). The "a11y warnings" referenced in earlier session notes were editor/IDE diagnostics, not a CI gate. There's nothing to promote from warn to error because no lint infrastructure exists — setting one up is a separate, larger decision (which rules, whether to also addsvelte-checkfor types) that wasn't part of this review's scope. Not done; flagging for a separate decision rather than silently bootstrapping tooling.
Implementation notes (2026-07-11)
- C1, B1, B3, A1, A3, D1, D2, B2, B4, A2 all implemented and verified live in the browser preview against the running stack (see Verification below).
- A1: standardized on
EntitySheetper the plan's recommendation —Knowledge.svelteandGraph.svelte's "View entity detail" now open the sheet instead of hash-navigating to the orphaned#/entity/:slugroute. The full-pageEntityDetailroute/component was left in place (not deleted) as a harmless deep-link fallback — nothing internal navigates to it anymore, but a bookmarked/shared URL still resolves. - B4: used the
dompurifypackage, already adependenciesentry inweb/package.json(unused until now) — no new dependency added. - B2: added a small
debounce()helper toweb/src/lib/utils.tsand switched Audit/Agent/Events' filter inputs fromonchange(blur-only) to debouncedoninput. - Found during verification, not in the original fix list: the
Knowledge page's search never actually renders results (the "Clear"
button appears, confirming
searchedflips totrue, but the content area stays on the "Recently learned" branch) despite the backend request succeeding with real data. Confirmed viagit diffthis isn't caused by anything touched here. Spun off as a separate follow-up rather than fixed in this pass, since it's unrelated to any finding in this review.
Verification
- After each interaction fix (C1, B1, A3): manual keyboard-only pass (Tab + Enter/Space, no mouse) through the affected page in the browser preview.
- After B3: type a filter query in Entities with mixed case against a known-mixed-case entity name; confirm it now matches.
- After A1: confirm both entry paths (Entities row click, Knowledge search hit's linked entity, Graph node's "View entity detail") land on the same UI pattern; confirm sidebar/header state is coherent from whichever page the user started on.
cd web && npm run lint && npm run checkclean after all fixes.- Visual:
npm run build+ spot-check each changed page in the browser preview (light pass, not full regression).
Open questions
- A1's resolution direction (sheet vs. full-page route) is a genuine product call, not just a bug fix — needs a decision before implementing, not just "proceed." Recommendation given above (standardize on the sheet), but flagging it explicitly since it changes user-visible behavior for Knowledge and Graph, not just Entities.
- Whether to promote a11y lint rules from warn to error (C2) is a policy call for the repo, worth a one-line "yes/no" rather than silently doing it.