Problem
Bench Review's core conversion flow — rating a judge — is practically unusable with a screen reader. Blind and low-vision litigants are squarely in this platform's audience, and as a US public-facing site this is ADA Title III / Unruh exposure on top of the usability gap. The codebase has deliberate a11y work elsewhere (tablist filters, role="dialog" mobile menu, aria-live error banner in the review form itself), so this is not a full-audit ask: a targeted one-day pass on the review flow closes the worst of it.
Findings
- All four star-rating widgets have no accessible name or state. The fairness (
resources/components/Bench/ReviewForm.stx:457-468), knowledge (:478-489), demeanor (:499-510), and overall (:539-550) rows render five<button>s carrying onlytype/class/x-class/@click/@mouseenter/@mouseleave; the<Icon name="star"/>child expands to a bare<svg>with no<title>/aria-hidden. A screen-reader user hears five identical unnamed buttons per row. Confirmed in built output:dist/judges/review.htmlcontains a singlearia-labelon the whole page. - Dangling
label for=attributes.ReviewForm.stx:454/475/496/536usefor="fairness"/"knowledge"/"demeanor"/"rating", but no element with those ids exists in the rendered page — the targets are the unlabeled buttons. - Selected state is color-only (WCAG 1.4.1).
x-classtogglestext-yellow-400vstext-gray-300(ReviewForm.stx:462/483/504/544) with no non-color cue (fill vs outline, checkmark, text). focus:outline-nonewith no replacement (WCAG 2.4.7). Exactly the four star rows (ReviewForm.stx:461/482/503/543) plus one non-tabbable menu container (resources/components/Bench/Judge/ProfileHeader.stx:161). All otherfocus:outline-noneuses in Bench components already pair afocus:ring-2/focus-visible:ring-2replacement, so scope is contained.- No skip link. No skip-to-content anchor anywhere under
resources/;resources/layouts/default.stx:3has<main>but no skip target. <html lang="en">is verified present in built output (dist/judges/review.html) — no action needed.
Acceptance criteria
- Each star widget exposes accessible names and state: either
role="radiogroup"with per-starrole="radio"/aria-checked, or per-buttonaria-label="Rate {n} of 5"+aria-pressed - The four
label for=attributes resolve to real ids, or labels become group captions wired viaaria-labelledby - Selected stars are distinguishable without color (e.g., filled vs outlined icon)
focus:outline-noneon the four star rows is replaced with a visiblefocus-visible:ring-2(or equivalent) style- A skip-to-content link precedes the header and targets
<main id="main">inresources/layouts/default.stx - Manual VoiceOver pass: a user can complete a full review submission (all four ratings) end-to-end
From the 2026-06-10 production-readiness audit.
Runtime pass against a live form (6b7cb792)
The body's checkboxes were stale — five were landed across the earlier passes and never ticked here. They are ticked now. The sixth, the manual VoiceOver pass, is still open and is still not something I can do.
What I could do that the earlier passes could not: drive the real widget against a working API. Every previous comment on this issue says some version of "markup inspection, not assistive-technology testing", and one notes the form could not be reached at all because the judge lookup needs the API. A stub API on :4321 serving the built dist/ closes that gap.
A role that promised what nothing implemented
role="radiogroup" is a contract, not a label: it tells a screen reader that Tab enters the group once and arrow keys move within it. The five stars were separately tabbable buttons with no key handling — so the role announced a keyboard behaviour that did nothing, which is exactly the defect the "More" menu had (role="menu" with no roving focus). There the answer was to drop the roles, because it was a disclosure. Here the role is correct for a single-select of five, so the contract is now implemented: roving tabindex, arrows with wrap, Home/End, selection following focus.
Two defects the live pass found
The live region announced the wrong rating. hoverRating takes precedence over rating in both the star fill and the aria-live readout, and nothing cleared it on a keyboard change — mouseleave only fires for a pointer. After hovering one star and arrowing to another, the DOM read:
aria-checked → "Rate 4 of 5"
aria-live → "Poor" (which is 1)That region is the only confirmation a non-sighted user gets that anything registered, and it was confidently wrong. Not reachable by reading the template — both values had to be read out of a live DOM.
A held arrow key never advanced. The first implementation computed the next value from the star that received the event, but focus moves on a later tick, so every repeat of a held key arrived at the star the user started on and re-set the same value. Measured: three presses from star 1 landed on star 2. It reads the rating signal now, so repeats advance.
Checkbox name, 258 → 28 characters. The anonymity <label> wrapped both the heading and the full explanation, so a screen reader announced all 258 characters as the control's name before ever saying "checkbox". aria-labelledby now names it and the explanation moved to aria-describedby, which is announced after the name and is skippable. The whole card still toggles on click.
Verified end to end, keyboard only
Title typed → rating set with arrow keys → body typed in the editor reached by Tab → Submit reached by Tab and fired with Return → "Review submitted". No pointer involved after the judge was chosen. Also confirmed live: "Skip to content" is the first focusable element, exactly one star is in the tab order at a time, aria-checked tracks the selection, End→5 and a further Right wraps to 1, and the rich-text editable is Tab-reachable — a tabIndex === -1 property reading suggested otherwise, and driving it proved the property wrong rather than the page.
Gate check 25
The repo has now hit "ARIA role promising keyboard behaviour that nothing implements" twice, so it is guarded: a file claiming radiogroup/menu/menubar/tablist/listbox/tree/grid has to handle keys somewhere. It strips comments first — its own first run flagged ProfileHeader.stx, whose comment explains the roles it removed.
What is still open
Only the VoiceOver pass. Everything above is DOM state, computed accessible names, and real keyboard driving — it is a much stronger claim than markup inspection, and it is still not the same as listening to the page. NVDA/JAWS behaviour in particular can differ from what the accessibility tree suggests, and the aria-live politeness and whether the readout interrupts are things only a listen will settle.