Accessibility test toolkit

Practice on a broken page

The docs site serves two copies of one team settings page. /demo/team has ten accessibility problems in it. /demo/team/fixed is the same page with all ten repaired. Run each command below against both URLs and compare the output. Three problems come back from axe-core, the open-source rule engine a1 axe runs inside the page. Two more come back only when you name the rule. The last five need the accessibility tree or a screen reader.

The practice page

Start the docs site, which serves both copies on port 4321.

npm run dev:docs
/demo/team The page with the problems. Open it in a browser.
/demo/team/fixed The repaired page. Open it in a browser.

The page is a team settings screen. A table lists three members, each row holding an avatar, a role, a status pill, a last-active date, and an icon-only button that removes the member. Above the table sit a filter field, three status filter chips, and an Invite member button that opens a dialog with an email field, a role select, and a submit button. Submitting shows a confirmation above the table.

Both URLs render packages/docs/src/components/demo/team-settings.astro, which takes one fixed prop. Every difference between the two pages is one of the ten fixes, so reading that file tells you exactly what changed.

Run the audit

a1 audit runs axe, reads the accessibility tree, and works out which WCAG success criteria the page triggers. A success criterion is one testable requirement in the Web Content Accessibility Guidelines, the standard most accessibility requirements come from. Start here on any page you have not checked before.

a1 audit http://localhost:4321/demo/team
audit  http://localhost:4321/demo/team

✗ 10 problems to fix.

Problems
  1.1.1  Non-text Content  [A]
      Images must have alternative text  critical
      3 failing elements:
        tr[data-status="active"][data-member-row=""]:nth-child(1) > td:nth-child(1) > .member > img
        ...
      Fix it:  a1 wcag rule image-alt
  4.1.2  Name, Role, Value  [A]
      Buttons must have discernible text  critical
      3 failing elements:
        tr[data-status="active"][data-member-row=""]:nth-child(1) > td:nth-child(5) > .icon-button[type="button"]
        ...
      Fix it:  a1 wcag rule button-name
  1.4.3  Contrast (Minimum)  [AA]
      Elements must meet minimum color contrast ratio thresholds  serious
      4 failing elements:
        tr[data-status="active"][data-member-row=""]:nth-child(1) > .faint
        tr[data-status="active"][data-member-row=""]:nth-child(2) > .faint
        .pill--pending
        and 1 more element
      Fix it:  a1 wcag rule color-contrast

Three rules, ten failing elements: the three avatar images have no alt attribute, each of the three remove buttons contains one svg and no text, and the Pending pill plus the three last-active dates are set in #a2aab5, which reaches 2.34:1 on white and 2.16:1 on the pill's #f4f6f8 background where 4.5:1 is the requirement.

The bottom of the same report counts the page: 2 headings (level 1, 3). That is the fourth problem, and no rule in the default run reported it. The next section gets axe to.

a1 audit http://localhost:4321/demo/team/fixed
audit  http://localhost:4321/demo/team/fixed

✓ Nothing failed the automated checks.

a1 axe alone gives the same three violations without the criterion rollup. Either command exits 4 on /demo/team and 0 on /demo/team/fixed, which is what makes them usable in a script. See script the CLI.

Rules axe skips by default

A default run covers only the axe rules that map to a WCAG criterion. Two of the problems on this page belong to rules axe tags as best practice instead, so you have to name them with --rule.

a1 axe http://localhost:4321/demo/team --rule heading-order
  ✗ heading-order  moderate
      Heading levels should only increase by one
      1 failing element
        .toolbar-heading
          <h3 class="toolbar-heading">Members</h3>

The second one needs the dialog open. axe reads the rendered page, and the dialog markup is hidden until the Invite member button runs. The demo page opens it for you when the URL ends in #invite.

a1 axe 'http://localhost:4321/demo/team#invite' --rule aria-dialog-name
  ✗ aria-dialog-name  serious
      ARIA dialog and alertdialog nodes should have an accessible name
      1 failing element
        .modal
          <div class="modal" role="dialog" aria-modal="true" data-invite-dialog="">
          Fix any of the following:
          • aria-label attribute does not exist or is empty
          • aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
          • Element has no title attribute

The fixed page uses a native <dialog> with aria-labelledby="invite-title". aria-dialog-name only checks elements carrying role="dialog", so on the fixed page it reports neither a violation nor a pass. The next section confirms the name another way.

Scan what is rendered

Anything behind a click is invisible to a1 axe. Give the page a URL that opens the dialog, as this one does with #invite, or open it in a screen reader session and scan from there.

The accessibility tree

a1 tree prints the role and accessible name of every node a screen reader can reach, taken from Playwright's ariaSnapshot(). Run it against both dialogs.

a1 tree 'http://localhost:4321/demo/team#invite'
a1 tree 'http://localhost:4321/demo/team/fixed#invite'
- dialog:
  - heading "Invite member" [level=2]

- dialog "Invite member":
  - heading "Invite member" [level=2]

The heading is right there in both. Only the fixed dialog puts it in the dialog's name, which is the string a screen reader reads when the dialog opens.

--role keeps matching nodes and their ancestors, which is the fastest way to check a set of controls at once.

a1 tree http://localhost:4321/demo/team --role button
- main:
  - button "All"
  - button "Active"
  - button "Pending"
  - button "Invite member"
  - table "3 members in this workspace":
    - rowgroup:
      - row "Dana Whitfield dana@northwind.test Admin Active 2 hours ago":
        - cell:
          - button

The remove buttons have no name, matching the button-name violation. The three filter chips come back as buttons, which they are not: they are <div role="button"> with no tabindex. The tree cannot tell you that, because a role is all it reads. The keyboard can.

Tab order

a1 sr drives a screen reader. --sr virtual runs the simulated reader in headless Chromium, which needs no VoiceOver or NVDA setup and is what to use in a script. See screen readers for what the simulated reader does and does not model.

a1 sr start http://localhost:4321/demo/team --sr virtual
a1 sr press Tab --phrase

Eight presses of Tab on each page, one line per press:

/demo/team                                /demo/team/fixed

link, General                             link, General
link, Billing                             link, Billing
link, Team, current page                  link, Team, current page
searchbox, placeholder Filter members     searchbox, Filter members, placeholder Filter members
button, Invite member                     button, All, pressed
button                                    button, Active, not pressed
button                                    button, Pending, not pressed
button                                    button, Invite member

On the left, Tab goes from the filter field straight to Invite member. The three filter chips are not in the tab order at all, so a keyboard user cannot filter the table. The three unnamed button lines after it are the remove buttons.

On the right the chips are <button> elements, so Tab reaches them, and aria-pressed makes the reader say pressed or not pressed.

axe has a rule for this, focus-order-semantics, and it reports the page as inapplicable. The rule reads elements that are in the tab order and asks whether their role fits. An element left out of the tab order never reaches it.

Opening the dialog

Move to the Invite member button, activate it, and read where the reader ended up.

a1 sr goto --name "Invite member" --role button
a1 sr activate
a1 sr read --phrase
/demo/team          button, Invite member
/demo/team/fixed    textbox, Email address, required

The broken page unhides a div and leaves focus on the button that opened it. A screen reader user hears nothing change. Tab from there walks the table behind the dialog instead of the fields inside it, and Escape does not close anything.

The fixed page calls showModal() on a <dialog>. The browser moves focus to the first field, keeps Tab inside the dialog, makes the rest of the page inert, closes on Escape, and returns focus to the Invite member button afterwards. That is five behaviors for one method call, and no axe rule reports any of them.

a1 wcag 2.4.3 and a1 wcag 2.1.2 are the criteria this fails. a1 audit already listed both under Check by hand.

The confirmation message

Fill the form and submit it. a1 sr checkpoint marks the transcript so the check that follows reads only what came after it.

a1 sr goto --name "Email address"
a1 sr type "sam@northwind.test"
a1 sr checkpoint invite
a1 sr goto --name "Send invitation" --role button
a1 sr activate
a1 sr expect "Invitation sent" --since invite
/demo/team          Matched: nothing        Result: fail    exit 4
/demo/team/fixed    Matched: polite: Invitation sent to sam@northwind.test.    Result: pass    exit 0

Both pages show the same green confirmation on screen. The broken page builds a plain <p> and appends it, so a screen reader has no reason to read it and a user who never looks away from the form learns nothing. The fixed page renders <p class="banner" role="status"> from the start and sets its text, and the reader announces it without moving focus.

The empty live region has to stay in the rendered page for this to work. The fixed page collapses it with .banner:empty { padding: 0; border: 0; } rather than display: none, which would take it out of the page and back to the broken behavior.

a1 wcag 4.1.3
4.1.3  Status Messages  [AA]

Testing it
  This needs a real page. Trigger a status message and confirm a screen reader announces it without moving
  focus.
    a1 sr expect <text>

If it fails
  ARIA22  Using role=status to present status messages  aria

What axe called a pass

The filter field on the broken page is <input type="search" placeholder="Filter members"> and nothing else. axe's label rule accepts a non-empty placeholder as a name, so it passes, and a1 audit --json records criterion 3.3.2 Labels or Instructions with axeVerdict: "pass".

The reader disagrees:

/demo/team          searchbox, placeholder Filter members
/demo/team/fixed    searchbox, Filter members, placeholder Filter members

The placeholder is gone the moment the user types a character, and it is the only thing naming the field. The fix is a <label for="member-filter">. Keep the placeholder or drop it, but do not let it be the label.

Criterion 4.1.3 goes the other way. a1 audit --json records it as applicability: "not-detected" on the broken page, because a missing live region leaves nothing to detect. A criterion no tool claims is one to check by hand.

The same run from an agent

Every MCP tool calls the same function as the matching command, so an agent gets the same results. Point your client at the server first.

{
  "mcpServers": {
    "a11ied": {
      "command": "npx",
      "args": ["-y", "a11ied", "mcp"]
    }
  }
}
Audit http://localhost:4321/demo/team with a11ied. Report every axe violation,
then check the three things axe cannot: whether Tab reaches the status filter
chips, whether opening the Invite member dialog moves focus into it, and whether
submitting the invite form announces a confirmation. Compare each answer against
http://localhost:4321/demo/team/fixed.

The tool calls that answer it:

audit target the demo URL. Returns the three violations, the criterion rollup, and the heading levels.
run_axe ruleIds: ["heading-order"], then ruleIds: ["aria-dialog-name"] against the #invite URL.
tree role: "button" for the control names, or the #invite URL for the dialog's name.
sr_session action: "start", target: "virtual", allowVirtual: true, url the demo page.
sr_action action: "press" with keys: ["Tab"] for the tab order, then goto, activate, type, and checkpoint for the dialog and the form.
sr_expect pattern: "Invitation sent", since: "invite". Returns exitCode 4 on the broken page and 0 on the fixed one.
wcag_rule ruleId for each failing rule, to get the criteria and the techniques that fix it.

An agent that has the agent skill installed already knows this order. See test from an AI agent for the full set of tools and the MCP reference for their inputs.

The ten fixes

Each row is one difference between the two pages, and each one is a few characters of markup or one method call.

image-alt Avatar images get alt="". The name is already in the cell next to them, so an empty alt is the right answer, not a description.
button-name Each remove button gets aria-label="Remove Dana Whitfield", and its svg gets aria-hidden="true".
color-contrast The last-active dates move from #a2aab5 to #414b59, 8.84:1 on white. The Pending pill becomes #6b4708 on #fdf0d5, 7.35:1.
heading-order The Members heading becomes an h2 under the page's h1.
aria-dialog-name The dialog gets aria-labelledby="invite-title", pointing at the heading it already had.
2.1.1 Keyboard The filter chips become <button type="button"> instead of <div role="button">, which puts them in the tab order and gives them Enter and Space for free.
4.1.2 Name, Role, Value The chips get aria-pressed, so a screen reader says which one is selected. Without it, the dark background is the only thing that says so.
2.4.3 Focus Order The dialog becomes a <dialog> opened with showModal(), which moves focus in, keeps it there, and returns it to the trigger on close.
3.3.2 Labels or Instructions The filter field gets a visible <label for="member-filter">Filter members</label>.
4.1.3 Status Messages The confirmation moves into a role="status" paragraph that is in the page before the form is submitted.

Point the same commands at your own app next. a1 audit first, then a1 tree for the names and roles, then a1 sr for the five things neither of them can report.