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