Building AuditEase: What I'm Learning by Writing My Own WCAG Scanner

Building AuditEase: An AI-Assisted WCAG Scanner That Actually Tells You How to Fix Things

A lot of my work is accessibility — mostly Drupal sites for governments and counties, where WCAG 2.1 AA isn't aspirational, it's a deadline on a calendar. Over the last year I've used pretty much every accessibility scanner I could find, and at some point I decided to just build the one I actually wanted.

This is why, and what I'm building.

The problem with the existing tools

WAVE, Lighthouse, Pa11y, Siteimprove. I've used them all. They're fine. They're not the thing I want.

Here's the pattern that broke me. Run a scan. Get a list of issues. Click into one — say, a color contrast failure on a button — and the tool tells me "Element has insufficient color contrast of 3.2:1 (foreground color: #767676, background color: #ffffff, font size: 14.0pt, font weight: normal). Expected contrast ratio of 4.5:1." Then it links me to the WCAG documentation.

Cool. Now I have to figure out which selector in which CSS file is producing that color. Then I have to decide what color would meet contrast while staying inside the brand guide. Then I have to think about whether changing the global button color is going to ripple through ten other components. The tool found the problem in two seconds. The fix is going to take me forty minutes.

Every scanner I've used stops at here's what's wrong and leaves what to actually do in your codebase as homework. That's the thing I wanted to fix.

The other annoyances stacked up too:

  • No way to track issues over time. Scan a site, hand the client a PDF, fix things, three months later have no clean way to compare where we were then versus where we are now. Eyeballing two reports side by side isn't a workflow.
  • Hard to share with clients. These reports are built for developers. When I'm handing something to a county comms director, I need something a normal human can read without a glossary.
  • Single-page scanning everywhere. Most of these tools want you to scan one URL at a time. When you're working on a Drupal Site Factory platform with 50+ sites, you're sitting there clicking Run for an afternoon.

So I started building.

What AuditEase does

It scans for WCAG issues, uses AI to suggest the actual code-level fix, tracks issues across recurring scans, and exports reports clients can read.

Scanning. AuditEase uses Playwright to crawl real pages and axe-core to do the actual rule evaluation. axe-core is Deque's open-source accessibility engine and the same library powering most of the tools I listed above. There was no point reinventing the detection layer — axe is excellent. Detection wasn't the problem. The Playwright piece matters because the crawler waits for pages to fully settle before scanning, which cuts down a lot of the false positives you get from tools that scan too early in the load.

AI-assisted remediation. This is the part I'm most excited about. When the scanner finds an issue, instead of pointing you at WCAG documentation, AuditEase takes the actual offending element, its DOM context, and the failing selectors, and uses the OpenAI Responses API to generate a specific recommendation. Not "fix landmarks." More like "wrap the main page content in a <main> element, and for these specific selectors add role='region' with unique labels." Real selectors from your site. Concrete steps. (Full example further down.)

One thing I want to be clear about: the AI is not deciding whether something passes or fails. The compliance score is deterministic. AI is only on the explanation and remediation layer. I want the math to be the math.

Recurring scans, fix tracking, project workflows. Each scan creates a snapshot. Issues are tracked across scans, so I can see what's fixed, what's regressed, and what's been sitting unresolved for three months. On top of that is a real remediation model — canonical issues, owners, due dates, notes, history comparisons between runs. Not just "here's a list of problems again."

Client-friendly exports. PDF and shareable web reports for people who don't know what an ARIA landmark is. Plain-language summary up top, technical detail underneath for the dev team.

The readiness report. The headline is intentional: "structured signal, not a legal compliance claim." The score, coverage, and critical rule count are all deterministic — they come from the math in lib/readiness.ts, not the model. The principle groupings on the left are the automated WCAG checks. The column on the right is the part most scanners ignore entirely: the manual checks a real audit still needs a human to do, with the related automated evidence pre-collected so the human knows where to look first.

The stack

The app is Next.js 16 with TypeScript on the front end, and a separate BullMQ worker doing the heavier lifting on the back end. The split matters: Playwright crawls, axe runs, AI calls, PDF generation, and Supabase writes all happen on the worker, not in the request-response cycle, so a long scan doesn't block the UI.

The pieces:

  • Next.js / React for the app UI and API routes
  • Playwright + axe-core for the actual accessibility scans
  • BullMQ + Redis for the scan queue and progress polling
  • Supabase for auth, scan history, projects, fix tracking, with RLS so users only see their own data
  • OpenAI Responses API for the plain-English reports and implementation-ready fixes
  • Railway for deployment, with some runtime tuning to keep Playwright happy

The code is organized roughly the way you'd expect. app/ has the pages and API routes. lib/axe-runner.ts is the crawl and axe analysis. lib/openai-client.ts turns raw findings into the structured AI reports. lib/readiness.ts is the deterministic scoring. lib/queue.ts owns BullMQ setup. worker/index.ts is where the async scan jobs actually run. supabase/migrations/ is the database and RLS setup. railway.toml and railpack.json handle deploy config.

One detail worth showing: the page-settle logic before each scan. axe will happily evaluate a page that hasn't finished hydrating, and you end up with phantom violations that disappear two seconds later. The crawler waits on a few signals before handing the page off:

async function waitForPageToSettle(
 page: import("playwright").Page,
 timeoutMs: number,
 settleMs: number
) {
 const shortTimeout = Math.min(10_000, Math.max(1_000, timeoutMs));
 await page.waitForLoadState("load", { timeout: shortTimeout }).catch(() => undefined);
 await page
   .waitForFunction(
     () => Boolean(document.body && document.body.children.length > 0),
     { timeout: shortTimeout }
   )
   .catch(() => undefined);
 if (settleMs > 0) {
   await page.waitForTimeout(Math.min(5_000, settleMs));
 }
}

Nothing fancy. Wait for load, confirm the body actually has children (catches some SPA edge cases where the document loads before React mounts anything), then an optional small settle window for client-side rendering to finish. The .catch(() => undefined) on the waits is intentional — a timeout here shouldn't kill the scan, it just means we proceed with what we've got. Bounded with sane upper limits so a misbehaving page can't hang the queue.

axe then runs against the settled page and returns a violations array. Each violation has a nodes array describing every offending element — HTML snippet, failing selectors, the rule that fired. That's what gets passed to the AI suggestion layer, along with whatever surrounding context I can grab from the page.

One thing I'm fussy about: the AI response is forced through a strict JSON schema. The model isn't producing free-form text that I then try to parse and render. It returns structured data with known fields, and the UI renders from that. If the model goes off-script, the response gets rejected, not displayed. This is the difference between AI you can show a client and AI that occasionally invents a CSS variable that doesn't exist.

In code, that looks like this:

const response = await getClient().responses.create({
 model,
 input: [
   { role: "system", content: SYSTEM_PROMPT },
   {
     role: "user",
     content:
       "Here are the axe-core violations JSON. Return only the JSON report, no extra text.\n" +
       JSON.stringify(inputPayload),
   },
 ],
 text: {
   format: {
     type: "json_schema",
     name: "wcag_plain_english_report",
     strict: true,
     schema: {
       type: "object",
       additionalProperties: false,
       properties: {
         summary: {
           type: "object",
           properties: {
             totalViolations: { type: "number" },
             overallScore: { type: "number" },
             riskLevel: {
               type: "string",
               enum: ["low", "medium", "high"],
             },
           },
           required: ["totalViolations", "overallScore", "riskLevel"],
         },
         issues: { type: "array" },
         nextSteps: {
           type: "array",
           items: { type: "string" },
         },
       },
       required: ["summary", "issues", "nextSteps"],
     },
   },
 },
});

The two flags doing the most work here are strict: true and additionalProperties: false. Together they mean the model can't drift outside the schema, invent extra fields the UI doesn't know how to render, or hand back prose where a number is supposed to go.

Here's an example of what comes back, generated from a real region landmark violation on a recent scan:

{
 "id": "region",
 "impact": "moderate",
 "title": "All page content should be contained by landmarks",
 "whoItHurts": "People who use screen readers, keyboard-only users, and anyone who relies on landmarks or regions to quickly navigate a page.",
 "whyItMatters": "Without landmarks (header, nav, main, aside, footer, or elements with role='region' and an accessible label), people using assistive technology cannot quickly jump to the main content or other sections.",
 "exactFix": "Use semantic landmarks (`header`, `nav`, `main`, `aside`, `footer`) in valid top-level structure, and add unique labels where landmarks repeat.",
 "howToFix": [
   "Identify top-level content areas that are not currently inside landmarks.",
   "Wrap the main page content in a single <main> element. Example: <main id=\"main-content\">…</main>.",
   "For independent blocks like field CTAs, wrap them in <aside> or <section> with a visible heading, or add role=\"region\" plus aria-label.",
   "Ensure each region has a unique, descriptive accessible name.",
   "Run the accessibility checker again and spot-check with a keyboard and screen reader."
 ],
 "exampleSelectors": [
   ".field--name-field-hwc-ctas",
   ".hero-title-container"
 ],
 "occurrences": 2
}

That's the difference I was after. Not "fix landmarks." Two actual selectors from the actual page, an exactFix you can take into a Twig template or a React component, and howToFix steps a developer can work through in order. This is the output that's safe to put in a client report.

A few things I've figured out

The detection layer is a solved problem. Where you can actually help developers is in the gap between knowing there's a problem and knowing what to do about it. That's hours, sometimes days, on a real audit.

The more context the AI suggestion has, the better it gets. A bare WCAG citation isn't useful. A recommendation that references the actual selector, the actual variable, and ideally the project's design tokens is what saves time. A lot of the current work is getting more of that context into the prompt without making the system slow or expensive — AI summaries, screenshots, worker concurrency, and anti-bot fallback are all configurable so I can dial cost and behavior per scan.

The other thing worth saying is that the AI part has to stay in its lane. The score is the score. AI translates axe's findings into language a human can use, and that's the entire scope of its job. The actual compliance math is plain TypeScript, sitting in lib/readiness.ts, doing addition and multiplication on rule weights:

const effectiveOccurrences = rule.hasExplicitOccurrences
 ? rule.occurrences
 : Math.max(1, rule.pages.size);

weightedOccurrences +=
 (IMPACT_SCORE_WEIGHT[rule.impact] ?? IMPACT_SCORE_WEIGHT.unknown) *
 effectiveOccurrences;

const pagesScanned = Math.max(1, allPages.size);
const uniqueRuleCount = byRule.size;

const densityPenalty = (weightedOccurrences / pagesScanned) * 0.8;
const diversityPenalty = uniqueRuleCount * 1.5;
const criticalPenalty = criticalRuleCount * 5;

const penalty = densityPenalty + diversityPenalty + criticalPenalty;
const overallScore = Math.max(0, Math.min(100, Math.round(100 - penalty)));

Three penalties: density (how many weighted violations per page), diversity (how many distinct rules are failing), and a heavier penalty for every critical-impact rule. Run the same site twice with no changes and you get the same number both times. Fix something and the number moves for a reason you can point to in the data. There's no model in the loop on any of that.

The thing I most want our team to take from this is that fixing accessibility issues after the fact is way more expensive than writing accessible code in the first place. If developers see specific fixes for the same kinds of mistakes over and over, the patterns start to stick and the next project starts ahead.

What it's teaching me

Honest disclaimer: the scoring math and the scan engine are both still in flux. The penalty weights in readiness.ts are educated guesses right now — I'll know more about whether they produce useful relative scores once I've run AuditEase against a couple dozen more sites. The crawler has edge cases I haven't hit yet. Plenty of features are missing.

That part doesn't bother me, though, because building this has been the fastest way I've ever actually learned WCAG. Every time I add a rule I have to figure out what failing it really looks like in real markup, and then describe both the violation and the fix in language a non-developer would accept. You can't bluff your way through that. Years of running other people's scanners didn't teach me half as much.

That's the real point, honestly. Even if AuditEase never goes any further than the version I use myself, the project has already paid for itself in what it's making me learn about the spec, the failures developers actually ship, and the patterns that prevent them next time.

Where it's going

Still early. I'm running AuditEase against real client work to stress-test the parts that need to hold up in the field. Next on the list: better crawl configuration for multi-site environments, deeper integration with our Drupal workflow, and tightening up suggestion quality on the trickier WCAG criteria. (The 1.4.13 hover-content stuff is genuinely hard to give good code-level advice on. Working on it.)

More posts as it develops. If you're doing accessibility work and any of this resonates, I'd love to compare notes.

/projects/building-auditease-what-im-learning-writing-my-own-wcag-scanner austin.amento.dev