Skip to main content

Accessibility here

The target is WCAG 2.2 Level AA, and the WCAG 2.2 quick reference is the one external page worth bookmarking. The rest of this section is not a WCAG tutorial — it is what those criteria turn into in this codebase: a dark-first, map-heavy operator console where a dispatcher is watching a fire.

That context changes the priority order. This app has almost no long-form text and almost no forms; it has controls layered over images. So the criteria that bite here are keyboard operability, accessible names on icon-only buttons, live regions for state that changes without a click, and non-text contrast on status colors — not reading order or captions.

Read the enforcement honestly

Two facts to know before you trust a green build.

eslint-plugin-jsx-a11y is configured and it is finding real problems. As of this writing, eslint components reports 125 jsx-a11y findings across 34 files — and every one of them is a warning, not an error. npm run lint exits 0. CI (devops/jobs/pr-job.yml) runs plain npm run lint with no --max-warnings, so none of it blocks a merge.

 46  jsx-a11y/click-events-have-key-events                    (warn)
39 jsx-a11y/no-static-element-interactions (warn)
27 jsx-a11y/no-noninteractive-element-interactions (warn)
7 jsx-a11y/interactive-supports-focus (warn)
4 jsx-a11y/no-noninteractive-element-to-interactive-role (warn)

The axe integration never fails either. @storybook/addon-a11y is installed and wired into the Vitest browser project, but .storybook/preview.tsx sets a11y: { test: 'todo' }, which reports violations without failing anything. There are 7 story files in the whole repo, and no npm script runs that Vitest project at all. Details in Testing and linting.

So: the tooling here is advisory. Treat a jsx-a11y warning on a line you touched as a blocker even though the build won't.

What the app already does well

Copy these patterns rather than inventing new ones.

Live regions for state that changes on its own. role="status" with aria-live="polite" appears in AreaScopeBar, CameraMovementZoom, CameraListComponent, and atoms/Loader — the camera count, the zoom readout, and the loading state all announce.

Toggle buttons carry their own state. organisms/PeakFinderOverlay/PeakFinderControls.tsx and molecules/AreaFilter/AreaFilterSection/AreaFilterSection.tsx use real <button> elements with aria-pressed, so the pressed state is native rather than visual-only.

Accessible names on icon-only controls. There are ~204 aria-label attributes under components/, which is the right instinct for a UI that is mostly icons.

The four rules that matter in this repo

  1. If it responds to a click, it is a <button>. The single largest source of a11y debt here is onClick on a div or a MUI icon. See Markup and ARIA.
  2. Every icon-only control needs an aria-label; every decorative icon needs alt="". jsx-a11y/alt-text checks that alt exists, not that it says anything useful.
  3. State that changes without a keypress needs a live region that was already mounted. A region rendered in the same commit as its first message announces nothing.
  4. Use the tokens; don't hand-pick a color. Several existing tokens fail contrast at their current usage — Color and typography has the measured numbers.

Before you open a PR

Run these, in this order:

npm run lint          # read the jsx-a11y warnings on YOUR files, not just the exit code
npm run test:ci # jest — this is what CI runs
npm run storybook # only if you touched a component that has a story

Then do the manual pass. It takes about two minutes and catches what no tool in this repo catches.

  • Tab through your change. Every control you added is reachable, in visual order, and shows a visible focus ring. If it has no ring, add one — there is no global :focus-visible style in styles/global.css or styles/global.scss.
  • Activate everything from the keyboard. Enter on buttons and links, Space on buttons and checkboxes, Escape closes whatever you opened.
  • Nothing is a focus dead end. After a dialog closes, focus lands somewhere sensible — not on <body>.
  • Every new icon-only button has an aria-label naming what it does, not what it looks like ("Show peaks", not "eye-icon").
  • Every new <img> either describes content or is alt="". A filename is neither.
  • New colors come from styles/tokens/, and if a color conveys status it clears 3:1 against its actual background.
  • No new text below 12px, and nothing smaller than the body text around it.
  • npm run lint produced no new jsx-a11y warnings in the files you touched.

If your change adds a dialog, a menu, or anything with arrow-key navigation, read Keyboard and focus first — that page documents the two patterns this repo gets wrong most often.

Next: Markup and ARIA.