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..ed77335 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={ + + } + /> ) : ( <> @@ -267,12 +276,14 @@ export function FindingsPage() { {capabilities.triageFindings ? ( ) : null} @@ -290,12 +301,14 @@ export function FindingsPage() { {capabilities.triageFindings ? ( ) : null} ); @@ -143,11 +159,20 @@ export function LeaderboardPage() { {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 + + } + /> ) : (
- 0 && data.rows.every((r) => selected.has(r.id))} - onChange={() => toggleAll(data.rows)} - /> + Severity
- toggleOne(f.id)} - /> + diff --git a/web/src/pages/Leaderboard.tsx b/web/src/pages/Leaderboard.tsx index 7bae2a0..31a2304 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"; @@ -121,11 +121,27 @@ export function LeaderboardPage() { const Th = ({ k, label, cls }: { k: SortKey; label: string; cls?: string }) => ( toggleSort(k)} aria-sort={sort.key === k ? (sort.dir === "asc" ? "ascending" : "descending") : "none"} > - {label} - {sort.key === k ? (sort.dir === "desc" ? " ▾" : " ▴") : ""} + {/* 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). */} +
@@ -164,21 +189,31 @@ export function LeaderboardPage() { - {rows.map((a) => ( - selectAuthor(a.author === selected ? null : a.author)} - > - - - - - - - - - + + + + + + + + + - ))} + ); + })}
{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)}%`} + {rows.map((a) => { + const isSel = a.author === selected; + return ( +
+ {/* 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)}{a.findings}{a.findings_per_pr.toFixed(1)} 0 ? "crit" : "zero"}`} data-label="Critical">{a.critical}{a.acceptance == null ? "—" : `${Math.round(a.acceptance * 100)}%`}
)} @@ -293,24 +329,24 @@ function AuthorDrilldown({ author, days, onClose }: { author: string; days: numb {data.prs.map((pr) => ( - + #{pr.number} - + {pr.owner}/{pr.repo} - + - + - {pr.total_findings} - {relativeTime(pr.latest_at)} + {pr.total_findings} + {relativeTime(pr.latest_at)} ))} 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." + /> ) : ( @@ -198,7 +202,7 @@ export function PRDetailPage() { {f.path ?? ""} {f.line ? :{f.line} : null} -
+
{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..8930ece 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -1894,12 +1894,110 @@ 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); +} +/* On short / phone viewports (or with large text / zoom) the recovery content + can exceed the screen; top-align so the actions are never centered partly + off-screen and the page scrolls naturally. */ +@media (max-width: 640px) { + .error-page { + align-items: flex-start; + } +} +.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 +2867,62 @@ a.dot-marker:hover, grid-column: auto; } } + +/* 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