← Back to the blog

Accessibility

Accessibility Belongs in the Project Template

In April 2026, four days before the deadline, the Department of Justice gave state and local governments another year to make their websites accessible. Large entities now must comply by April 26, 2027, and smaller entities and special districts by April 26, 2028. The substantive requirements of the 2024 rule are unchanged: WCAG 2.1 Level AA, for nearly everything a public entity puts on the web.

I build web applications for a municipal government, and I read that extension as a reset clock, not a stopped one. Here is how I made accessibility something our projects start with instead of something we retrofit.

The law, briefly

The ADA’s Title II covers state and local governments, and since 2024 it has an explicit technical standard: WCAG 2.1 AA. Title III covers private businesses open to the public. There is no equivalent regulation there yet, but DOJ guidance and the courts lean on WCAG anyway, so the practical target is the same.

I target WCAG 2.2 AA. It is a superset of 2.1, it adds sensible criteria like minimum target size and visible focus, and aiming one version ahead means the next rule update is not a fire drill.

The idea: put the gate in the template

My department scaffolds new applications from a dotnet new template: .NET 10 API, Vue 3 front end, Clean Architecture. Every audit I have seen fail did so for the same reason. Accessibility was checked at the end, by which point the problems were structural.

So the accessibility suite ships inside the template. A project generated today has a failing-capable WCAG gate in its first commit, before anyone writes a feature.

The scan

The core is a small helper around axe-core and Playwright:

export const WCAG_TAGS = [
  "wcag2a", "wcag2aa",
  "wcag21a", "wcag21aa",
  "wcag22aa",
  "section508",
];

export async function checkAccessibility(page, testInfo, options = {}) {
  let builder = new AxeBuilder({ page }).withTags(WCAG_TAGS);
  // per-page rule exceptions and excluded selectors applied here
  const results = await builder.analyze();

  await testInfo.attach("accessibility-scan-results", {
    body: JSON.stringify({ url: results.url, passes: results.passes.length,
      incomplete: results.incomplete.map(r => r.id),
      violations: results.violations }, null, 2),
    contentType: "application/json",
  });

  expect(results.violations.length, formatViolations(results.violations)).toBe(0);
}

Three decisions matter here.

Axe tags are cumulative, so a 2.2 AA audit means listing the 2.0 and 2.1 tags too. Miss that and you silently run a fraction of the rules.

The scan result is attached to the report on every run, pass or fail. A green test still shows what was covered, and the incomplete list — things axe could not decide, often contrast over images — becomes a review queue for a human.

Failures are formatted for people: rule, impact, help link, the offending selectors. A JSON diff of the whole result set teaches nobody anything.

The helper also excludes the Vite and Vue DevTools overlays. One of them is a div with an aria-label and no role, a real 4.1.2 failure that never reaches production and that I cannot fix. Exceptions for anything else are per page, and have to be written down in the test.

The pages

The spec loops over every page a fresh project serves without signing in, including the ones teams forget: help, privacy, terms, the accessibility statement itself.

await page.goto(path);
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(path)}$`));
await expect(page.getByRole("heading").first()).toBeVisible();
await checkAccessibility(page, testInfo);

That heading wait is the most important line. In a single-page app, scanning right after navigation audits a loading spinner, and spinners pass. Waiting for the route’s own content is what makes a green result mean something.

Because the file lives in a template, the page list is conditional. Choose local identity and /register and /forgot-password are scanned; choose no auth and they never exist. There is also a test that an unauthenticated visitor’s redirect lands on an accessible page, since login is the front door for screen reader users too.

The pipeline

A GitHub Actions workflow runs the suite on every pull request touching the UI. Three things make it more than a red X.

It blocks the deploy. The deployment workflow calls this one first, so a regression cannot reach production.

Nobody downloads anything to see what broke. A jq step turns Playwright’s JSON output into a passed/failed/flaky table on the run’s Summary tab, with failed tests listed by name. I allow retries in CI because dev servers start slowly, and the flaky column keeps that honest: a scan that only passes on retry is visible.

Every release carries its evidence. On publish, the report is zipped as playwright-report-<tag>-<result>.zip and attached to the GitHub Release, including when the result is failure. For a public entity, a dated record of what was scanned for each version shipped is worth as much as the scan. The job that does this is the only one with write permission.

What this does not prove

Automated tools catch roughly a third to half of WCAG success criteria. Axe can tell me an image has alt text, not whether the text is useful. It cannot judge focus order, keyboard traps, or reading level. And WCAG 2.2’s automated coverage is thin: most of its new criteria need a person.

My suite also scans each page’s initial state. Validation errors, open menus, and modals are not covered yet, and it runs against the dev server rather than a production build. Both are next.

So this is a regression net, not an audit. It guarantees the easy failures never come back, which frees the manual review for the things only a person can assess.

Takeaways

Put the gate where projects begin, not where they end. Wait for real content before scanning. Make failures readable. Keep the evidence with the release. And say plainly what your automation does not cover, because the people relying on your site will find it either way.

Found this useful? Share it.

A ready-made LinkedIn post is one click away, or share the link wherever your team talks.

Discuss your project ↗