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.

point outward at

🔌 Pluggable loops — each is only a signal + a judge + a prompt

📦 dependency-patch
(acts: opens PRs)

🛡️ security-patch
(acts: opens PRs)

🧭 determinism-review
(advisory)

♿ a11y-review
(advisory · NEW)

⚙️ The froot chassis — built once, shared by every loop

⏰ durable schedule
+ continue-as-new

🐙 GitHub plumbing
checkout · PRs · comments

📊 derived read-model
(no database)

🌍 target repos
glassbox · revisionist · …

Where a11y-review sits. The chassis is built once; each loop is only a signal, a judge, and a prompt. a11y-review is the second advisory (review-only) loop, beside determinism.

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.

 ① signal 

 ② action 

 ③ verification = the human + runtime axe 

 ④ fix lands in source 

 ⑤ next tick re-derives → gap gone → comment self-clears 

⏰ Durable tick
(per repo)

📋 List open PRs
(re-derived each tick)

🔍 Scan changed templates
(dialect-aware regex · pure)

🤖 Adjudicate each site
(thin model · cite-or-omit)

🧮 Keep the real gaps
(pure synthesis)

💬 Upsert ONE advisory comment
(or clear it to 'all clear')

👤 Human reads + fixes
(no merge — advisory)

🐙 GitHub
the comment IS the signal

The five stages, coloured by who owns each step: blue = the durable chassis, purple = the one thin model call, green = the external repo, orange = the human. The dashed arrow is the loop closing — a fix in source erases the signal.
The six ingredientsWhere each lives in a11y-review
Signala dialect-aware regex sweep of the PR's changed Vue/JSX templates
Bounded actionpost (or clear) one advisory comment — never a merge
Verificationthe human, plus the repo's own runtime axe checks. The loop never grades itself
Reversibilitytrivial — 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 surfacepropose-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.

PrA11yReviewWorkflow — one per (PR, commit SHA)

A11yReviewWorkflow — one long-lived loop per repo

keyed on head SHA:
new commit ⇒ fresh review,
same commit ⇒ no-op

loops forever

🚀 a11y_review_starter
(one-shot · opt-in via FROOT_A11Y_ENABLED)

list open PRs

dispatch a review
per PR

sleep ~5 min

continue-as-new
(history stays one tick)

scan

adjudicate
(only if sites found)

synthesize

post / clear

The outer loop fans out one inner review per open PR. The inner review is a short, linear pipeline. Both restart cleanly and dedupe by name, not by remembered state.

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.

🤖 model judge🔍 scanner (pure)🐙 GitHub🔁 a11y loop (one PR)🤖 model judge🔍 scanner (pure)🐙 GitHub🔁 a11y loop (one PR)loop[one call per candidate]the model never runs (spine-heavy, model-thin)alt[sites found][nothing flagged]keep only cited gaps + judgment calls (pure)stay silent — no clean-PR spamalt[findings exist, or a stale comment is present][clean and none before]list changed files + checkout PR head1the changed .vue / .jsx templates2sweep for the 5 risk patterns3candidate sites (each with a context window)4is this a real gap? quote it.5gap / ok / judgment (+ citation)6upsert ONE marker-tagged comment7
The per-PR pipeline. Note the branch: on a diff that changes no templates, or only clean ones, the model is never called at all.

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.

no

yes

named / decorative

a design call

gap

no quote

quoted

a line of template

① mechanical scan
matched a risk pattern?

not a candidate

② model reads it
in context — really a gap?

✅ verified — dropped

🤔 judgment — surfaced separately

③ cite-or-omit:
did it QUOTE the exact element?

dropped
(suspected confabulation)

🔴 a11y gap — review + fix

Three gates. A gap must clear all three. The other two outcomes are surfaced honestly (a separate judgment section) or stay silent (verified).
What the scan looks forThe 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> / :srcan image with no alt text
Each flagged site is sorted into one of three buckets: gap (fix it), verified (already fine), or judgment (a design call).

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.

a fresh PR

clean ⇒ stay silent (no spam)

gaps found ⇒ post the map

new commit, still gaps ⇒ edit in place

gaps fixed ⇒ overwrite to "✅ all clear"

a gap returns ⇒ re-post

NoComment

Posted

AllClear

The decay the determinism
reviewer lacks: a fixed PR's
comment is cleared, never
left stale.

The comment's whole life. It always reflects the current commit, and fades into 'all clear' when the work lands.

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.

yes — e.g. a version bump

no — a good alt text or aria-label
is a design call, not a lookup

no — green lint ≠ usable
with a screen reader (MHE §3.9)

someday — render + check
behavioural oracle

the decay to fix:
source-level a11y gaps

is the signal the spec?
does 'what's wrong' tell you
exactly 'what to do'?

UNIFY: one mechanical loop
that opens a fix PR

SPLIT: an advisory identifier now,
a separate fixer later

is there an OBJECTIVE oracle?
(something that proves a fix is right)

♿ ship the identifier:
propose-only, the human fixes.
observe-then-act (MHE §3.4)

only then a fixer earns autonomy,
as far as its oracle is trusted

The fork in the road, and why a11y-review is on the advisory branch. The same rule that lets dependency-patch auto-merge is the rule that keeps a11y-review's hands off the merge button.

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.

RepoWhy 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-themernot targets — generated HTML and a VS Code extension, no hand-authored template surface