Skip to content
131 changes: 123 additions & 8 deletions web/src/components/states.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="empty">
<div className={`empty${illustration ? " has-art" : ""}`}>
{illustration ? (
<div className="empty-art" aria-hidden="true">
{illustration}
</div>
) : null}
<div className="title">{title}</div>
{hint ? <div>{hint}</div> : null}
{hint ? <div className="empty-hint">{hint}</div> : null}
{action ? <div className="empty-cta">{action}</div> : null}
</div>
);
}

// ── 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 (
<svg viewBox="0 0 96 96" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="20" y="14" width="42" height="54" rx="4" className="art-soft" />
<path d="M30 28h22M30 38h22M30 48h12" className="art-soft" />
<circle cx="58" cy="58" r="15" className="art-accent" />
<path d="m69 69 9 9" className="art-accent" />
</svg>
);
}

/** Shield with a check — "all clear, nothing flagged". */
export function EmptyShieldArt() {
return (
<svg viewBox="0 0 96 96" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M48 14 26 22v20c0 16 10 26 22 30 12-4 22-14 22-30V22z" className="art-soft" />
<path d="m39 46 6 6 13-13" className="art-accent" />
</svg>
);
}

/** Stacked bars / podium — "no activity to rank yet". */
export function EmptyChartArt() {
return (
<svg viewBox="0 0 96 96" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 78h64" className="art-soft" />
<rect x="24" y="50" width="14" height="24" rx="2" className="art-soft" />
<rect x="41" y="34" width="14" height="40" rx="2" className="art-accent" />
<rect x="58" y="58" width="14" height="16" rx="2" className="art-soft" />
</svg>
);
}

/** Empty open box — generic "nothing here". */
export function EmptyBoxArt() {
return (
<svg viewBox="0 0 96 96" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 36 48 22l32 14-32 14z" className="art-soft" />
<path d="M16 36v26l32 14 32-14V36" className="art-soft" />
<path d="M48 50v26" className="art-accent" />
<path d="M32 43v9" className="art-accent" />
</svg>
);
}

export function LoadingState({ label = "Loading…" }: { label?: string }) {
return (
<div className="center-pad">
Expand Down Expand Up @@ -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 (
<section className="card">
<div className="empty">
<div className="mono" style={{ color: "var(--text-3)", fontSize: 11, letterSpacing: "0.12em", marginBottom: 8 }}>
404 · NOT FOUND
<div className="empty has-art">
<div className="empty-art" aria-hidden="true">
<EmptyBoxArt />
</div>
<div className="mono empty-code">404 · NOT FOUND</div>
<div className="title">{message}</div>
{action ? <div className="empty-cta">{action}</div> : null}
</div>
</section>
);
}

// 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 (
<div className="error-page" role="alert">
<div className="error-page-inner">
<div className="error-brand">
<LogoIcon style={{ width: 30, height: 30 }} />
<span className="error-wordmark">DiffSentry</span>
Comment thread
diffsentry[bot] marked this conversation as resolved.
</div>
<div className="empty-art error-art" aria-hidden="true">
{is404 ? <EmptyBoxArt /> : <EmptyShieldArt />}
</div>
<h1 className="error-title">{title}</h1>
<p className="error-detail">{detail}</p>
<div className="error-actions">
<Link className="btn btn-primary" to="/overview">
Back to overview
</Link>
<button type="button" className="btn btn-ghost" onClick={() => window.location.reload()}>
Reload page
</button>
</div>
</div>
</div>
);
}

/** Render-prop wrapper that handles loading / error before exposing data. */
export function QueryBoundary<T>({
query,
Expand Down
41 changes: 27 additions & 14 deletions web/src/pages/Findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -259,20 +259,31 @@ export function FindingsPage() {
bodyClass="flush"
>
{data.rows.length === 0 ? (
<EmptyState title="No findings match" hint="Try widening the filters above." />
<EmptyState
illustration={<EmptySearchArt />}
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={
<button type="button" className="btn btn-primary" onClick={reset}>
Clear filters
</button>
}
/>
) : (
<>
<table className="tbl rail">
<thead>
<tr>
{capabilities.triageFindings ? (
<th style={{ width: 28 }}>
<input
type="checkbox"
aria-label="Select all on this page"
checked={data.rows.length > 0 && data.rows.every((r) => selected.has(r.id))}
onChange={() => toggleAll(data.rows)}
/>
<label className="check-tap">
<input
type="checkbox"
aria-label="Select all on this page"
checked={data.rows.length > 0 && data.rows.every((r) => selected.has(r.id))}
onChange={() => toggleAll(data.rows)}
/>
</label>
</th>
) : null}
<th>Severity</th>
Expand All @@ -290,12 +301,14 @@ export function FindingsPage() {
<tr key={f.id} data-sev={(f.severity ?? "").toLowerCase()}>
{capabilities.triageFindings ? (
<td data-label="Select">
<input
type="checkbox"
aria-label={`Select finding ${f.id}`}
checked={selected.has(f.id)}
onChange={() => toggleOne(f.id)}
/>
<label className="check-tap">
<input
type="checkbox"
aria-label={`Select finding ${f.id}`}
checked={selected.has(f.id)}
onChange={() => toggleOne(f.id)}
/>
</label>
</td>
) : null}
<td data-label="Severity">
Expand Down
92 changes: 64 additions & 28 deletions web/src/pages/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -121,11 +121,27 @@ export function LeaderboardPage() {
const Th = ({ k, label, cls }: { k: SortKey; label: string; cls?: string }) => (
<th
Comment thread
diffsentry[bot] marked this conversation as resolved.
className={`sortable${cls ? " " + cls : ""}${sort.key === k ? " sorted" : ""}`}
onClick={() => toggleSort(k)}
aria-sort={sort.key === k ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
Comment thread
diffsentry[bot] marked this conversation as resolved.
>
{label}
<span className="sort-caret">{sort.key === k ? (sort.dir === "desc" ? " ▾" : " ▴") : ""}</span>
{/* A real <button> carries the interaction so sorting uses native
keyboard/focus semantics; aria-sort stays on the <th>. 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). */}
<button
type="button"
className="th-sort"
onClick={() => toggleSort(k)}
aria-label={
sort.key === k
? `${label}, sorted ${sort.dir === "asc" ? "ascending" : "descending"}. Activate to sort ${sort.dir === "asc" ? "descending" : "ascending"}.`
: `${label}, not sorted. Activate to sort ascending.`
}
>
{label}
<span className="sort-caret" aria-hidden="true">{sort.key === k ? (sort.dir === "desc" ? " ▾" : " ▴") : ""}</span>
</button>
</th>
);

Expand All @@ -143,11 +159,20 @@ export function LeaderboardPage() {

<Card
title="By author"
subtitle={`${data.authors.length} author${data.authors.length === 1 ? "" : "s"} · click a row to drill in · click a column to sort`}
subtitle={`${data.authors.length} author${data.authors.length === 1 ? "" : "s"} · click an author to drill in · click a column to sort`}
bodyClass="flush"
>
{data.authors.length === 0 ? (
<EmptyState title="No review activity yet" hint="Author stats appear once PRs are reviewed in this window." />
<EmptyState
illustration={<EmptyChartArt />}
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={
<Link className="btn btn-primary" to="/overview">
View repositories
</Link>
}
/>
) : (
<table className="tbl">
<thead>
Expand All @@ -164,21 +189,31 @@ export function LeaderboardPage() {
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr
key={a.author}
className={`clickable${a.author === selected ? " selected" : ""}`}
onClick={() => selectAuthor(a.author === selected ? null : a.author)}
>
<td className="strong">{a.author}</td>
<td className="num">{a.prs_reviewed}</td>
<td className="num muted">{a.reviews}</td>
<td className="num">{a.avg_risk == null ? "—" : Math.round(a.avg_risk)}</td>
<td className="num">{a.findings}</td>
<td className="num muted">{a.findings_per_pr.toFixed(1)}</td>
<td className={`num ${a.critical > 0 ? "crit" : "zero"}`}>{a.critical}</td>
<td className="num">{a.acceptance == null ? "—" : `${Math.round(a.acceptance * 100)}%`}</td>
<td className="right" style={{ width: 130 }}>
{rows.map((a) => {
const isSel = a.author === selected;
return (
<tr key={a.author} className={isSel ? "row-selected" : undefined}>
<td className="strong cell-primary" data-label="Author">
{/* Selection rides on a real button (keyboard/focus +
Comment thread
diffsentry[bot] marked this conversation as resolved.
Comment thread
diffsentry[bot] marked this conversation as resolved.
aria-pressed) rather than a faux-button row. */}
<button
type="button"
className="row-link"
aria-pressed={isSel}
aria-label={`${isSel ? "Hide" : "Show"} details for ${a.author}`}
onClick={() => selectAuthor(isSel ? null : a.author)}
>
Comment thread
diffsentry[bot] marked this conversation as resolved.
{a.author}
</button>
</td>
<td className="num" data-label="PRs">{a.prs_reviewed}</td>
<td className="num muted" data-label="Reviews">{a.reviews}</td>
<td className="num" data-label="Avg risk">{a.avg_risk == null ? "—" : Math.round(a.avg_risk)}</td>
<td className="num" data-label="Findings">{a.findings}</td>
<td className="num muted" data-label="Per PR">{a.findings_per_pr.toFixed(1)}</td>
<td className={`num ${a.critical > 0 ? "crit" : "zero"}`} data-label="Critical">{a.critical}</td>
<td className="num" data-label="Accepted">{a.acceptance == null ? "—" : `${Math.round(a.acceptance * 100)}%`}</td>
<td className="right trend-col" data-label="Trend">
<LineSpark
values={authorVolume(data.series, a.author, days)}
title={`${a.author} · reviews/day`}
Expand All @@ -187,7 +222,8 @@ export function LeaderboardPage() {
/>
</td>
</tr>
))}
);
})}
</tbody>
</table>
)}
Expand Down Expand Up @@ -293,24 +329,24 @@ function AuthorDrilldown({ author, days, onClose }: { author: string; days: numb
<tbody>
{data.prs.map((pr) => (
<tr key={`${pr.owner}/${pr.repo}#${pr.number}`}>
<td className="mono">
<td className="mono cell-primary" data-label="PR">
<Link className="link" to={`/repos/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/pr/${pr.number}`}>
#{pr.number}
</Link>
</td>
<td className="mono muted">
<td className="mono muted" data-label="Repo">
<Link className="link" to={`/repos/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}`}>
{pr.owner}/{pr.repo}
</Link>
</td>
<td>
<td data-label="Risk">
<RiskBadge level={pr.latest_risk_level} score={pr.latest_risk_score} />
</td>
<td>
<td data-label="Outcome">
<ApprovalBadge approval={pr.latest_approval} />
</td>
<td className="num">{pr.total_findings}</td>
<td className="right muted">{relativeTime(pr.latest_at)}</td>
<td className="num" data-label="Findings">{pr.total_findings}</td>
<td className="right muted" data-label="Latest">{relativeTime(pr.latest_at)}</td>
</tr>
))}
</tbody>
Expand Down
Loading
Loading