Skip to content

A11y pass: WCAG 2.2 AA across the dashboard#14

Merged
MaxGhenis merged 1 commit intomainfrom
feat/a11y-pass
May 9, 2026
Merged

A11y pass: WCAG 2.2 AA across the dashboard#14
MaxGhenis merged 1 commit intomainfrom
feat/a11y-pass

Conversation

@MaxGhenis
Copy link
Copy Markdown
Contributor

Summary

Implements every Blocker, Major, and Minor finding from the in-tree accessibility review. Single PR; nothing functional changes for sighted mouse users.

What changes

Tokens (one CSS edit clears most contrast failures)

  • --color-text-muted remapped from text-tertiary (#9CA3AF, 2.54:1) to gray-600 (#4B5563, 8.59:1). Eyebrow, stat labels, column headers, intervals, pending notes — all clear 4.5:1 at 10–11px.
  • New --color-warning-text / --color-danger-text / --color-success-text for use on -soft fills. Badge variants now clear 4.5:1.
  • Active country/sensitivity pill and active nav switch from bg-primary text-void / text-primary (~3.3–3.5:1) to bg-primary-strong text-white / text-primary-strong (≥7:1).

Document landmarks

  • <h1> per page (sr-only) + <main id="main"> + skip-to-content link in layout.tsx.
  • Per-route <title> (Paper — PolicyBench).

Focus + keyboard

  • Global :focus-visible ring (no outline:none regressions).
  • Compact pills bumped to ≥24×24 (WCAG 2.2 SC 2.5.8).
  • Disabled sensitivity buttons → aria-disabled + click guard, retaining tab-discovery and the title context.

Live regions + table semantics

  • Leaderboard: role="region" + aria-live="polite" + status announcing model count and active view; rows / column headers labeled via ARIA roles; per-row aria-label="Rank N, model, score X%".
  • Interval text gets aria-label="Rank 1 across bootstrap draws; 95% score interval 79.8% to 83.5%".
  • Open-set caveat moves from role="note" (no such role) to <aside aria-labelledby>.

Tooltip

  • ExplanationTooltip Escape-dismisses (returns focus), drops double-naming, removes pointer-events-none so the user can move the mouse into a long tooltip to read it, exposes aria-expanded on the trigger.

Iframe

  • /paper iframe: sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox", loading="lazy", referrerPolicy="same-origin". Adds a "Back to top" anchor outside the iframe so keyboard users can escape.

Reduced motion

  • @media (prefers-reduced-motion: reduce) snaps all animations/transitions to instant. SiteHeader scroll-progress hook detects the same and skips the RAF lerp.

Other

  • ProviderMark: role="img" + single aria-label; uses currentColor.
  • "by PolicyEngine" pill: dropped redundant aria-label/title that caused name-in-label mismatch with the visible "by PolicyEngine" text.
  • ScenarioExplorer <select> now has htmlFor/id association.
  • <details> summaries get a rotating chevron.
  • Heatmap legend has an sr-only caption explaining color is a redundant cue.
  • Shuffle button single-named.

Verification

  • bun run lint clean.
  • bun run build clean (still static).
  • SSR / contains <h1 class="sr-only">, skip-to-content, role="region", aria-live="polite", 4 role="columnheader", 13 role="row", aria-pressed="true" on active pills, aria-disabled on Binary-on-Global, aria-current on active nav.
  • SSR /paper contains <h1 class="sr-only">, skip link, iframe with sandbox + loading="lazy".

Out of scope

  • Converting the leaderboard's responsive cards to a single <table> element (the existing CSS-grid + ARIA-row pattern works without a media-query swap and ships in this PR via roles). Real <table> is a larger refactor and not required for AA.

🤖 Generated with Claude Code

Tokens (single CSS edit clears most contrast failures)
- Remap --color-text-muted from text-tertiary (#9CA3AF, 2.54:1) to
  gray-600 (#4B5563, 8.59:1). Eyebrow, stat labels, column headers,
  intervals, pending notes, etc. now all clear 4.5:1 at the small sizes
  we render them.
- Introduce --color-warning-text (#d9480f), --color-danger-text
  (#b91c1c), --color-success-text (primary-700). Badge variants now use
  text-warning-text / text-danger-text / text-success-text /
  text-primary-strong on -soft fills, all >=4.5:1.
- Active country pill, sensitivity pill, and active nav switch from
  bg-primary text-void / text-primary (~3.3-3.5:1) to bg-primary-strong
  text-white / text-primary-strong (>=7:1).

Document landmarks
- Each page now has an <h1> (sr-only on / and /paper so the existing
  visual hierarchy survives).
- <main id="main"> on both pages plus a "Skip to main content" link in
  layout.tsx (visually hidden until focused).
- Per-route metadata so /paper has a distinct browser tab title.

Focus and keyboard
- Global :focus-visible ring on every interactive element via
  globals.css, scoped to focus-visible so mouse clicks don't show it.
- Compact ViewSelector pills bumped to 24x24+ (WCAG 2.2 SC 2.5.8).
- Disabled sensitivity buttons use aria-disabled + click guard instead
  of the HTML disabled attr, so screen readers can read the title /
  context explaining why ("no UK rows under this slice; ..."). Active
  state strips aria-pressed when disabled.
- Hidden header chrome stays out of tab order (already correct);
  re-enables once visible.

Live regions and table semantics
- Leaderboard wrapped in role="region" aria-live="polite" with a
  status label that announces model count and active view. The grid
  uses role="row" / role="columnheader" / row-level aria-label so SR
  users hear "Rank N, model name, score X%".
- Bootstrap interval text now carries aria-label
  ("Rank 1 across bootstrap draws; 95% score interval ...") so it
  reads as a sentence, not opaque numbers.
- Open-set caveat is an <aside aria-labelledby> rather than the
  non-standard role="note".

Tooltip
- ExplanationTooltip now Escape-dismisses (returns focus to the
  trigger), drops aria-label that was double-naming the button, and
  removes pointer-events-none so a mouse user can hover into the
  tooltip to read long explanations. Keeps aria-expanded on the
  trigger.

Iframe (/paper)
- sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
  loading="lazy" referrerPolicy="same-origin"; "Back to top" anchor
  outside the iframe so keyboard users can escape.

Reduced motion
- @media (prefers-reduced-motion: reduce) snaps all transitions and
  animations to instant. SiteHeader's scroll-progress hook detects
  the same and snaps to either expanded or collapsed instead of
  running RAF lerping.

Smaller fixes
- ProviderMark: role="img" + single aria-label (no double-name);
  uses currentColor so it stays legible on any surface.
- "by PolicyEngine" pill: dropped redundant aria-label/title that
  caused name-in-label mismatch with visible "by PolicyEngine".
- ScenarioExplorer <select> now has htmlFor/id linking to its label.
- <details> summaries get a rotating chevron; without one, screen
  readers and users with limited contrast had no disclosure cue.
- Heatmap legend gets an sr-only caption explaining color is a
  redundant cue and the printed % is the source of truth.
- Shuffle button single-named (dropped redundant title + sr-only span).

Verification
- bun run lint clean.
- bun run build clean (still ○ static).
- SSR HTML / contains: <h1 class="sr-only">, skip-to-content link,
  role=region/row/columnheader, aria-pressed="true" on the active
  country and sensitivity pills, aria-disabled on Binary-on-Global,
  aria-current on the in-section nav link.
- SSR HTML /paper contains: <h1 class="sr-only">, skip link, iframe
  with sandbox + loading="lazy".
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
policybench-site Error Error May 9, 2026 4:01am

Request Review

@MaxGhenis MaxGhenis merged commit bae2dfc into main May 9, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant