From c10145380b9c715286edf923ef61448531eff3ee Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 28 Jun 2026 14:39:18 -0700 Subject: [PATCH 1/8] UI polish: branded states, mobile reflow, WCAG AA Final craftsmanship pass across the dashboard SPA: Branded states (web/src/components/states.tsx) - EmptyState gains optional light line-art illustrations and a next-step CTA (backward compatible: existing title/hint callers unchanged). Added four on-brand illustrations (search, shield-check, chart, box). - NotFoundState is branded with the box illustration + CTA slot. - New RouteErrorBoundary: a friendly, full-page recovery screen wired as the router's errorElement so render/loader crashes never show a blank page. - Findings / Leaderboard / PRDetail empty states now use illustrations + CTAs. Mobile reflow (Leaderboard + base.css) - Leaderboard table + author-drilldown PRs table now carry data-label / cell-primary, so they reflow into stacked cards on phones via the existing 640px pattern (Findings / PRDetail already did). - Touch targets: interactive controls get a >=44px hit area at <=640px (buttons, segmented pickers, PR tabs, nav rows, bulk-select checkboxes). WCAG AA + keyboard (tokens.css + Leaderboard) - Darkened/lightened the muted text tokens to clear AA: --text-3 >= 4.5:1 (normal text) and --text-4 >= 3:1 (graphical floor) on every surface they render on, in both themes. --text-2 already passed and is unchanged. - Sortable column headers and clickable author rows are now keyboard-operable (tabindex + Enter/Space) with aria-sort / aria-pressed. Verified: web build + smoke:dashboard pass; mobile widths spot-checked at 390px (reflow, branded empty state, 404); no console errors; no unfocusable clickables. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/components/states.tsx | 131 +++++++++++++++++++++++++++++++--- web/src/pages/Findings.tsx | 13 +++- web/src/pages/Leaderboard.tsx | 60 +++++++++++----- web/src/pages/PRDetail.tsx | 10 ++- web/src/router.tsx | 21 +++--- web/src/styles/base.css | 126 ++++++++++++++++++++++++++++++++ web/src/styles/tokens.css | 16 +++-- 7 files changed, 334 insertions(+), 43 deletions(-) diff --git a/web/src/components/states.tsx b/web/src/components/states.tsx index 763c5f7..5e9207c 100644 --- a/web/src/components/states.tsx +++ b/web/src/components/states.tsx @@ -1,20 +1,91 @@ import { useEffect, useRef, type CSSProperties, type ReactNode } from "react"; +import { Link, isRouteErrorResponse, useRouteError } from "react-router-dom"; import type { UseQueryResult } from "@tanstack/react-query"; import { ApiError } from "../api/client"; -import { AlertIcon } from "./icons"; +import { AlertIcon, LogoIcon } from "./icons"; // Shared loading / error / empty states so every screen handles the three // non-happy paths consistently. -export function EmptyState({ title, hint }: { title: ReactNode; hint?: ReactNode }) { +export function EmptyState({ + title, + hint, + illustration, + action, +}: { + title: ReactNode; + hint?: ReactNode; + /** A light, on-brand line illustration (see Empty*Art below). Optional so the + * compact empty states embedded in cards/donuts can stay illustration-free. */ + illustration?: ReactNode; + /** A next-step CTA (a link or button) that points the user somewhere useful. */ + action?: ReactNode; +}) { return ( -
+
+ {illustration ? ( + + ) : null}
{title}
- {hint ?
{hint}
: null} + {hint ?
{hint}
: null} + {action ?
{action}
: null}
); } +// ── Branded empty-state illustrations ──────────────────────────────── +// Light line-art, drawn with currentColor so the .empty-art rule can tint them +// (a muted base stroke with a single accent-keyed highlight). Decorative only — +// the surrounding EmptyState already carries the text, so these are aria-hidden. + +/** Magnifier over a document — "nothing matched your search/filters". */ +export function EmptySearchArt() { + return ( + + + + + + + ); +} + +/** Shield with a check — "all clear, nothing flagged". */ +export function EmptyShieldArt() { + return ( + + + + + ); +} + +/** Stacked bars / podium — "no activity to rank yet". */ +export function EmptyChartArt() { + return ( + + + + + + + ); +} + +/** Empty open box — generic "nothing here". */ +export function EmptyBoxArt() { + return ( + + + + + + + ); +} + export function LoadingState({ label = "Loading…" }: { label?: string }) { return (
@@ -191,19 +262,63 @@ export function ErrorState({ error }: { error: unknown }) { ); } -export function NotFoundState({ message }: { message: string }) { +export function NotFoundState({ message, action }: { message: string; action?: ReactNode }) { return (
-
-
- 404 · NOT FOUND +
+ +
404 · NOT FOUND
{message}
+ {action ?
{action}
: null}
); } +// Full-page error boundary wired as the router's errorElement. Catches render +// and loader errors thrown anywhere under the Shell and shows a friendly, +// on-brand recovery page instead of a blank screen or React's dev overlay. +export function RouteErrorBoundary() { + const error = useRouteError(); + const routeResp = isRouteErrorResponse(error) ? error : null; + const is404 = routeResp?.status === 404; + + const title = is404 ? "We couldn't find that page" : "Something went wrong"; + const detail = is404 + ? "The page may have moved, or the link might be out of date." + : routeResp + ? `${routeResp.status} ${routeResp.statusText}`.trim() + : error instanceof Error + ? error.message + : "An unexpected error interrupted this page."; + + return ( +
+
+
+ + DiffSentry +
+ +

{title}

+

{detail}

+
+ + Back to overview + + +
+
+
+ ); +} + /** Render-prop wrapper that handles loading / error before exposing data. */ export function QueryBoundary({ query, diff --git a/web/src/pages/Findings.tsx b/web/src/pages/Findings.tsx index 4e71eda..c234a61 100644 --- a/web/src/pages/Findings.tsx +++ b/web/src/pages/Findings.tsx @@ -6,7 +6,7 @@ import { Card, PageHeader } from "../components/primitives"; import { SeverityBadge, TriageBadge } from "../components/badges"; import { TriageMenu } from "../components/TriageControls"; import { useAuth } from "../auth/useAuth"; -import { EmptyState, QueryBoundary, SkeletonTable } from "../components/states"; +import { EmptySearchArt, EmptyState, QueryBoundary, SkeletonTable } from "../components/states"; import { pluralize, relativeTime } from "../lib/format"; import type { FindingExplorerRow } from "../api/types"; @@ -259,7 +259,16 @@ export function FindingsPage() { bodyClass="flush" > {data.rows.length === 0 ? ( - + } + title="No findings match" + hint="No findings line up with these filters. Try widening the severity, source, or age, or clear everything to start fresh." + action={ + + } + /> ) : ( <> diff --git a/web/src/pages/Leaderboard.tsx b/web/src/pages/Leaderboard.tsx index 7bae2a0..5ff8ee7 100644 --- a/web/src/pages/Leaderboard.tsx +++ b/web/src/pages/Leaderboard.tsx @@ -5,7 +5,7 @@ import { Breadcrumbs } from "../components/Shell"; import { Card, Metric, PageHeader } from "../components/primitives"; import { ApprovalBadge, RiskBadge } from "../components/badges"; import { Donut, Hbar, LineSpark } from "../components/charts"; -import { EmptyState, QueryBoundary } from "../components/states"; +import { EmptyChartArt, EmptyState, QueryBoundary } from "../components/states"; import { buildDaySeries, relativeTime, type DayBin } from "../lib/format"; import type { AuthorDayRow, AuthorStatRow } from "../api/types"; @@ -122,6 +122,13 @@ export function LeaderboardPage() { ); From 2d526b53fea90f526b67895b0707fbda46ce2682 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 28 Jun 2026 14:59:58 -0700 Subject: [PATCH 6/8] Leaderboard: move trend-column width to CSS so mobile reflow can override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Trend cell carried an inline style={{ width: 130 }}, whose specificity beats the card-reflow's `.tbl td { width: 100% }`, pinning the cell to 130px on phones. Moved it to `.tbl td.trend-col` scoped under @media (min-width: 641px), so desktop keeps the 130px column and the ≤640px reflow expands it to full card width. Verified in-browser: 130px at 1381px, full width at 390px. Per DiffSentry. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/pages/Leaderboard.tsx | 2 +- web/src/styles/base.css | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/pages/Leaderboard.tsx b/web/src/pages/Leaderboard.tsx index 9e8373c..31a2304 100644 --- a/web/src/pages/Leaderboard.tsx +++ b/web/src/pages/Leaderboard.tsx @@ -213,7 +213,7 @@ export function LeaderboardPage() { - {capabilities.triageFindings ? ( ) : null} @@ -299,12 +301,14 @@ export function FindingsPage() { {capabilities.triageFindings ? ( ) : null}
toggleSort(k)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleSort(k); + } + }} + tabIndex={0} aria-sort={sort.key === k ? (sort.dir === "asc" ? "ascending" : "descending") : "none"} > {label} @@ -147,7 +154,16 @@ export function LeaderboardPage() { bodyClass="flush" > {data.authors.length === 0 ? ( - + } + title="No review activity yet" + hint="Author stats appear once DiffSentry reviews PRs in this window. Try a wider window, or open a PR to kick off the first review." + action={ + + View repositories + + } + /> ) : ( @@ -168,17 +184,27 @@ export function LeaderboardPage() { selectAuthor(a.author === selected ? null : a.author)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectAuthor(a.author === selected ? null : a.author); + } + }} > - - - - - - - - - + + + + + + + + - - - - - - + + ))} diff --git a/web/src/pages/PRDetail.tsx b/web/src/pages/PRDetail.tsx index db5aa5b..e6545e2 100644 --- a/web/src/pages/PRDetail.tsx +++ b/web/src/pages/PRDetail.tsx @@ -7,7 +7,7 @@ import { Card, PageHeader } from "../components/primitives"; import { ActionBar } from "../components/ActionBar"; import { ApprovalBadge, RiskBadge, SeverityBadge, TriageBadge } from "../components/badges"; import { TriageMenu } from "../components/TriageControls"; -import { EmptyState, QueryBoundary } from "../components/states"; +import { EmptyShieldArt, EmptyState, QueryBoundary } from "../components/states"; import { Markdown } from "../components/Markdown"; import { DiffViewer } from "../components/DiffViewer"; import { useEventStream, type StreamEnvelope } from "../realtime/useEventStream"; @@ -172,7 +172,11 @@ export function PRDetailPage() { bodyClass="flush" > {a.findings.length === 0 ? ( - + } + title="No findings — all clear" + hint="Nothing was flagged across any review of this PR." + /> ) : (
{a.author}{a.prs_reviewed}{a.reviews}{a.avg_risk == null ? "—" : Math.round(a.avg_risk)}{a.findings}{a.findings_per_pr.toFixed(1)} 0 ? "crit" : "zero"}`}>{a.critical}{a.acceptance == null ? "—" : `${Math.round(a.acceptance * 100)}%`} + {a.author}{a.prs_reviewed}{a.reviews}{a.avg_risk == null ? "—" : Math.round(a.avg_risk)}{a.findings}{a.findings_per_pr.toFixed(1)} 0 ? "crit" : "zero"}`} data-label="Critical">{a.critical}{a.acceptance == null ? "—" : `${Math.round(a.acceptance * 100)}%`} {data.prs.map((pr) => (
+ #{pr.number} + {pr.owner}/{pr.repo} + + {pr.total_findings}{relativeTime(pr.latest_at)}{pr.total_findings}{relativeTime(pr.latest_at)}
@@ -198,7 +202,7 @@ export function PRDetailPage() { {f.path ?? ""} {f.line ? :{f.line} : null} - no longer carries role/tabindex/onKeyDown. Row keeps a visual selected state (.row-selected). Subtitle updated to "click an author to drill in". - CSS: add .th-sort / .row-link / .row-selected, retire .tbl tr.clickable rules; mobile 44px touch target now targets .row-link. web build + smoke:dashboard pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/pages/Leaderboard.tsx | 54 ++++++++++++++++------------------- web/src/styles/base.css | 37 +++++++++++++++++++----- 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/web/src/pages/Leaderboard.tsx b/web/src/pages/Leaderboard.tsx index 5ff8ee7..f64a92a 100644 --- a/web/src/pages/Leaderboard.tsx +++ b/web/src/pages/Leaderboard.tsx @@ -121,18 +121,14 @@ export function LeaderboardPage() { const Th = ({ k, label, cls }: { k: SortKey; label: string; cls?: string }) => ( ); @@ -150,7 +146,7 @@ export function LeaderboardPage() { {data.authors.length === 0 ? ( @@ -180,23 +176,22 @@ export function LeaderboardPage() { - {rows.map((a) => ( - selectAuthor(a.author === selected ? null : a.author)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - selectAuthor(a.author === selected ? null : a.author); - } - }} - > - + {rows.map((a) => { + const isSel = a.author === selected; + return ( + + @@ -213,7 +208,8 @@ export function LeaderboardPage() { /> - ))} + ); + })}
+
{f.title ?? "—"}
{f.body ? (
diff --git a/web/src/router.tsx b/web/src/router.tsx index 3053d72..ad6be65 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from "react"; -import { createBrowserRouter, Navigate } from "react-router-dom"; +import { createBrowserRouter, Link, Navigate } from "react-router-dom"; import { DEMO, DEMO_BASENAME } from "./demo/mode"; import { Shell } from "./components/Shell"; import { useAuth } from "./auth/useAuth"; @@ -26,7 +26,7 @@ import { AuditPage } from "./pages/Audit"; import { NotificationsPage } from "./pages/Notifications"; import { ApiTokensPage } from "./pages/ApiTokens"; import { WebhooksPage } from "./pages/Webhooks"; -import { LoadingState, NotFoundState } from "./components/states"; +import { LoadingState, NotFoundState, RouteErrorBoundary } from "./components/states"; // Code editor (CodeMirror) only loads on the config screen, so split it out of // the main bundle. @@ -35,12 +35,14 @@ const RepoConfigPage = lazy(() => import("./pages/RepoConfig").then((m) => ({ de function NotFoundPage() { return (
- - + + ← Back to overview + + } + />
); } @@ -62,6 +64,9 @@ export const router = createBrowserRouter( { path: "/share/impact/:id", element: }, { element: , + // A render/loader error in any child renders the friendly recovery page + // instead of a blank screen. + errorElement: , children: [ { path: "/", element: }, { path: "/ops", element: }, diff --git a/web/src/styles/base.css b/web/src/styles/base.css index cde7498..bde8627 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -1894,12 +1894,102 @@ a.dot-marker:hover, color: var(--text-3); font-size: 13px; } +.empty.has-art { + padding: 30px 20px 34px; +} .empty .title { color: var(--text-1); font-size: 14px; font-weight: 500; margin-bottom: 6px; } +/* Light line-art: a muted base stroke (--text-4) with a single accent-keyed + highlight (.art-accent). Decorative — the EmptyState text carries meaning. */ +.empty-art { + display: flex; + justify-content: center; + margin-bottom: 15px; + color: var(--text-4); +} +.empty-art svg { + width: 84px; + height: 84px; +} +.empty-art .art-accent { + stroke: var(--accent); + opacity: 0.9; +} +.empty-hint { + max-width: 48ch; + margin: 0 auto; + line-height: 1.5; +} +.empty-cta { + margin-top: 18px; +} +.empty-code { + color: var(--text-3); + font-size: 11px; + letter-spacing: 0.12em; + margin-bottom: 8px; +} + +/* ── Full-page error / 404 boundary ────────────────────────────── */ +.error-page { + min-height: 100vh; + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + background: radial-gradient(ellipse 1200px 500px at 50% -10%, var(--page-glow), transparent 60%), var(--bg); +} +.error-page-inner { + text-align: center; + max-width: 460px; +} +.error-brand { + display: inline-flex; + align-items: center; + gap: 10px; + margin-bottom: 22px; + color: var(--accent-bright); +} +.error-brand .logo { + filter: drop-shadow(0 0 8px var(--accent-glow)); +} +.error-wordmark { + font-family: var(--font-display); + font-weight: 600; + font-size: 17px; + color: var(--text); + letter-spacing: -0.015em; +} +.error-art { + margin-bottom: 18px; +} +.error-art svg { + width: 96px; + height: 96px; +} +.error-title { + font-size: 22px; + font-weight: 620; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.error-detail { + color: var(--text-2); + font-size: 13.5px; + line-height: 1.55; + margin-bottom: 22px; +} +.error-actions { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} /* ── Markdown body ─────────────────────────────────────────────── */ .md-body { @@ -2769,6 +2859,42 @@ a.dot-marker:hover, grid-column: auto; } } + +/* ── Touch targets (phones) ───────────────────────────────────────── + WCAG 2.5.5 / platform HIG: interactive controls get a comfortable ≥44px + hit area on touch-sized viewports. Tables are already in card mode here, so + each row-card and its inline actions are easy to tap. */ +@media (max-width: 640px) { + .btn { + min-height: 44px; + } + .btn-link { + min-height: 44px; + display: inline-flex; + align-items: center; + } + /* Window / segmented pickers + PR tabs become full-height tap targets. */ + .seg-btn, + .prtab { + min-height: 44px; + } + /* Sidebar/drawer nav rows. */ + .snav { + min-height: 44px; + } + /* Roomier checkboxes (findings bulk-select) and clickable row-cards. */ + .tbl input[type="checkbox"] { + width: 20px; + height: 20px; + } + .tbl td[data-label="Select"] { + padding-top: 12px; + padding-bottom: 12px; + } + .tbl tbody tr.clickable { + min-height: 44px; + } +} /* ─── Theme / density controls (W1 theming) ────────────────────────── */ /* Segmented control — a row of options where exactly one is active. Used for the theme picker (dark/light/system) and density (comfortable/compact). */ diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 8c61a79..855fde4 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -34,12 +34,16 @@ --line-strong: #2a3043; --line-soft: #141824; - /* Text */ + /* Text. The muted tiers (--text-3 / --text-4) are tuned to clear WCAG AA on + the surfaces they actually render on: --text-3 ≥ 4.5:1 (normal-text AA) + against --bg / --bg-elev / --bg-elev-2 / --bg-hover; --text-4 ≥ 3:1 (the + graphical / large-text floor) for faint placeholder / zero / tick text. + --text-2 already clears AA and is left as-is. */ --text: #f1f3f8; --text-1: #d8dbe5; --text-2: #9aa0b2; - --text-3: #6a7085; - --text-4: #40465a; + --text-3: #82879b; + --text-4: #616a88; /* Brand. These six are also what an admin accent override replaces inline. */ --accent: #5a8dff; @@ -154,8 +158,10 @@ --text: #11151f; --text-1: #2b3142; --text-2: #565d72; - --text-3: #78808f; - --text-4: #a7aebd; + /* Darkened to clear WCAG AA: --text-3 ≥ 4.5:1, --text-4 ≥ 3:1 on light + surfaces (--bg / --bg-elev / --bg-elev-2 / --bg-deep). See dark theme. */ + --text-3: #646b79; + --text-4: #7e889e; /* Deepen the accent a touch so it carries on white surfaces. */ --accent: #3d6ee8; From a998470e45d5ad71ac187f5aa8f941a501818d6b Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 28 Jun 2026 14:43:59 -0700 Subject: [PATCH 2/8] Leaderboard a11y: native button semantics for sort + row drill-in Address DiffSentry review: replace the focusable-th / role="button"-row workarounds with real nested controls (ARIA sortable-table pattern). - Sortable column headers render a
; aria-sort stays on the th. Removed tabIndex/onKeyDown from the header. - Author drill-in moves onto a real
toggleSort(k)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleSort(k); - } - }} - tabIndex={0} aria-sort={sort.key === k ? (sort.dir === "asc" ? "ascending" : "descending") : "none"} > - {label} - {sort.key === k ? (sort.dir === "desc" ? " ▾" : " ▴") : ""} + {/* A real . */} +
{a.author}
+ {/* Selection rides on a real button (keyboard/focus + + aria-pressed) rather than a faux-button row. */} + + {a.prs_reviewed} {a.reviews} {a.avg_risk == null ? "—" : Math.round(a.avg_risk)}
)} diff --git a/web/src/styles/base.css b/web/src/styles/base.css index bde8627..f1558dd 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -2891,7 +2891,10 @@ a.dot-marker:hover, padding-top: 12px; padding-bottom: 12px; } - .tbl tbody tr.clickable { + /* Drill-in author button gets a full-height tap target. */ + .tbl tbody tr .row-link { + display: inline-flex; + align-items: center; min-height: 44px; } } @@ -5000,27 +5003,47 @@ input[type="checkbox"].learn-check { /* ── Analytics: sortable + clickable tables, sparkline, drill-down ── */ .tbl thead th.sortable { - cursor: pointer; user-select: none; white-space: nowrap; } -.tbl thead th.sortable:hover { +/* The interactive control is a real
. */} - . The + button's accessible name also spells out the current sort state + and next action, since a tabbing screen-reader user hears the + button name rather than the th's aria-sort, and the caret glyph + is decorative (aria-hidden). */} + {a.findings_per_pr.toFixed(1)} 0 ? "crit" : "zero"}`} data-label="Critical">{a.critical} {a.acceptance == null ? "—" : `${Math.round(a.acceptance * 100)}%`} + Date: Sun, 28 Jun 2026 15:08:29 -0700 Subject: [PATCH 7/8] a11y: real 44px checkbox tap target + sort-button focus ring DiffSentry re-review of the mobile/sort work: - Bulk-select checkbox: sizing the alone didn't enlarge its hit area. Wrapped each checkbox in a
- 0 && data.rows.every((r) => selected.has(r.id))} - onChange={() => toggleAll(data.rows)} - /> + Severity
- toggleOne(f.id)} - /> + diff --git a/web/src/styles/base.css b/web/src/styles/base.css index 1f9c441..035b438 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -2860,6 +2860,17 @@ a.dot-marker:hover, } } +/* Tap-target wrapper around bulk-select checkboxes. Tight on desktop; the + touch-target block below expands it to a 44px hit area on phones while the + checkbox itself stays visually 20px. The wrapping