@@ -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 also toggles the
+ checkbox natively, so the whole area is clickable. */
+.check-tap {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+/* ── 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;
+ }
+ /* Bulk-select: keep the checkbox visually 20px, but expand its label wrapper
+ to a full 44px tap target (the input alone wouldn't enlarge the hit area). */
+ .tbl input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ }
+ .tbl td[data-label="Select"] {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+ .tbl th .check-tap,
+ .tbl td[data-label="Select"] .check-tap {
+ min-width: 44px;
+ min-height: 44px;
+ }
+ /* Drill-in author button gets a full-height tap target. */
+ .tbl tbody tr .row-link {
+ display: inline-flex;
+ align-items: center;
+ 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). */
@@ -4874,29 +5028,80 @@ 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 filling the header cell, so
+ sorting keeps native keyboard/focus semantics; aria-sort lives on the th. */
+.tbl thead th.sortable .th-sort {
+ /* Full native-button reset so the control adopts the header cell's own
+ presentation (no default chrome / padding in Safari & Firefox) and the
+ clickable area fills the cell. */
+ appearance: none;
+ -webkit-appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 2px;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ color: inherit;
+ letter-spacing: inherit;
+ text-transform: inherit;
+ cursor: pointer;
+}
+/* Numeric / right-aligned headers keep their label flush right. */
+.tbl thead th.num .th-sort,
+.tbl thead th.right .th-sort {
+ justify-content: flex-end;
+}
+/* The reset strips native chrome, so give keyboard focus an explicit ring.
+ Inset (negative offset) so it can't be clipped by the card's overflow:hidden
+ at the header edge; --accent-bright carries in both themes. */
+.tbl thead th.sortable .th-sort:focus-visible {
+ outline: 2px solid var(--accent-bright);
+ outline-offset: -2px;
+ border-radius: var(--r-xs);
+}
+.tbl thead th.sortable:hover .th-sort {
color: var(--text-1);
}
-.tbl thead th.sortable.sorted {
+.tbl thead th.sortable.sorted .th-sort {
color: var(--accent-bright);
}
.tbl thead th.sortable .sort-caret {
color: var(--accent);
font-size: 10px;
}
-.tbl tbody tr.clickable {
+/* Author drill-in: a real button in the first cell carries the interaction;
+ the row only reflects the selected state visually. */
+.tbl tbody tr .row-link {
+ font: inherit;
+ color: var(--text);
+ font-weight: 600;
+ text-align: left;
cursor: pointer;
}
-.tbl tbody tr.clickable.selected {
+.tbl tbody tr .row-link:hover {
+ color: var(--accent-bright);
+}
+.tbl tbody tr.row-selected {
background: var(--accent-soft);
}
-.tbl tbody tr.clickable.selected td:first-child {
+.tbl tbody tr.row-selected td:first-child {
box-shadow: inset 2px 0 0 var(--accent);
}
+/* Desktop width for the inline trend/sparkline column. Scoped above the 640px
+ card-reflow breakpoint (not an inline style) so it never fights the mobile
+ `.tbl td { width: 100% }` rule when rows collapse into stacked cards. */
+@media (min-width: 641px) {
+ .tbl td.trend-col {
+ width: 130px;
+ }
+}
/* Compact SVG line sparkline */
.line-spark {
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;