Inspiration
Every year, the WebAIM Million (https://webaim.org/projects/million/) audit scans the top one million homepages on the internet. Every year, roughly 95% of them fail basic accessibility checks. The same three failures top the list annually: missing image alt text, unlabeled buttons, and unreadable color contrast.
What struck us wasn't the number — it was that the number doesn't move. We already have excellent detection tools. Lighthouse, axe, and WAVE will happily teliolations.
So we asked a different question: if detecb still broken?
We think it's because those tools stop exans. They hand you a list of 60 problems anda link to a WCAG specification, and then you — a developer with a sprint deadline — have to read the spec, understand the context of each element, anport isn't the solution. It's homework. And it's flat homework. A decorative 16×16 gets reported with exactly the same urgency as a 1200×800 hero image missing alt text. Only one of those actually blocks a screen-reader user, but the report gives you no way to tell which.
We wanted to build the part that everyone
What it does
AccessAI takes a URL and returns a ranked list of accessibility violations, each with a working, copy-paste code fix that has been programmatically verified to actually solve the problem it claims to solve.
Three stages:
- Scan — A real Chromium browser renders the page, scrolls it to trigger lazy-loaded content, and measures the live computed DOM for three WCAG failure classes.
- Rank — A LightGBM classifier trained on 414 real violations scraped from 41 live production sites predicts severity as high, medium, or low, so you fix what actually blocks users first.
- Fix & Verify — An LLM generates a concrete code fix, and then a deterministic validator checks that fix against the specific violation. If it fails, it's regenerated.
That last step is the whole point, and we'll come back to it.
How we built it
Three services, deliberately split along the lines of what each language is genuinely good at.
The scanner (Node + Playwright, port 4000). Browser automation is I/O-bound and Playwright is the best tool for it. We don't parse static HTML — we render the page and read the computed DOM, which means we catch violations in JavaScript-rendered content that static parsers miss entirely.
The contrast detector implements the WCAG relative luminance formula directly. For each sRGB channel ( C \in {R, G, B} ), normalize to ( c = C/255 ) and linearize:
$$ c_{lin} = \begin{cases} \dfrac{c}{12.92} & c \leq 0.03928 [8pt] \left(\dfrac{c + 0.055}{1.055}\right)^{2.4} & c > 0.03928 \end{cases} $$
$$ L = 0.2126,R_{lin} + 0.7152,G_{lin} + 0.072 $$
The contrast ratio between foreground and background is then:
$$ \text{ratio} = \frac{L_{light} + 0.05}{L_{d $$
which we compare against ( 3{:}1 ) for large text (( \geq 24\text{px} ), or ( \geq 18.66\text{px} ) bold) and ( 4.5{:}1 ) otherwise. The tricky part isg what the background actually is, whichmeans walking up the DOM tree through every transparent ancestor until you hit a real color.
The classifier (Python + FastAPI + LightGBM, port 8000). The model and its whole ecosystem belong in Python, so we kept it there and talked over HTTP.
The frontend (Next.js 16 + React 19 + TailwRL, watch the scan, get severity-coloredcards with the explanation, the fix, and a validation badge.
The idea we're proudest of: making the AI prove itself
Here's the thing about asking an LLM to fix an accessibility violation. It will always give you an answer, and that answer will always look right.
We watched a model return this for a missin

That's syntactically perfect, confidently dls the exact violation it was asked to fix.An empty alt attribute tells a screen reader to skip the image entirely. If we had shipped that to a user, we'd have made their site look fixed while leavibly worse than doing nothing, because nowthey think it's handled.
So we stopped trusting the model. Every generated fix goes through a deterministic validator that checks it against the specific violation type:
if v_type == 'missing_alt_text': match = re.search(r'alt="\'["\']', fix_code) if not match: return False, "No alt attribute found in fix" if match.group(1).strip() == "": return False, "alt attribute is present but empty" return True, "alt attribute present and A contrast fix must contain a real color va contain a non-empty aria-label or actualvisible text. If validation fails, the fix is regenerated — up to three attempts. Every result carries validated: true/false and the reason, surfaced directl
The LLM is a component in a verified pipelithat distinction is what separates an AIproduct from an AI demo.
Challenges we ran into
- Almost every major site timed out.
Our first scanner used Playwright's waitUntil: 'load'. Wikipedia, CNN, NYT, and WHO all failed with Timeout 30000ms exceeded — our dataset is still full of those error rows.
The cause was subtle: load waits for every image, font, and third-party analytics tracker to finish. On a modern media site with persistent connections, that moment may genuinely never arrive. We switched to domcontentloaded — the DOM is all we need — with a commit fallback that scans a partial page rather than losing the site entirely. We also learned that headless Chromium's default user agent gets stalled by some sites, so we send a real desktop UA.
- The bug that taught us the most: silent, confident zero.
For a while, scans came back reporting 0 violations on sites we could see were broken. No error. No warning. Just a clean, confident zero.
The scanner was finding violations correctly. But the code that fetched fixes did this:
try { results.push(await getFix(violation)); } catch (err) { console.error('Failed to get fix:', err.message); // violation vanishes here }
and then reported results.length as the violation count. So the number we displayed as "violations found" was actually "fixes successfully generated." Any hiccup in the classifier didn't produce an error — it produced a wrong answer that looked like good news.
That's the most dangerous failure mode a tool like ours can have. A crash is honest. "Your site is perfectly accessible" when it isn't is a lie that gets shipped. We rewrote it so a violation is always returned, with the missing fix flagged explicitly, and the response now separates totalViolations from fixesGenerated and fixesFailed so the two can never be confused again.
- Our model could have cheated, and we almost let it.
We label severity using expert heuristics — for contrast issues, how far the ratio falls short (feature_gap); for images, whether it's decorative (feature_isDecorative).
Then we nearly trained the model on a feature set that included those exact columns. It would have scored near-perfect accuracy by simply inverting our own labeling rule, and learned nothing generalizable at all. We caught it and dropped them explicitly:
Dropped: feature_gap (directly used in the contrast rule)
feature_isDecorative (directly used in the alt-text rule)
LEAKING_FEATURES = ['feature_gap', 'feature_isDecorative']
Now the model has to infer severity from raw signals — element size, contrast ratio, required ratio, font size, violation type. Lower reported accuracy, dramatically more real.
- Rate limits versus a validation loop that multiplies calls.
Groq's free tier allows 30 requests/minute. But our validate-and-retry loop means one violation can cost up to 3 LLM calls, so a 60-violation page needs 180. We hit 429 constantly and lost fixes.
We fixed it by parsing the API's own try again in Ns hint and backing off precisely, plus capping concurrency at 2. A 63-violation scan of nytimes.com now completes with 63 out of 63 fixes generated, zero failures, absorbing 3 rate-limit pauses along the way.
- We were only scanning a sliver of each page.
Our contrast detector had .slice(0, 40) — the first 40 elements — and nothing triggered lazy-loaded images. We raised the cap to 250 and added auto-scroll before scanning. CNN went from 2 detected violations to 34. The detector had been fine all along; we'd just been showing it almost none of the page.
Accomplishments that we're proud of
We didn't stop at finding problems — we fixed them. Most accessibility tools hand you a list and a link to the spec. Ours hands you the corrected code.
- We made the AI prove itself. Every fix our LLM writes gets checked by a validator before anyone sees it. If it claims to fix missing alt text but returns alt="", it gets rejected and regenerated. No confident-sounding wrong answers.
- We caught our model cheating. Our severity labels were built from two features we'd almost left in the training data — the model would have scored near-perfect by just inverting our own rule. We dropped them. Lower accuracy on paper, a model that actually generalizes in practice.
- It works on real websites, not just demos. We scanned CNN, NYT, Wikipedia, and WHO — the heavy, messy, JavaScript-everywhere sites that break scrapers. A 63-violation scan of nytimes.com returns 63 out of 63 fixes, zero failures.
- We fixed a bug that was quietly lying to users. Our scanner once reported "0 violations" on broken sites — no error, just a clean, confident zero. Now a violation is never dropped, and every failure shows itself instead of hiding.
- We built on real data. 414 genuine violations scraped from 41 live production sites. Nothing synthetic.
- We shipped three services that actually talk to each other. A Playwright scanner, a Python ML service, and a Next.js frontend — running end to end, in one click, from a URL. ## What we learned Detection was never the hard part. The genuinely hard problems were resolving an inherited background color through six levels of transparent divs, and knowing when not to score something — text over a background image has no single color to measure, and guessing produces confident noise. Knowing what to skip turned out to be as important as knowing what to catch.
A tool that fails loudly beats a tool that succeeds wrongly. Our zero-violations bug reframed how we think about error handling. Every failure path in the pipeline now degrades visibly instead of silently.
AI output is an input, not an answer. The validation loop cost us maybe forty lines of regex. It's the difference between a demo and something we'd let a real developer rely on.
Real data is messy and worth it. Scraping 41 live production sites gave us DNS failures, connection resets, and rate limits — but also 414 genuine violations that no synthetic dataset would have taught us to handle.
What's next for Accessai
- More detectors — keyboard navigation, focus order, heading hierarchy, form labels
- Full-site crawling with a domain-level accessibility score over time
- A GitHub App that opens a pull request with the validated fixes already applied — going from suggesting the fix to shipping it
- A CI/CD gate that blocks merges which regress accessibility, so sites get better by default instead of by audit
- Exportable VPAT / ADA compliance reports for teams facing legal requirements
The web didn't become inaccessible because developers stopped caring. It stayed inaccessible because fixing it was tedious. We'd like to make that part disappear.
Two notes: it's written in "we" — swap to "I" if you're solo. And the alt="" example in the AI-verification section is illustrative of the failure class your validator guards against; if you'd rather only claim things you directly observed, say "a model can return" instead of "we watched a model return."
Built With
- chromium
- fastapi
- groq
- javascript
- next.js
- pickel
- playwright
- pydantic
- typescript
- uvicorn


Log in or sign up for Devpost to join the conversation.