What it is
A per-PR accessibility reviewer that advises, never merges.
froot is a set of durable maintenance loops pointed at GitHub repositories. a11y-review is the newest one. It watches each pull request, sweeps the templates that PR changed for accessibility gaps a screen-reader or keyboard-only user would hit, and leaves a single advisory comment. It never merges anything. A human fixes what they agree is a defect.
Think of it as the source-level complement to the runtime accessibility checks (axe) a UI app already runs in its browser tests. Those catch problems in the rendered page; a11y-review catches them in the source, on the way in, at the moment the gap is introduced.
The loop that closes
In Many Hands Engineering, a real loop has five stages that close on themselves: signal, action, verification, commit-or-revert, update. The last stage is what turns work into something that compounds instead of accumulating as noise. The old local a11y script had the first four. It fired from a command, printed a map, and forgot. froot supplies the two stages that need durable memory: a signal that decays in the terrain, and an explicit rule for when it acts.
| The six ingredients | Where each lives in a11y-review |
|---|---|
| Signal | a dialect-aware regex sweep of the PR's changed Vue/JSX templates |
| Bounded action | post (or clear) one advisory comment — never a merge |
| Verification | the human, plus the repo's own runtime axe checks. The loop never grades itself |
| Reversibility | trivial — it only ever edits a single comment |
| Signal-update (decay) | the comment, re-derived every commit; it self-clears once the gaps are fixed |
| Authority surface | propose-only, and off by default — a deliberate opt-in |
The durable shape — two nested loops
The durability is Temporal, and it takes the form of two workflows. The outer one is a single long-lived loop per repo: each tick it lists the repo's open PRs, hands each to an inner review, sleeps a few minutes, then restarts itself fresh so its history never grows. The inner one runs once per (PR, commit) — keyed on the commit SHA, so a new push gets a fresh review and re-checking the same commit is a no-op.
A slow tick costs nothing. The loop is advisory, so it never has to win a race against a merge — if it reviews a moment late, it just reviews the next commit.
One pull request, start to finish
Here is a single review. The loop checks out the PR's head and asks GitHub which files the PR touched, keeping only the templates. A pure scanner sweeps those for five high-signal patterns. Only if it finds something does the model get involved: one small call per flagged site, asking a single question — is this really a gap, and if so, quote it. A pure step keeps the confirmed gaps. Then the comment goes up. Or, if everything is clean and a stale comment is lingering, it gets cleared.
How a finding earns its place — triangulation
The danger with any advisory loop is crying wolf. A noisy reviewer trains people to ignore it, which destroys the one thing it exists to provide. So a finding has to survive three independent checks before it is allowed to speak. Each can fail in a different way, so they cannot all be wrong at once. MHE calls this triangulation — the practical defence against a single gameable metric.
| What the scan looks for | The gap it might be |
|---|---|
role="img" / inline <svg> | an image a screen reader announces with no name |
<input> / <textarea> / <select> | a form control with no associated label |
a click handler on a <div>/<span>/<li> | operable by mouse, dead to the keyboard |
<img> / :src | an image with no alt text |
The signal that decays
A finding that never goes away is just nagging. The comment is a living thing: re-written in place on every commit, and cleared to all clear the moment a PR's gaps are fixed. A clean PR that never had a problem stays silent — froot does not stamp looks good! on everything. That silence is part of keeping the signal worth reading.
Why advisory, and not just auto-fix?
The obvious wish is to have an agent simply fix the gaps. We deliberately did not — yet. Two reasons, both about trust.
First, an accessibility fix is not a lookup. A good alt text or aria-label is a judgment about meaning, not a mechanical edit you can derive from the problem statement. Second, and decisive: there is no objective oracle. froot's iron rule is that a loop may act on its own only as far as something can prove it was right. A dependency bump has that proof — the repo's CI passes or it does not. An a11y fix does not: a linter going green does not mean a screen-reader user can actually operate the thing.
So the loop ships as the identifier that leaves a map, and earns its way toward acting later, the way every froot loop does: observe first, act once trust is earned.
Turning it on
It is off until you ask for it: a single switch (FROOT_A11Y_ENABLED) plus a one-shot job that starts the loop for each configured repo. Pointing it at a repo with no templates is harmless — it simply finds nothing. The first two homes are where it is most additive.
| Repo | Why it fits (or does not) |
|---|---|
| glassbox (React) | a large template surface that already values a11y (runtime axe in CI) — the missing layer is exactly this source-level pass |
| revisionist (Vue) | a small surface with no a11y tooling yet — greenfield, highest marginal value |
| static / vibe-themer | not targets — generated HTML and a VS Code extension, no hand-authored template surface |