DiresQ

Accessibility

Nine issues found, three critical, all fixed and held by tests.

The people most likely to need a tool like this are the people least likely to have perfect vision, a steady hand, or a working mouse. So this got audited rather than assumed.

Nine issues, three of them critical. Then four more when reports learned to work offline and the form grew a status message somebody cannot afford to miss. Then six more on the last day, when the audit finally read the stylesheets instead of the palette and the report form instead of the pages that already had tests. Then three more after that, reviewing the countdown the board grew so the escalation could be filmed.

Twenty-two in total, six of them critical, all fixed, and all now held in place by tests that run in CI — because an audit nobody can repeat is a claim, not a result. The last nine are the interesting ones: every one of them sat under a passing test whose job was to catch exactly that.


What was wrong

Six stylesheets had no focus style at all

board.css, actions.css, triage.css, nav.css, homepage.css and map.css defined no :focus anything. Tabbing through the accountability board — the main screen of the app — gave no indication of where you were.

Fixed with one :focus-visible rule applied globally:

a:focus-visible, button:focus-visible, input:focus-visible {
    outline: 3px solid #89b4fa;
    outline-offset: 2px;
}

:focus-visible rather than :focus, so keyboard users get the ring and mouse users don’t get one around every button they happen to click. There’s a forced-colors fallback for Windows High Contrast Mode, which throws our palette away entirely.

WCAG 2.4.7 Focus Visible — Level AA

Placeholders were doing the job of labels

Login, sign-up and both search boxes had a placeholder and no <label>.

Placeholders disappear the moment you type. A screen reader may not announce them at all, and anyone returning to a half-filled form has nothing left to tell them which box is which. Every input now has a real label, visually hidden where the design didn’t have room for one:

<label class="visually-hidden" for="login-username">Username</label>
<input id="login-username" type="text" name="username" ...>

Hidden with the clip-path technique, not display: none — the latter removes it from the accessibility tree, which defeats the point.

WCAG 3.3.2 Labels or Instructions — Level AA

Two colours were unreadable

ElementForegroundBackgroundRatioNeeded
Capability tags#45475a#3132441.8:14.5:1
Footer / legal text#6c7086#1e1e2e3.4:14.5:1

The first was effectively invisible. Both moved to #a6adc8, which reaches 5.65:1 and still reads as secondary text.

The rest of the palette did well — Catppuccin Mocha is a well-built theme. Body text is 11.3:1, the red overdue state is 7.1:1, links are 7.8:1.

WCAG 1.4.3 Contrast (Minimum) — Level AA

The board repainted silently every three seconds

The board replaces its whole list on each poll. To a screen reader that was either nothing at all, or — with the wrong setting — an interruption every three seconds forever.

It’s now a labelled region with aria-live="polite", so someone going overdue is announced at the next natural pause rather than cutting across whatever is being read.

Deliberately not assertive. Assertive interrupts immediately, and something that interrupts every three seconds is something people switch off.

WCAG 4.1.3 Status Messages — Level AA

No way to skip the navigation

Nine of ten pages had no main landmark, and none had a skip link. Every page meant tabbing through the whole header first.

Both added everywhere. The skip link is the first thing in the tab order and invisible until it has focus.

WCAG 2.4.1 Bypass Blocks — Level A

Smaller things

Found later, when reports learned to work offline

Filing a report with no signal added three live regions and a status message somebody genuinely cannot afford to miss. A second audit over just that surface found four things. Contrast passed everywhere — all nineteen new foreground/background pairs clear 4.5:1, and every coloured border that carries meaning clears 3:1 — but the announcing did not.

None of the four would have been caught by the contrast test or by looking at the page. Colour was the easy part and it was already right.

Found in the final audit, the day of submission

A third pass, reading the stylesheets rather than the palette and the report form rather than the pages that already had tests. Six issues — three of text nobody could read, three of controls with no name — and three of the six are critical.

ElementForegroundBackgroundRatioNeeded
.hint on a report card#45475a#3132441.38:14.5:1
.hint on the triage card#45475a#1818251.92:14.5:1
.match-block .hint#6c7086#3132443.0:14.5:1

.hint is the small print under a field that explains how the field works — “Try 30 min, 2 hours, or leave it”, and on the triage page “A normal adult is around 12–20”, which is the reference the question cannot be answered without. It was the text a first-time user needs most and it was the text nobody could read. Nine instances across two pages.

The third row is the interesting one: .match-block .hint is more specific than .hint, so fixing the base rule left it behind. A contrast fix can be undone by specificity without anybody touching the colour.

WCAG 1.4.3 Contrast (Minimum) — Level AA

Then the labels. The earlier audit fixed login, sign-up and the search boxes, and the test written to keep them fixed listed those four pages — so the report form, the form this entire application exists to submit, was never checked. It had four problems:

The lesson both halves share: the tests were written around the bug that was found, not the rule that was broken. A test that lists pages will not cover the page added next week, and a test that lists colours will not see the one you are about to use.

Found reviewing the demo clock, after submission

The board grew a countdown — due in 5 min, falling to 20 min past due — so the escalation could be filmed. Reviewing it turned up three things, and only one of them was an accessibility problem.

The countdown was drawn once and then erased. It was added to templates/board.html and not to board.js, which replaces the whole list every three seconds. It rendered on load, and the first poll wiped it. No test saw it, because the tests read the template. Nobody would see it by hand unless they watched one row for three seconds. Not a WCAG issue at all — the audit found it because auditing means reading the thing rather than the notes about it.

A ticking number inside a live region. #responders is aria-live="polite" and is replaced wholesale on every poll. A countdown changes on every repaint, and at DIRESQ_DEMO_SPEED=60 that is every three seconds forever — announcements queued faster than they can be spoken, burying the one that matters, which is the badge changing to OVERDUE. The countdown is aria-hidden="true". That is a real trade and it is written down in limits.md rather than only here. (4.1.3 Status Messages)

Two more unreadable greys, on the board this time. --overlay (#45475a) on the row background is 1.92:1, and it was the colour of the coordinates and of every “not assigned”, “no position” and “no contact yet” state. The test that exists to prevent exactly this — the one named after not reintroducing the unreadable greys — only ever read a11y.css. Widening it to every stylesheet found six more files doing the same thing: the disclaimer footer, the legal links under three forms, the offline page’s timestamp and footer, the triage footnote, and the stood-down vote on a report. (1.4.3 Contrast (Minimum))

The pattern is the same one this page keeps recording. Each time, the test was written around the file where the bug was found instead of the rule that was broken.

What was already right

Worth recording, because it was mostly by accident of building the boring way:

How it’s enforced

TestAccessibility, plus the live-region checks in TestFilingOfflineIsHonestAboutIt — run in CI on every push:

TestCatches
Every page declares a languageMissing lang
Every page has a skip link and a mainRegressions on new pages
Every page loads the focus stylesheetSomeone dropping the include
No <img> without altImages added later
No control named only by a placeholderParses the markup, matches <label for> and aria-label against every <input>, <select> and <textarea>, on six pages including the report form
No <label for> points at a control that isn’t thereA label with no field is styled text that promises one
Board is polite, not assertiveA future “make it announce faster”
Contrast of the palette pairsComputes the ratio from the hex and fails below 4.5:1
No stylesheet sets text to an unreadable greyReads all fourteen stylesheets — the palette test could not see these
Every color: in every stylesheet is readableResolves var() against each file’s own palette and checks the result against the backgrounds it can actually sit on. Not a list of colours — the declarations themselves
The board’s two renderers agreeEvery r.<field> in board.js appears in board.html and back, with an explicit exception list
Every fragment rowHtml builds is emittedField parity alone stays green if the interpolation is deleted and the local left behind
The countdown is out of the live regionA number that changes every repaint would talk over the state change it counts toward
The status region is unhidden before it is writtenA live region announcing nothing
Only one thing announces a queued reportTwo regions talking over each other
The suggestion panel does not rewrite identical markupA paragraph repeated at somebody every few seconds

The contrast test does the WCAG relative-luminance maths rather than checking names against an approved list, which is the right idea. For most of this project it was also checking the wrong thing: the pairs were written out by hand in the test, so it verified the palette and never once read a stylesheet. Any class using a colour that wasn’t on the list was invisible to it, which is exactly how .hint sat at 1.38:1 for weeks under a test whose whole job was contrast, passing.

There are three tests now. The original still checks the palette. A second reads every stylesheet, finds the greys that are unreadable on anything we put behind them, and fails unless a11y.css demonstrably lifts that selector — so a fix cannot be quietly undone by a more specific rule, which is the other way this went wrong.

The third stops listing anything. It reads every color: declaration in every stylesheet, resolves var() against that file’s own palette, and checks the result against the backgrounds it can sit on — the page surfaces for ordinary text, the bright accent fills for the near-black text that goes on badges.

Getting that split right took two attempts and the second attempt was caught by its own canary. The first version classified anything above 0.03 relative luminance as light text; #45475a sits at 0.065, so it was compared against yellow badges it never appears on, passed comfortably, and would have let through the exact colour the test was written for. There is now an assertion that the grey which caused all this is still classified as light text — a check whose only job is to fail if the check above it stops working.

What has not been done

Nobody has used this with a screen reader. Automated checks catch roughly a third of real accessibility problems, and it’s the easy third. Everything above is markup and colour; none of it is the experience of actually navigating the app with VoiceOver or NVDA.

The map has no non-visual equivalent. It’s a Leaflet canvas. The board carries the same information as text and the feed lists every report, so the data is reachable another way — but nobody has confirmed that is enough, and “there’s another page with the same data” is a weaker answer than it sounds.

No testing with real assistive technology, at any level. No switch access, no voice control, no magnification beyond browser zoom.

Zoom past 200% is unverified. The layout is responsive and should reflow, but it hasn’t been checked at 400%, which is what 1.4.10 actually asks for.

If somebody with a genuine need for any of this wanted to tell us what we got wrong, we’d rather know.