Coverage is lost at the ticket, not at the tool.
A ticket says “add a newsletter dialog”. It never says that focus moves into the dialog, that Escape closes it, or that focus comes back to the button. So nobody writes that test, the scan passes, and the barrier ships. Six steps keep that from happening, all free.
-
Setup · Once, for the coding agent
Give the agent the rule
Add the free MCP server, then paste this into
CLAUDE.md,AGENTS.mdor.cursor/rules. A server can’t make an agent call it; the rule it reads on every request can.When asked to design, build or change UI, call the easeweb before_building tool with the request before writing code or drawing the design. Put its requirements into the feature’s own test, not a separate accessibility suite. After building, call test_steps, write each test it lists as the agent’s, and hand the "a person must check" items to me as written. Never mark a [person] test as done, and never remove one.The last clause matters most. An agent reports what it was asked to finish, and a
[person]item it was told to hand over is the one it won’t quietly close. -
Plan · Product owner
Write the behaviours into the ticket
Before the work is assigned, run
before_buildingon the request in your words. It returns the behaviours to build in, split by who can confirm them. Paste both lists into the ticket. For colours and design tokens,check_palettedoes the same: each pair’s contrast ratio against 1.4.3 and 1.4.11, and the nearest passing shade.a newsletter signup dialog that opens from a button
The agent can check
- Opening the dialog from the keyboard moves focus inside it.
- Tab and Shift + Tab never reach the background while it is open.
- Escape and the visible close button each close it, and focus returns to the trigger.
- The dialog has an accessible name.
A person must check
- Focus lands at a useful point for the task in the dialog.
- A screen reader announces the dialog name when it opens.
- The dialog stays usable at increased zoom and in forced colors.
Abridged; the full answer adds the reference code. This is the Plan stage of the development cycle.
-
Code · Developer
Lint, set to error
The framework’s accessibility rules, while typing. Install the one for your stack and set it to error:
- React, Next:
eslint-plugin-jsx-a11y, its recommended rules as errors - Vue:
eslint-plugin-vuejs-accessibility - Svelte: the compiler’s a11y warnings, failed with
svelte-check --fail-on-warnings - Angular:
@angular-eslint/templateaccessibility rules
Catches: Patterns in the source: an image with no alt attribute, a click handler on a div, a label with nothing to point at.
Misses: Anything that only exists once the page runs.
- React, Next:
-
Pull request · Developer
Scan what changed, rendered
Install Playwright with axe, with a desktop and a phone project so every test also runs on the phone layout. Scan what the pull request changed: the component, in its own test, once it is open. That takes a second. A whole-page scan is for the shared templates, one page each, with the reflow check at 320px.
npm i -D @playwright/test @axe-core/playwright npx playwright install --with-deps chromium// playwright.config.js import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ use: { baseURL: process.env.BASE_URL ?? 'http://localhost:5173' }, // Every test runs twice: the desktop layout and the phone one. projects: [ { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'phone', use: { ...devices['Pixel 7'] } } ] });// tests/a11y/scan.spec.js import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; const scan = (page, within) => new AxeBuilder({ page }).withTags(TAGS).include(within).analyze(); // What the pull request changed: the component, once it is open. test('newsletter dialog has no axe violations', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: 'Subscribe' }).click(); const { violations } = await scan(page, '[role=dialog]'); expect(violations.map((v) => v.id)).toEqual([]); }); // Shared templates: one page each, at rest. Seconds per page. for (const path of ['/', '/products/example', '/checkout']) { test(`axe ${path}`, async ({ page }) => { await page.goto(path); const { violations } = await scan(page, 'body'); expect(violations.map((v) => v.id)).toEqual([]); // Reflow (WCAG 1.4.10): no sideways scroll at 320px wide. await page.setViewportSize({ width: 320, height: 800 }); const over = () => document.documentElement.scrollWidth > 320; expect(await page.evaluate(over)).toBe(false); }); }Catches: What a general rule can describe: a missing name, low contrast, broken markup.
Misses: Whether the dialog traps focus, whether the error is announced, whether the name means anything. Text over an image or gradient, which axe marks incomplete rather than failing. It never presses a key.
-
Pull request · Developer or agent
Assert the ticket’s behaviours in the feature’s own test
Not a separate accessibility suite, which is skipped when the build is slow and deleted when it flakes. The ticket’s first list becomes the assertions.
// tests/newsletter.spec.js // The feature’s own test, with its accessibility requirements in it. test('newsletter dialog keeps focus', async ({ page }) => { await page.goto('/'); const opener = page.getByRole('button', { name: 'Subscribe' }); await opener.focus(); await page.keyboard.press('Enter'); const dialog = page.getByRole('dialog', { name: 'Newsletter' }); await expect(dialog).toBeVisible(); // Focus moved in await expect(dialog.locator(':focus')).toHaveCount(1); // and stayed in for (let i = 0; i < 10; i++) await page.keyboard.press('Tab'); await expect(dialog.locator(':focus')).toHaveCount(1); // and came back await page.keyboard.press('Escape'); await expect(opener).toBeFocused(); });Catches: Where focus goes, what each key does, which attributes change, what a live region holds. The only layer that encodes the requirement.
Misses: What a screen reader says, and whether the words make sense to someone who can’t see the page.
-
Pull request · CI
Run all three on every pull request
One job runs lint, the scan and the feature tests against a preview URL, as a required check on the main branch. Then open a pull request that breaks one rule on purpose and watch it go red: that is the only proof the job runs.
# .github/workflows/accessibility.yml name: Accessibility on: pull_request jobs: accessibility: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run lint - run: npx playwright install --with-deps chromium - run: npx playwright test env: # playwright.config.js reads this as baseURL. BASE_URL: ${{ vars.PREVIEW_URL }} -
Before release · A person
Do the two-minute script
The ticket’s second list is the part no test covers.
test_stepsreturns it as a script: which keys to press and what should happen. Do it once on a phone too, with VoiceOver or TalkBack; no desktop run covers that.- Open it with Enter on the button. You hear “Join the newsletter, dialog”.
- Press Tab round once. Each control is read, and nothing behind the dialog is.
- Press Escape. The button you started on is read again.
- At 200% zoom nothing is cut off. In forced colors the dialog’s edge and the focus ring are visible.
It also returns each item as a test marked
[person]andfixme, so CI lists what only a person can confirm on every run. Never delete one or take off itsfixme; say in the pull request who checked it.
Two traps
Green is not the same as tested.
A test that passes empty
Asked for a test, an agent asserts that the dialog is visible and calls the ticket done. The skeleton from test_steps leaves a placeholder assertion in every test, so one that was never written fails. In review, a test with no key press and no focus assertion tests nothing.
A check that silently skips
The scan is scoped to the wrong element, the test never reaches the page it names, or the job runs on a branch nobody merges from. The deliberate failure in step 6 is how you find out; repeat it after any change to CI.
Honestly
What the MCP server does here.
- Gives the requirements at ticket time, in the guide’s words, not from the agent’s memory of ARIA.
- Fixes the split between what the agent can check and what a person must, in the tool’s own output.
- Returns the test skeleton with the person’s items already marked, so CI shows the gap.
And what it doesn’t.
- Make the agent call it. Only your project rule does that.
- Make anyone run the two-minute script.
- Confirm a fix. It never sees your page.
Step 7 is where I come in
On ongoing support, a Section 508 Trusted Tester does the two-minute scripts and a full test by hand on a schedule. Each finding goes back upstream as a rule for your agents and a regression test in your CI.