Accessibility
Perron targets WCAG 2.2 level AA. Part of it the build proves on every change: npm run check fails when any check below breaks. The rest no build can see, so a person checks it on every screen before it ships.
What the build checks
- Text contrast (1.4.3). Every text colour reaches 4.5:1 on
color.surface.default,raisedandinset, in both themes. The test lists the deliberate misses by name:color.text.tertiaryandcolor.transit.delay.unknown, which are only for hints, captions and the "—" of an unknown delay or figure, always next to stronger text. Status and brand text also reach 4.5:1 on their own tinted surfaces. Under a quiet control's hover and pressed layers, text keeps 4.5:1 ondefaultandraised; oninset, the light theme's brand outline label drops to 4.3:1 on hover and 4.0:1 while pressed, so a brand outline button sits ondefaultorraised. - Non-text contrast (1.4.11). What shows where a control is, or what state something is in, reaches 3:1 against every surface:
color.border.control,color.border.brand, the focus ring and the status fills. Decorative lines,color.border.subtleandcolor.border.strong, are exempt, and their descriptions say so. On the map,color.map.aircraftreaches 3:1 on every ground a plane flies or taxis over (Map). - The focus ring (2.4.7, 1.4.11) reaches 3:1 on every surface and on the map's land, water and parks.
- The surface ladder.
color.surface.insetstands apart fromcolor.surface.raisedby at least 1.15:1 in both themes, so an inset track reads without a border. See Elevation. - Line badges. Label colours on line colours are measured, and the official pairs that miss are listed. See Semantic colours.
- Reduced motion (2.3.3, a AAA criterion met anyway) lives in the tokens: when the system asks for less motion, transitions take 0.01 ms. See Motion.
Text on surfaces
| Light | Dark | |||||
|---|---|---|---|---|---|---|
| Colour | on surface.default | on surface.raised | on surface.inset | on surface.default | on surface.raised | on surface.inset |
color.text.primary | 17.26:1 | 17.92:1 | 15.32:1 | 15.32:1 | 13.45:1 | 11.21:1 |
color.text.secondary | 7.63:1 | 7.92:1 | 6.77:1 | 7.80:1 | 6.85:1 | 5.71:1 |
color.text.tertiary | 3.85:1, under | 4.00:1, under | 3.42:1, under | 4.48:1, under | 3.93:1, under | 3.28:1, under |
color.text.success | 5.34:1 | 5.54:1 | 4.74:1 | 8.57:1 | 7.52:1 | 6.27:1 |
color.text.warning | 5.38:1 | 5.59:1 | 4.78:1 | 8.60:1 | 7.55:1 | 6.29:1 |
color.text.danger | 6.12:1 | 6.35:1 | 5.44:1 | 7.50:1 | 6.59:1 | 5.49:1 |
color.text.info | 7.06:1 | 7.32:1 | 6.26:1 | 7.51:1 | 6.59:1 | 5.49:1 |
color.text.brand | 5.41:1 | 5.62:1 | 4.81:1 | 9.82:1 | 8.62:1 | 7.19:1 |
color.transit.delay.on-time | 5.34:1 | 5.54:1 | 4.74:1 | 8.57:1 | 7.52:1 | 6.27:1 |
color.transit.delay.slight | 17.26:1 | 17.92:1 | 15.32:1 | 15.32:1 | 13.45:1 | 11.21:1 |
color.transit.delay.late | 6.12:1 | 6.35:1 | 5.44:1 | 7.50:1 | 6.59:1 | 5.49:1 |
color.transit.delay.early | 7.06:1 | 7.32:1 | 6.26:1 | 7.51:1 | 6.59:1 | 5.49:1 |
color.transit.delay.unknown | 3.85:1, under | 4.00:1, under | 3.42:1, under | 4.48:1, under | 3.93:1, under | 3.28:1, under |
color.transit.severity.delay | 6.12:1 | 6.35:1 | 5.44:1 | 7.50:1 | 6.59:1 | 5.49:1 |
color.transit.severity.data | 7.63:1 | 7.92:1 | 6.77:1 | 7.80:1 | 6.85:1 | 5.71:1 |
color.transit.severity.diversion | 7.06:1 | 7.32:1 | 6.26:1 | 7.51:1 | 6.59:1 | 5.49:1 |
color.transit.severity.planned | 7.63:1 | 7.92:1 | 6.77:1 | 7.80:1 | 6.85:1 | 5.71:1 |
color.transit.severity.notice | 7.63:1 | 7.92:1 | 6.77:1 | 7.80:1 | 6.85:1 | 5.71:1 |
Controls, focus and status fills on surfaces
| Light | Dark | |||||
|---|---|---|---|---|---|---|
| Colour | on surface.default | on surface.raised | on surface.inset | on surface.default | on surface.raised | on surface.inset |
color.border.control | 3.10:1 | 3.12:1 | 3.03:1 | 3.32:1 | 3.26:1 | 3.04:1 |
color.border.brand | 5.41:1 | 5.62:1 | 4.81:1 | 9.82:1 | 8.62:1 | 7.19:1 |
color.border.focus | 17.26:1 | 17.92:1 | 15.32:1 | 15.32:1 | 13.45:1 | 11.21:1 |
color.status.success | 3.87:1 | 4.02:1 | 3.44:1 | 8.57:1 | 7.52:1 | 6.27:1 |
color.status.warning | 4.12:1 | 4.28:1 | 3.66:1 | 8.60:1 | 7.55:1 | 6.29:1 |
color.status.danger | 4.99:1 | 5.18:1 | 4.43:1 | 7.50:1 | 6.59:1 | 5.49:1 |
color.status.info | 5.71:1 | 5.92:1 | 5.07:1 | 7.51:1 | 6.59:1 | 5.49:1 |
The focus ring on the map
| Light | Dark | |||||
|---|---|---|---|---|---|---|
| Colour | on map.land | on map.water | on map.park | on map.land | on map.water | on map.park |
color.border.focus | 15.48:1 | 11.78:1 | 13.61:1 | 14.88:1 | 16.27:1 | 13.89:1 |
Rules
- Focus. Every interactive element shows the ring on
:focus-visible: afocus.widthsolid outline incolor.border.focus,focus.offsetaway from the element.perron.csssets it for the whole page. Never remove an outline without drawing the ring another way. - Targets (2.5.8). Anything people press is at least
size.target-minsquare, or has that much room around it. Perron's smallest control, a switch's track, issize.target-mintall and wider than that; the rest are larger, a small buttonsize.button-sm. - Colour never alone (1.4.1). A status colour comes with a word or an icon, a data state with its name next to the dot, a line colour with the line's name.
- Hover (1.4.13). Anything shown on hover is also reachable by selecting the item, because touch and keyboard users never see hover. A hover card stays while the pointer is over it and closes with Esc.
- Keys. Esc closes the topmost thing first: a dialog, then a detail panel, then a highlight. + and − zoom, arrow keys pan.
- State in words. A toggle says its state in words as well as in looks, and says it once, one of two ways. A plain button whose label is an action and its only word changes its words with the state ("Follow" / "Following") and has no pressed state. A control with a pressed or checked state keeps its label, and its role tells the ear the state: a toggle button's or a filter chip's
aria-pressedsays pressed, a switch'srole="switch"says on or off. Never both: a label that changes on a control witharia-pressedis heard as "Following, toggle button, pressed". The eye gets more than a hue: a switch's thumb moves as its track fills, and a chip's fill turns to ink. One primary action per panel. - Moving things. Loops (the follow blink, the report and selection pulses, and the progress bar's sweep) stop in their component when the system asks for less motion. The tokens only shorten transitions.
| Token | Value |
|---|---|
focus.widthWidth of the focus ring | 2 pxborder.width.050 |
focus.offsetGap between the focused element and its ring | 2 px |
size.target-minSmallest touch or pointer target, from WCAG 2.2 target size (minimum) | 24 px |
What a person checks
Before a screen ships, check what the build can't:
- Keyboard. Every action is reachable in a sensible order. Only an open dialog traps focus, and closing anything returns focus to where it came from.
- Names. Every control has an accessible name; an icon button carries a label. The map has a text alternative: the vehicle list.
- Images. Images and the mark have alt text, or are hidden from assistive technology when they are decoration.
- Zoom and reflow (1.4.4, 1.4.10). At 200% zoom and at 320 px wide, nothing is cut off or overlaps, and only the map scrolls sideways.
- Text spacing (1.4.12). Wider line, letter and word spacing breaks nothing.
- Announcements. Live updates, such as countdowns and moving vehicles, don't flood a screen reader: a polite live region announces changes of state only.
- Forced colours. In Windows' contrast themes, the focus ring and the selected state still show. The vehicle dot shapes keep each data state readable without colour: each of the five has a shape of its own, and no signal, a solid dot like live, is cut by a slash (Data states).