Skip to content
easeweb

For developers

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.

  1. Setup · Once, for the coding agent

    Give the agent the rule

    Add the free MCP server, then paste this into CLAUDE.md, AGENTS.md or .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.

  2. Plan · Product owner

    Write the behaviours into the ticket

    Before the work is assigned, run before_building on 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_palette does 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.

  3. 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/template accessibility 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.

  4. 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.

  5. 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.

  6. 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 }}
  7. Before release · A person

    Do the two-minute script

    The ticket’s second list is the part no test covers. test_steps returns 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.

    1. Open it with Enter on the button. You hear “Join the newsletter, dialog”.
    2. Press Tab round once. Each control is read, and nothing behind the dialog is.
    3. Press Escape. The button you started on is read again.
    4. 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] and fixme, so CI lists what only a person can confirm on every run. Never delete one or take off its fixme; 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.