Document Phase 5: finalize design-system.md, add runbook, exit criteria

design-system.md was still describing a mid-Phase-5 state (charting,
dashboard panels, query/search, and alerting UI all listed as "not
built yet"); added sections for all of them plus a real Accessibility
section, and fixed color-token values that had drifted from the actual
tokens.css since the contrast fixes.

phase-5-runbook.md documents what was actually verified against a live
docker-compose stack with real seeded data, including the five real
bugs that live-verification caught -- two of them backend bugs with no
connection to the frontend redesign, only surfaced because getting
real dashboard/alert data required exercising write paths nothing had
exercised since Phase 4's tenant_id migrations landed.

CLAUDE.md gets Phase 5's exit criteria, matching every prior phase's
"what done looks like" section.
This commit is contained in:
2026-08-16 12:36:31 -07:00
parent 9862bcccae
commit 595d1fe0fd
3 changed files with 591 additions and 0 deletions
+371
View File
@@ -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 `<SeverityBadge severity={row.severity} />`
(`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 `<Badge tone="...">` 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 `<a>` when `href` is passed, `<button>` otherwise. |
| `Input` | Thin styled wrapper over `<input>`; `bind:value`, `invalid` sets `aria-invalid` + a danger border. |
| `Select` | Styled wrapper over native `<select>` (real `<option>` children) — not a custom listbox, so it keeps native keyboard/screen-reader behavior for free. |
| `Badge` | Generic status pill. `tone`: `neutral`/`success`/`danger`/`accent`. |
| `SeverityBadge` | `severity` prop takes a raw OTel string; maps to a tier internally. Use this instead of `Badge` for anything log-severity-shaped. |
| `Table` | A styling wrapper around real `<table>` markup (native semantics matter for screen readers) — pass real `<thead>`/`<tbody>` as children. Density-aware via the row tokens above. Not a data grid; sort/resize (Phase 5 task 6) layers on top later. |
| `Card` | `title`/`actions` (a snippet) header, `padded` toggle. |
| `Modal` | Built on native `<dialog>` — real focus trap, Escape-to-close, and top-layer stacking, not hand-rolled. `bind:open`, `title`, `footer` snippet. |
| `Tooltip` | Pure-CSS hover/focus tooltip, `role="tooltip"` + `aria-describedby`. |
| `Tabs` | Renders the tab list only (roving tabindex, arrow-key nav); the caller renders each panel's content on `bind:active` and owns `id="panel-{id}"`/`aria-labelledby="tab-{id}"`. |
`CommandPalette.svelte` and `NavSidebar.svelte`
(`web/src/lib/components/`, not under `ui/`) are app-shell components,
not general-purpose library pieces — mounted once in `+layout.svelte`.
## Navigation & tenant switching
Persistent sidebar (`NavSidebar.svelte`): Search / Dashboards / Alerts /
Data Sources / Settings, active-route highlighting, a command-palette
hint, and the theme/density quick toggles. The tenant indicator calls
`getCurrentSession()` (`$lib/api.ts`), which wraps `POST
/internal/authorize` — an endpoint that already existed (`api/authz.
HTTPAuthorizer` and `alerting` already call it) and was already reachable
from the browser (`enterprise-auth`'s whole mux is behind
`WithCredentialedCORS`, not just the tenant-picker routes), so this
needed zero backend changes.
**Known limitation, not yet solved at the API level**: there is no
"list my other tenant memberships while already logged in" endpoint —
`GET /auth/memberships` only works during the short-lived pending-login
window (see `enterprise/internal/loginhandler`'s doc comment), not for
an established session. So "Switch tenant" in the sidebar re-triggers
login (`/auth/oidc/login`) rather than offering an inline dropdown —
logging in again is the only tenant-switching mechanism this product
actually has today, and if there's more than one membership it
naturally lands back on `/select-tenant`. A real inline switcher would
need a new `GET /auth/my-memberships`-shaped endpoint (or equivalent);
that's real, disclosed future work, not a bug in what's built.
## Command palette
`⌘K`/`Ctrl+K` anywhere in the app. Indexes the five static nav
destinations plus live-fetched dashboards and alert rules
(`listDashboards()`/`listRules()`, via `Promise.allSettled` so one
failing endpoint doesn't blank the other's results or the static items).
Arrow keys + Enter to navigate, Escape to close (native `<dialog>`,
same reasoning as `Modal`). Substring filter, not fuzzy — no new
dependency pulled in for it, matching this repo's "boring,
well-understood dependencies" convention.
## Charting (`web/src/lib/charts/`)
Built on **ECharts**, via modular imports (`echarts/core` plus only the
chart/component modules actually used — `LineChart`, `BarChart`,
`HeatmapChart`, `TooltipComponent`, `GridComponent`, `LegendComponent`,
`DataZoomComponent`, `VisualMapComponent`, `MarkLineComponent`,
`CanvasRenderer`), not the full bundle. Picked over Observable Plot (SVG
rendering hits a real performance ceiling at volume, and no built-in
zoom/pan or legend-toggle) and raw D3 (too much hand-rolled engineering
for chart types this standard). Verified, not assumed: the lazy-loaded
chart chunk is 211,975 bytes gzipped, and a synthetic 30,006-row/6-series
stress fixture (`/dev/charts`, an unlisted dev-only route) renders its
first two frames in ~50ms on a production build — `npm run dev`'s
~3.1s figure for the same fixture is pure dev-mode/unminified-JS
overhead, not a real perf number, and was confirmed as such before being
discarded.
Five chart components, all consuming the same `{columns, rows}`
`QueryResult` shape Phase 2's query endpoint has always returned:
`TimeSeriesChart` (multi-series line, legend toggle), `BarChart`
(including stacked), `SingleStat` (big number + sparkline + trend),
`Heatmap`, `TopN` (ranked horizontal bars). `EChart.svelte` is the shared
base wrapper (init/resize/dispose lifecycle).
**No query-language change was needed for multi-series or drill-down**
both are pure frontend reshaping of the existing tabular output:
- `pivot.ts`'s `pivot(columns, rows, config)` turns a "long" result (one
row per series+x pair, e.g. `stats count by service, timestamp`) into
one series per distinct value of the grouping column. Its value-column
auto-detection has one sharp edge worth knowing if you touch it: when
a result has only one non-x column (a bare `stats count`, `single_stat`'s
most common shape), `Array.prototype.findIndex` returning `-1` for "not
found" must be checked explicitly — `-1 ?? fallback` never falls back,
because `-1` isn't `null`/`undefined`. This was a real, shipped bug
(every `single_stat` panel silently rendered `0`) caught by seeding a
live dashboard with real query results rather than only fixture data,
not by any static check.
- `drilldown.ts` strips a panel's query down to its pre-`stats` filter
portion, appends a clicked series/x-value as a new filter term, and
computes a tight time window around a clicked timestamp — then
navigates to the Search page via URL params (`?q=&earliest=&latest=`).
`theme.ts`'s `readChartTokens()` reads real computed CSS custom-property
values (`getComputedStyle`) so charts render in the actual active
theme's colors rather than a hardcoded palette — guarded with an
`SSR_FALLBACK` object for adapter-static's prerender pass, where
`document` doesn't exist.
**The one backend change in this phase**: `heatmap` as a `VizType`
(`api/dashboards/types.go`'s `validVizType()`, `web/src/lib/api.ts`'s
`VizType` union, and the `dashboard_panels` table's `viz_type` CHECK
constraint — three places, not two; the DB constraint mirrors the Go
validator and was originally missed, which meant a heatmap panel passed
API validation and then failed on insert. See
`metadata/migrations/0035_add_heatmap_viz_type.sql`). Justified under
the brief's "no query-language or data-model changes except what's
strictly needed to feed richer visualizations" exception — a heatmap is
a viz type, not a new query capability.
## Dashboard panels
Drag-and-drop grid is **GridStack**, already a Phase 3 dependency — no
new library needed, satisfying the brief's "a maintained library is
fine, don't hand-roll grid physics." `PanelEditor.svelte` (a `Modal`)
replaces the old inline add-panel form: a debounced live preview reuses
`PanelViz` directly, so what you see while editing is pixel-identical to
what renders on save, not a separate preview renderer that can drift
from the real thing. Empty/loading/error states use `EmptyState` and
`Skeleton` rather than a blank panel or a raw error string.
## Query & search (`web/src/lib/query-editor/`)
**CodeMirror 6**, not a hand-rolled textarea-plus-overlay highlighter —
picked specifically because autocomplete needs real cursor-aware popup
positioning, which a plain textarea can't give you. `language.ts` is a
`StreamLanguage` tokenizer for the pipe grammar; its `token()` function
must return real `@lezer/highlight` tag names looked up by string
(`'controlKeyword'`, `'operatorKeyword'`, `'name.function'` for
tag+modifier pairs) — a custom `Tag.define()` object's `.toString()`
looks plausible but silently fails to highlight anything, a real bug hit
and fixed while building this. `completions.ts` provides context-aware
suggestions: stage keywords after `|`, stats functions after `stats`,
field names elsewhere.
`ResultsTable.svelte`: sortable columns (click header — a real `<button>`
inside the `<th>`, not a clickable `<th>` itself, so it's keyboard-operable
for free), resizable columns (pointer-drag handles, mouse-only by
design — the `role="separator"` handle is intentionally not in the tab
order, matching how most apps treat column resize as a mouse affordance
rather than a keyboard one), and expandable rows. The row-expand
interaction was originally a bare `<tr onclick=...>` with no keyboard
equivalent at all — a real gap the accessibility pass caught (not via
axe, which doesn't flag missing keyboard handlers on custom widgets;
caught by manually tabbing through the page), fixed by adding
`tabindex="0"`, `role="button"`, `aria-expanded`, and an `Enter`/`Space`
`onkeydown` handler alongside the existing click handler.
"Add as panel to dashboard" (`AddToDashboardModal.svelte`) lets a query
built on the Search page become a saved panel without hand-copying the
query string into the dashboard editor.
## Alerting UI
`AlertStatePill.svelte` reuses the log-severity color tiers rather than
inventing a second color vocabulary: `ok → quiet`, `pending → warn`,
`firing → critical`. `DeliveryTimeline.svelte` reframes the existing
`delivery_log` data (no new backend fields) as a vertical timeline —
"why didn't I get paged" is a chronological question, and a flat table
answered it less directly than a timeline does.
## Accessibility
Automated checks ran with **axe-core**, injected into the live app in a
real browser and driven against a real seeded docker-compose stack
(dashboards with all 6 panel viz types populated with real query
results, a firing alert rule with real delivery history) — not just
static markup or empty/loading states, since several of the real
findings below only appear once real content renders. Real violations
found and fixed:
- `color-contrast`: `AlertStatePill`'s critical tier at 4.04:1 (below
the 4.5:1 AA threshold) when rendered on an opaque `--color-surface`
card, not the plain page background most severity chips happen to sit
on — see the Severity tiers section above for why translucent
backgrounds made this worse, not better, and why quiet/info (dark) and
quiet/warn/error (light) were fixed alongside it as the same latent
bug, not yet triggered elsewhere only because no page had rendered
those specific tiers on an opaque surface yet.
- `landmark-main-is-top-level` / `landmark-no-duplicate-main` /
`landmark-unique`: the layout shell's content wrapper was a second
`<main>` nested around each page's own top-level `<main>`.
- `landmark-one-main` / `region`: `/data-sources` was the one page using
a plain `<div>` instead of `<main>` as its top-level element.
- `heading-order`: `Card` and `EmptyState` both jumped from `<h1>` to
`<h3>`, skipping `<h2>`.
- `aria-input-field-name`: the CodeMirror query editor's `.cm-content`
had no accessible name — fixed via `EditorView.contentAttributes.of({
'aria-label': ... })`.
- `empty-table-header`: the alerts list table's trailing actions column
had a bare `<th></th>` — fixed with visually-hidden text (`.sr-only`,
now a shared utility in `app.css`) rather than a visible "Actions"
label that would've added a fifth, less-necessary heading.
Keyboard-only operation was verified by tabbing through, not just
inferred from markup: sidebar nav → command-palette hint → theme/density
controls → into main content, with a visible focus ring
(`--focus-ring`, a two-layer box-shadow so it's visible on both light
and dark surfaces) at every stop; the query editor is reachable and
labeled; sort buttons are real `<button>`s; the results table's row
expansion works via `Enter`/`Space` after the fix above. Responsive:
the sidebar collapses to an off-canvas drawer under 860px
(`NavSidebar.svelte`'s `mobileOpen`/`onCloseMobile`, a transform-based
slide-in with a backdrop button), verified down to tablet-landscape
width, not just described in CSS.
+155
View File
@@ -0,0 +1,155 @@
# Phase 5 runbook
Extends `/docs/phase-0-runbook.md` through `/docs/phase-4-runbook.md`.
Read those first. Phase 5 touched only `/web` and the two narrow,
justified backend changes called out below — no new services, no new
pinned-stack components.
## What's actually been verified
Every claim below was checked against a live `docker compose up`
stack with real seeded data (not fixture files, not mocked stores),
in a real browser (Claude in Chrome), not just `npm run check` and
`npm run build` passing. Five real bugs were found this way — two of
them backend bugs unrelated to the frontend redesign itself, only
surfaced because getting real chart/panel/alert data required actually
exercising the write paths those bugs were in. That's the same
"a passing check and a working feature are not the same claim"
discipline every prior phase's runbook has held to.
## 1. Bring up the stack
```sh
docker compose up -d --build
cd web && npm run dev # localhost:5183, talks to localhost:8080/8081 by default
```
No new services this phase. `docker compose ps` should show the same
set as Phase 4.
## 2. Seed real data
`hack/benchmark-fixture` (Phase 2) still works unchanged:
```sh
cd hack/benchmark-fixture
go run . -count=5000 -batch-size=500 -concurrency=4
```
Create a dashboard covering all 6 panel viz types and an alert rule
with a real notification target, either through the web UI or via the
API directly. One thing worth knowing if you script this the way this
runbook's own verification did: the query language requires a leading
base term before any pipe stage — `stats count by host` alone is a
syntax error (`unexpected COMMA after query` or similar), because
`Parse()` always parses a base filter/free-text term before it'll
accept a `|`. `earliest=-1h | stats count by host` is the idiomatic
"match everything in the current window" form (see
`/docs/query-language-reference.md`'s `earliest=-24h severity=ERROR |
stats count by service` example) — this isn't new to Phase 5, but is
easy to trip over when scripting panel creation instead of using the
query bar, which always has a real base term from its own default
query.
## 3. Dashboard panels — all 6 viz types
Time-series line, bar, single-stat, top-N, table were all real Phase 3
capabilities re-rendered on the new chart layer; heatmap is the one new
type this phase added. Verify each renders against real query results,
not just the `/dev/charts` synthetic fixture route (unlisted, dev-only —
useful for perf testing, not a substitute for exercising the real panel
CRUD + query path).
**Real bug caught here**: `single_stat` panels rendered `0` for every
query, including ones the API confirmed returned real data (`{"columns":
["count"],"rows":[[5000]]}` from `POST /query` showed `5000`, the panel
showed `0`). Root cause in `web/src/lib/charts/pivot.ts`'s value-column
auto-detection: `Array.prototype.findIndex` returns `-1` for "not
found," and the fallback used `??`, which only substitutes on
`null`/`undefined``-1 ?? fallback` evaluates to `-1`, not the
fallback. For a single-column result (`stats count`, single-stat's most
common shape), the x-column fallback (index 0) excludes the only column
from the search, `findIndex` always returns `-1`, and the panel silently
read `row[-1]` (`undefined`) as its value. Fixed by checking for `-1`
explicitly instead of relying on `??`. This would not have been caught
by `/dev/charts`'s synthetic multi-column fixture data — it only
reproduces with a genuinely single-column result, which only a real
`stats count`-shaped query against the live API produces.
**Real bug caught here, backend**: creating a heatmap panel returned
`{"error":"viz_type must be one of table, line, bar, single_stat,
top_n, heatmap, got \"heatmap\""}`-shaped failures at two different
layers in sequence. First, the running `api` container was stale
relative to the Phase 5 source change adding `heatmap` to
`validVizType()` — rebuilding (`docker compose build api`) fixed that.
Second, after rebuilding, panel creation still failed with a Postgres
`23514` check-constraint violation: `dashboard_panels`'s `viz_type`
CHECK constraint (`metadata/migrations/0002_create_dashboard_panels.sql`)
was never updated alongside the Go validator, so `heatmap` passed API
validation and then failed on insert. Fixed with a new migration,
`metadata/migrations/0035_add_heatmap_viz_type.sql` (drop and recreate
the constraint — Postgres has no `ALTER CHECK`). Worth remembering for
any future `VizType` addition: it's a three-place change (Go validator,
TS union, DB constraint), not two.
## 4. Alert rule creation
**Real bug caught here, backend, unrelated to the Phase 5 redesign
itself**: creating any alert rule against a freshly migrated database
failed with `inserting initial alert_state: ERROR: null value in
column "tenant_id" of relation "alert_state" violates not-null
constraint`. Phase 4 added `tenant_id` to `alert_state` and
`delivery_log` (migrations `0022`/`0023`, backfilled via a join through
`alert_rules.id`, then set `NOT NULL`) but
`alerting/internal/rulestore/store.go`'s `Create` and `ApplyTransition`
were never updated to populate it on new inserts — every existing row
had a value from the backfill, so this was invisible until the first
rule created *after* that migration ran, which nothing in Phase 4's own
verification happened to do (Phase 4's Docker access was lost partway
through, per its own runbook's disclosed gap). Fixed: `Create`'s
`alert_state` insert now passes `r.TenantID` explicitly;
`ApplyTransition`'s `delivery_log` insert resolves it via `(SELECT
tenant_id FROM alert_rules WHERE id = $1)` since that function only
receives a rule ID, not a full `Rule`. Confirmed fixed against the live
stack: rule creation, evaluation, firing, and a real delivery attempt
(to a fake Slack webhook URL — a real HTTP 404 back, logged in
`delivery_log`, which is itself the intended behavior for an
unreachable target) all completed end to end.
This means **every core-mode alert rule created against a Phase-4- or
Phase-5-migrated database before this fix was silently broken** — worth
knowing if debugging an environment provisioned between those two
points.
## 5. Accessibility sweep
Automated: axe-core, injected into the live app via a temporary
`web/static/axe-test-temp.js` script tag (removed before commit — do
not check it in; recreate from `node_modules/axe-core/axe.min.js` if you
need to re-run this). Checked every route, including the ones that only
render meaningfully with real backend data: `/`, `/dashboards`,
`/dashboards/[id]` (with all 6 panel types populated), `/alerts`,
`/alerts/[id]` (with a real firing rule and delivery history),
`/alerts/new`, `/settings`, `/data-sources`, `/select-tenant`,
`/dev/charts`. Real findings and fixes are cataloged in
`/docs/design-system.md`'s Accessibility section — don't duplicate that
list here; the summary is zero outstanding violations across all of the
above, in both themes, confirmed by re-running axe after each fix
rather than assuming a fix worked.
Manual: keyboard-only tab-order walk through the sidebar, command
palette, query editor, and results table (see the design-system doc's
Accessibility section for the one real gap this caught that axe
structurally can't — a custom interactive element with no keyboard
handler at all doesn't trip any axe rule, since axe checks markup/ARIA
correctness, not "does this thing respond to Enter").
## 6. What's not verified
Same category of gap as every prior phase's disclosed limitations, not
a new one: real external IdP login, a real multi-container Kubernetes
deployment, and load-testing the chart layer against ClickHouse-scale
(millions-of-rows) result sets rather than the 30K-row synthetic
`/dev/charts` stress fixture — that fixture validates client-side
render performance, not query performance at that scale, which is
already covered by Phase 2's own benchmark.