diff --git a/CLAUDE.md b/CLAUDE.md
index 2aed479..aad376b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -383,6 +383,71 @@ Non-goals for this phase (same discipline as every phase so far):
someone with database superuser access; that's an operational control,
out of scope here and named explicitly, not silently assumed away.
+## What "done" looks like for Phase 5
+
+**Status: shipped.** A ground-up frontend redesign — visual direction,
+a real design system, navigation/IA, charting, dashboard panels,
+query/search, and alerting UI — plus an accessibility pass, all verified
+against a live docker-compose stack with real seeded data, not just
+`npm run check`/`npm run build` passing. See `/docs/design-system.md`
+for the token system and component library, and
+`/docs/phase-5-runbook.md` for the full verification log, including five
+real bugs this phase's live-verification discipline caught that a
+type-checked, successfully-building frontend would not have surfaced on
+its own.
+
+The visual direction ("Signal": near-neutral grayscale UI, color rationed
+to the four-tier severity system plus a single interactive accent, real
+dark-mode-as-default) was picked from three proposed directions before
+any token or component work started, per an explicit stop point in this
+phase's brief. The charting library (ECharts, over Observable Plot and
+raw D3 — see the design-system doc for the reasoning and the verified
+bundle-size/perf numbers) was likewise confirmed before being wired into
+every panel type, the second explicit stop point.
+
+Two of the five bugs this phase's verification caught were backend bugs
+with no connection to the frontend redesign itself, only surfaced
+because getting real dashboard/alert data to verify the new UI against
+required actually exercising write paths nothing had exercised since
+Phase 4's `tenant_id` migrations landed:
+
+- `alerting`'s `rulestore.Create`/`ApplyTransition` never populated the
+ `tenant_id` column Phase 4 added to `alert_state`/`delivery_log` (with
+ a `NOT NULL` constraint) — every alert rule created against a
+ Phase-4-or-later database silently failed. Existing rows all had a
+ value from Phase 4's backfill migration, which is exactly why this
+ went uncaught: Phase 4's own verification never created a *new* rule
+ post-migration, and its runbook already discloses that Docker access
+ was lost partway through that phase.
+- `dashboard_panels`'s `viz_type` CHECK constraint was never updated
+ alongside `heatmap`'s addition to the Go/TS validators — a three-place
+ change (Go validator, TS union, DB constraint), not two.
+
+Both are fixed (`alerting/internal/rulestore/store.go`,
+`metadata/migrations/0035_add_heatmap_viz_type.sql`) and confirmed
+against a live stack: rule creation → evaluation → firing → a real
+(failed, to an intentionally fake webhook) delivery attempt, and a
+heatmap panel created, persisted, and rendered end to end. See the
+runbook for the other three findings (one more real product bug — a
+`findIndex`/nullish-coalescing bug in the chart-pivoting logic that made
+every `single_stat` panel render `0` — and two real accessibility
+findings caught by axe-core against live-rendered pages with real data,
+not fixture data or empty states).
+
+Non-goals for this phase (same discipline as every phase so far):
+- No query-language or data-model changes beyond the one narrowly
+ justified exception: `heatmap` as a `VizType`, needed to feed a new
+ visualization, not a new query capability.
+- No changes to tenant isolation, RBAC, SSO, or audit logging — Phase 4's
+ surface area is untouched; this phase is presentation-layer only.
+- No mobile-phone-width layout — responsive verification stops at
+ tablet-landscape width, per the brief's explicit scope ("laptop/
+ tablet-landscape," not phone-width).
+- No fuzzy search in the command palette, no data-grid virtualization for
+ very large result sets, no chart types beyond the five built
+ (time-series, bar, single-stat, heatmap, top-N) — real, disclosed
+ future work, not oversights.
+
## When in doubt
Ask before: changing the pinned stack, adding a new external dependency
that pulls in a large transitive tree, or making an architectural decision
diff --git a/docs/design-system.md b/docs/design-system.md
new file mode 100644
index 0000000..a5358cc
--- /dev/null
+++ b/docs/design-system.md
@@ -0,0 +1,371 @@
+# Sentry Design System (Phase 5)
+
+Direction: **Signal** — cockpit/ICU-monitor instrumentation logic. Color
+is a rationed resource: the UI is near-neutral grayscale everywhere, so
+the four severity tiers land as genuinely the most saturated colors
+anywhere on screen, not one saturated color competing with a dozen
+decorative ones. See the Phase 5 design-direction review (three options
+were presented; Signal was the one picked) for the two rejected
+directions and the full rationale.
+
+This doc is the source of truth for the token system and component
+library `/web/src/lib/components/ui` implements — read it before adding
+a new color, spacing value, or component, rather than reaching for a
+literal hex or one-off markup the way Phase 0-3's pages did.
+
+## Status
+
+**Status: Phase 5 shipped.** All ten tasks are built: the token system,
+the `ui/` component library, the persistent sidebar nav + command
+palette, a real ECharts-based charting layer (5 chart types, drill-down,
+zoom), the dashboard panel rebuild (GridStack drag-and-drop + live-preview
+panel editor), the query/search redesign (CodeMirror syntax highlighting
++ autocomplete, sortable/resizable/expandable results), the alerting UI
+redesign (severity-state pill + delivery timeline), and an accessibility
+pass driven by real axe-core runs against the live app (not static
+analysis) — see the Accessibility section below for what that actually
+caught. See `/docs/phase-5-runbook.md` for the full verification log.
+
+Verified in a real browser against a live docker-compose stack with real
+seeded data (not just fixtures): dark/light/system theme and
+comfortable/compact density switching persisting across navigation and
+reload; command palette open/filter/keyboard-navigate/go-to; every nav
+route including dashboard/alert detail pages with real panels and real
+alert history; all 6 panel viz types rendering real query results; a
+firing alert rule's full state-history timeline; keyboard-only operation
+of the query editor, results table (sort/expand), and dashboard grid.
+
+## Fonts
+
+Self-hosted (not a Google Fonts CDN link — no runtime dependency on a
+third party for the app to render correctly): `web/static/fonts/`,
+licensed under the SIL Open Font License (see that directory's
+`LICENSE.txt`).
+
+| Role | Typeface | Why |
+|---|---|---|
+| UI (nav, labels, headings, body) | Overpass | Originally drawn for U.S. highway signage — engineered to be read correctly, fast, under bad conditions. A more honest reason to pick a typeface for an incident-response tool than "it looks modern." |
+| Data (log tables, code, query bar, numbers) | Overpass Mono | Same family as the UI face — one typeface end to end removes even the small cognitive cost of a font pairing, matching the direction's overall restraint. |
+
+Both are variable fonts (one file covers the full weight range), loaded
+via `@font-face` in `web/src/lib/styles/fonts.css`.
+
+## Color tokens
+
+Defined in `web/src/lib/styles/tokens.css`. Dark is the literal default
+— `:root` defines the dark palette directly, light is the override
+(both `@media (prefers-color-scheme: light)` for an unset preference and
+`[data-theme="light"]` for an explicit one) — not a retrofit where light
+is `:root` and dark is bolted on. Component CSS must only ever read a
+token, never a literal hex; that's what makes the theme/density toggles
+a token swap instead of a per-component rewrite.
+
+| Token | Dark | Light | Use |
+|---|---|---|---|
+| `--color-bg` | `#0a0a0b` | `#f7f7f8` | Page background |
+| `--color-surface` | `#17181a` | `#ffffff` | Cards, tables, inputs |
+| `--color-surface-raised` | `#1e2023` | `#ffffff` | Hover states, popovers |
+| `--color-border` / `--color-border-strong` | `#2a2c2f` / `#3a3d41` | `#dfe0e2` / `#c7c9cc` | Dividers, input borders |
+| `--color-text` / `--color-text-muted` / `--color-text-faint` | `#f0f0f1` / `#85888d` / `#8a8d92` | `#101113` / `#6b6e73` / `#75787d` | Body text hierarchy |
+| `--color-accent` / `--color-accent-strong` | `#3fb6ff` | `#0b84d6` | Interactive elements only — links, primary buttons, focus rings, active nav. Never reused for severity (see below); a semantic color competing with the brand accent defeats the point of "color means something." |
+
+### Severity tiers
+
+The schema carries seven OTel severities
+(`TRACE`/`DEBUG`/`INFO`/`WARN`/`ERROR`/`FATAL`/`UNSPECIFIED`, see
+`/storage/README.md`). Seven colors would be seven things to memorize at
+a glance; `web/src/lib/severity.ts`'s `severityTier()` collapses them to
+four accent tiers plus one "quiet" state:
+
+| OTel severity | Tier | Dark | Light |
+|---|---|---|---|
+| `TRACE`, `DEBUG`, `UNSPECIFIED` | quiet | `#85888d` | `#6b6e73` |
+| `INFO` | info | `#4c8dff` | `#1a63d6` |
+| `WARN` | warn | `#f5c242` | `#8c6800` |
+| `ERROR` | error | `#ff6a39` | `#c94b1e` |
+| `FATAL` | critical | `#ff2d78` | `#c21362` |
+
+Chosen as a blue → amber → orange → magenta progression — hue *and*
+lightness both shift at every step, so no two adjacent tiers rely on
+red-vs-green to be told apart, and the sequence should survive grayscale
+and protanopia/deuteranopia simulation. That reasoning hasn't been
+verified with an actual simulator yet — do that before treating it as
+confirmed accessible, not just plausible.
+
+Each tier also has a translucent `-bg` token (e.g. `--color-sev-warn-bg`)
+for chip/pill backgrounds. These are tuned independently of the solid
+foreground colors above, not derived from them by a fixed formula: a
+translucent color tints *toward* its own hue as alpha increases, which
+for these saturated, low-luminance hues (critical's magenta especially)
+*lowers* contrast against the foreground text the higher the alpha goes
+— the opposite of the intuitive "more opaque background is safer"
+assumption. `--color-sev-warn` itself was darkened in light mode
+(`#9c7300` → `#8c6800`) for the same underlying reason: axe-core caught
+the *plain* light-mode warn text failing AA (4.32:1) against white
+before any background was even involved. See the Accessibility section.
+
+Use ``
+(`ui/SeverityBadge.svelte`) to render one of these — it owns the OTel
+string → tier mapping so call sites can't invent a sixth color by hand.
+For non-log-severity status (success/danger/neutral/accent — e.g. a
+"saved" confirmation), use `` instead; the two are
+deliberately separate components so a form-validation color can never
+accidentally collide with a log-severity one.
+
+## Type scale
+
+14px base (dense-first — this is a tool for reading log tables, not
+marketing copy), 1.2 modular ratio:
+
+`--text-xs` 11px · `--text-sm` 13px · `--text-base` 14px · `--text-md`
+16px · `--text-lg` 20px · `--text-xl` 28px · `--text-2xl` 40px
+
+`--font-weight-normal` 400, `--font-weight-medium` 600,
+`--font-weight-bold` 700. `font-variant-numeric: tabular-nums` is set
+globally on `body` so columns of numbers/timestamps align.
+
+## Spacing, radius, shadow
+
+8px-based spacing scale: `--space-1` through `--space-8` (4px, 8px,
+12px, 16px, 24px, 32px, 48px, 64px). Radius: `--radius-sm` 4px,
+`--radius-md` 6px, `--radius-lg` 10px, `--radius-full` (pills). Shadow
+is used sparingly, matching Signal's restraint — only real overlays
+(modals, the command palette, tooltips) get one (`--shadow-sm/md/lg`);
+inline UI never does.
+
+## Density
+
+Two presets, one token swap — `html.density-compact` overrides
+`--row-height`, `--row-padding-y/x`, `--panel-padding`, `--control-height`,
+and drops `--text-base` to `--text-sm`. Comfortable (the default) suits
+dashboards and forms; compact suits log tables and query results. Toggle
+via `$lib/density.svelte.ts`'s `setDensity()`/`toggleDensity()` — a
+global, persisted (`localStorage['sentry.density']`) preference, not a
+per-page setting, so switching it on one page carries to the next.
+`web/src/app.html` has a synchronous inline script that applies the
+stored value before first paint, so there's no flash of the wrong
+density on reload; keep that script's storage key/values in sync with
+`density.svelte.ts` if either changes.
+
+## Theme
+
+`$lib/theme.svelte.ts`, same persisted/synchronous-apply shape as
+density. Three states — `'dark' | 'light' | 'system'` — but unlike most
+apps, the *unset* default is `'dark'`, not `'system'`. That's the actual
+point of "real dark mode as the default, not an afterthought": a
+first-time visitor on a light-OS machine still lands in dark. `'system'`
+is available as a deliberate opt-in for anyone who wants their OS
+setting to win instead.
+
+## Components (`web/src/lib/components/ui/`)
+
+Import from the barrel: `import { Button, Input, ... } from
+'$lib/components/ui';`
+
+| Component | Notes |
+|---|---|
+| `Button` | `variant`: `primary`/`secondary`/`ghost`/`danger`. `size`: `sm`/`md`. Renders `` when `href` is passed, `