The GTFS Scorecard targets WCAG 2.2 Level AAA across the public site:
the landing page (web/index.html), the single-page app (web/app/ plus
web/src/app.js and web/src/styles.css), the prerendered pages generated by
pipeline/src/scorecard_pipeline/render_site.py, and the standalone pages
(web/subscribe.html, web/submit.html, web/try.html, web/how-to-read/,
web/about/).
This report records per-criterion conformance, how the colour-selection mechanism works, and an honest list of anything still short of full AAA.
web/src/theme.js is loaded on every page. It renders an accessible, keyboard-
operable menu (a <button> with aria-haspopup/aria-expanded opening a
role="menu" of role="menuitemradio" items) labelled "Theme". The choice is
saved to localStorage under scorecard-theme and applied as a data-theme
attribute on <html>, which overrides the CSS custom properties in both
web/src/styles.css and the landing page's inline tokens.
Four options are offered: System (default; follows
prefers-color-scheme), Light, High contrast, and Dark. Each
theme is verified to clear AAA contrast by
pipeline/scripts/check_contrast.py, which computes the WCAG 2.x
relative-luminance ratio for every text/background pair the site uses and
asserts >=7:1 for normal text (>=4.5:1 for large text) across all three explicit
palettes. To avoid a flash of the wrong theme, each page also applies the saved
data-theme inline in <head> before first paint.
The OS-following defaults are scoped to :root:not([data-theme]) in both
stylesheets, so an explicit Light choice wins over an OS dark preference (the
shared stylesheet lacked this guard until 2026-07; the landing page always had
it).
This control is what satisfies 1.4.8's requirement for a mechanism to select foreground and background colours.
Computed with python3 pipeline/scripts/check_contrast.py. Text tokens were
kept or darkened; the 2026-07 signage palette brightened the grounds, which
raises every dark-text ratio.
| Pair | Ratio |
|---|---|
--ink-soft #3d4339 on --paper #f2f3ee (app) |
9.14 |
--ink-soft #3d4339 on --card #fbfcf8 |
9.89 |
--ink-soft #3d4339 on --paper-deep #e5e8df (fix effort) |
8.22 |
--green #163a2c link on --paper |
11.24 |
--green #163a2c link on --card |
12.16 |
--green-bright #1d4633 on --card |
10.31 |
severity --warning #6b490e text on --card |
7.89 |
severity --info #3a4753 text on --card |
9.24 |
.score-of board-soft #bcccbd on --board #102a20 |
9.10 |
.peer-context #d3e0d4 on --board |
11.19 |
landing --ink-soft #3c4840 on --paper #f2f3ee |
8.59 |
landing --ink-soft #3c4840 on --paper-2 #e5e8df |
7.72 |
landing --green #11522a on --paper |
8.32 |
landing --green #11522a on --paper-2 |
7.48 |
landing --rust #822c12 on --paper |
8.12 |
The High contrast and Dark themes clear the same threshold with wider margin (typically 9:1 to 21:1); run the script for the full table.
| Criterion | Status | Notes |
|---|---|---|
| 1.4.6 Contrast (Enhanced) 7:1 | MET | All normal-text pairs darkened to >=7:1 (>=4.5:1 large) across all themes; checked by check_contrast.py. |
| 1.4.8 Visual Presentation | MET | Prose measure capped to ~70ch (--measure); no justified text; the theme menu provides the colour-selection mechanism. |
| 2.4.8 Location | MET | The filled nav stop carries aria-current="page" when it is the page being read and aria-current="true" when it is the hub of the section that page sits in, so a hub is never announced as the current page; a nav[aria-label="Breadcrumb"] on prerendered agency, program, fix, and section pages. |
| 2.4.9 Link Purpose (Link Only) | MET | Bare-filename and arrow/"view" links given self-describing text or aria-label ("standards crosswalk", "the change feed", "Open transit data downloads", "Source code on GitHub"). |
| 2.4.13 Focus Appearance | MET | All :focus-visible outlines are >=3px solid with >=3:1 contrast; the choropleth has a real outline plus a thick dark stroke. The dark pine chrome band (header controls and footer links) rings in --amber rather than the blue --focus, which is tuned for the light page and would fall to ~1.6:1 on pine; amber clears >=9:1 on pine in every theme (check_contrast.py). |
| 2.5.5 Target Size (Enhanced) 44x44 | MET (one documented exception) | Chips, filter buttons, follow/remove, selects, copy buttons, search, form controls, and dense nav/footer links are >=44px. See the choropleth exception below. |
| 3.1.3 Unusual Words | MET | A glossary lives at /how-to-read/#glossary; first uses are <dfn> and linked to it. |
| 3.1.4 Abbreviations | MET | First meaningful use of GTFS, GTFS-Realtime/GTFS-RT, RT, NTD, FTA, ACS, D-10, MDB, GBFS, YAML, CI, SHA-256, JSON wrapped in <abbr title="...">. |
| 3.1.5 Reading Level | MET | Dense NTD, conformance, and equity copy each carry a one-line plain-language summary alongside it. |
The US state choropleth on the directory is a progressive-enhancement convenience over the always-present, fully accessible state chip grid and agency list. Small states render below 44x44 as SVG paths; enlarging every state to 44x44 would distort the map geometry. The exception is acceptable because an equivalent control of adequate size exists: every state is also a
=44px-tall
.state-chip, and the full agency list is reachable without the map. The map paths still receive a visible >=3px focus indicator and are keyboard operable.
- Choropleth cell size (2.5.5): documented exception above; the equivalent chip grid satisfies the requirement.
- Third-party map page (
/map/): the national MapLibre map is a convenience layer. Its internal canvas controls are outside our styling; the page links prominently to the accessible/agencies/list, so no user is stranded. We do not claim AAA for the embedded map widget itself. - External link reading level: linked external documents (Federal Register, validator docs) are not under our control and may exceed a lower-secondary reading level; we summarize them in plain language on our own pages.
- Legacy
.grade-stamp: an unused legacy hero style remains in the stylesheet; current pages render the dark status board, whose text is a fixed light cream independent of theme.
python3 pipeline/scripts/check_contrast.py # contrast across all themes
node --check web/src/app.js
node --check web/src/theme.js
cd pipeline && uv run scorecard render-site # regenerate prerendered pages