Accessibility Testing skill for Codex
axe in CI, humans for what axe cannot see. Codex adds axe-core scans where they actually catch defects: real-browser checks for page states after interaction, jsdom checks for shared components, and a CI gate that blocks new serious and critical violations without drowning legacy apps. It then runs or scripts the keyboard, screen reader, zoom and contrast pass that scanners miss, and reports each finding with its WCAG success criterion.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- accessibility-testing Installs to ~/.agents/skills/accessibility-testing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install accessibility-testing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project accessibility-testing Installs to .agents/skills/accessibility-testing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/accessibility-testing.zipunzip -o accessibility-testing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) accessibility-testing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $accessibility-testing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds automated accessibility checks with axe-core (jest-axe, vitest-axe, @axe-core/playwright, cypress-axe, pa11y-ci) and pairs them with the manual keyboard, screen reader, zoom and contrast passes that automation cannot do, gating CI on new serious and critical violations. Use when asked to add a11y tests, fail the build on accessibility regressions, triage axe violations, test a component or page for WCAG 2.2 AA, or check keyboard and screen reader behaviour before release.
Use it when
- Adding automated a11y checks to component or E2E tests
- Accessibility regressions keep shipping and CI should block them
- Triaging a pile of axe violations into fixes with owners
- Preparing a manual accessibility pass before a release
Use something else when
- You need a formal WCAG conformance audit report of a whole site: use wcag-accessibility-audit
- The question is which ARIA pattern a widget should implement: use aria-patterns
- You are testing native iOS or Android accessibility: use mobile-accessibility
How to check the result
Removing a label or lowering a contrast token makes the a11y test fail with the rule id and selector, and the suite is green and deterministic on two consecutive runs once reverted.
The mistake it prevents
Treating a passing jsdom axe scan as proof of accessibility, when jsdom cannot evaluate contrast and no scan checks focus order or announcements.
Done well means: every page and interactive component has an automated axe scan that blocks new serious or critical violations, every release gets a short manual pass for what scanners cannot see (focus order, announcements, meaningful names), and each finding names the WCAG success criterion, the element, and the user who is blocked.
Automated rules catch only part of real barriers (Deque’s own estimate is roughly half by issue volume, far less by criteria). A green axe run means “no machine-detectable defects”, never “accessible”.
Inspect first
- Read
AGENTS.mdfor the test runner, package manager and CI command. - Find existing tooling:
rg -n "axe|pa11y|toHaveNoViolations|checkA11y|AxeBuilder" -g '!node_modules'. - Identify the surfaces: component tests (Jest/Vitest + Testing Library), E2E (Playwright,
Cypress), Storybook (
@storybook/addon-a11y), or a list of public URLs. - Ask which conformance target applies. Default to WCAG 2.2 level AA unless the user names another (EN 301 549 and Section 508 map onto 2.1 AA).
- Installing axe packages needs network; in a sandbox with network off, ask for approval or check whether they are already in the lockfile.
Choose where to scan
| Surface | Tool | Catches | Misses |
|---|---|---|---|
| Unit/component (jsdom) | jest-axe or vitest-axe | Missing names, bad ARIA, label associations | Contrast (no layout in jsdom), focus, real CSS |
| Real browser, app flows | @axe-core/playwright | Contrast, hidden content, states after interaction | Anything needing human judgement |
| Cypress suites | cypress-axe | Same as browser | Same |
| Storybook | addon-a11y + test runner | Every story state | Page-level landmarks, flows |
| Static URL list / crawl | pa11y-ci or @axe-core/cli | Quick site sweep | States behind login or interaction |
Default: browser-level scans on key pages and states in the existing E2E suite, plus jsdom
scans for shared components. jsdom scans alone are not enough because color-contrast is
skipped without a real layout.
Component scans (jsdom)
// vitest or jest with Testing Library
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe"; // vitest: import { axe } from "vitest-axe"
import { SignupForm } from "./SignupForm";
it("has no detectable a11y violations, including the error state", async () => {
const { container, getByRole } = render(<SignupForm />);
await userEvent.click(getByRole("button", { name: /create account/i }));
expect(await axe(container)).toHaveNoViolations();
});
Register the matcher once in setup (expect.extend(toHaveNoViolations) from jest-axe, or
the vitest-axe/extend-expect entry). Scan after interaction too: error messages, open
menus and dialogs are where most defects hide.
Browser scans (Playwright)
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const WCAG = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
test("checkout page passes axe at WCAG 2.2 AA", async ({ page }) => {
await page.goto("/checkout");
await page.getByRole("button", { name: "Apply coupon" }).click();
const results = await new AxeBuilder({ page })
.withTags(WCAG)
.exclude("#third-party-chat") // not ours; track separately
.analyze();
const blocking = results.violations.filter(v => ["serious", "critical"].includes(v.impact ?? ""));
expect(blocking, JSON.stringify(blocking.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), null, 2)).toEqual([]);
});
Wrap this in a fixture (makeAxeBuilder) so tags and exclusions live in one place.
Cypress equivalent: cy.injectAxe() after cy.visit(), then
cy.checkA11y(null, { runOnly: { type: "tag", values: WCAG }, includedImpacts: ["serious", "critical"] }).
Gating policy
| Situation | Gate |
|---|---|
| New suite on a clean app | Fail on any violation |
| Legacy app with many existing violations | Fail on serious and critical; record the rest in a baseline file keyed by rule id + selector; fail when the baseline grows |
incomplete results (axe “needs review”) | Never fail CI; list them for the manual pass |
| Third-party widgets | Exclude by selector with a comment and an owner; report upstream |
Never disable a rule globally to get green. Disable per scan, with a comment naming the reason and an issue link, and only when the rule is a confirmed false positive.
Manual pass
Run it per release and for every new interactive component. The full checklist with WCAG references is in references/manual-checklist.md; read it when doing a release pass or writing up findings. Minimum:
- Keyboard only: reach and operate everything with Tab, Shift+Tab, Enter, Space, arrows, Escape; focus is always visible and never trapped (except inside open modals)
- Focus order follows visual order; focus moves into dialogs and returns on close
- Screen reader (VoiceOver on macOS/iOS, NVDA on Windows): names, roles, states and live announcements (form errors, toasts, cart count) make sense without seeing the screen
- Zoom to 200% and reflow at 320 CSS px width: no loss of content, no two-axis scrolling
- Contrast: text 4.5:1 (3:1 for 18.66px bold / 24px+), UI component and focus indicators 3:1
- Pointer targets at least 24x24 CSS px or adequately spaced (WCAG 2.2, 2.5.8)
- Motion respects
prefers-reduced-motion; nothing flashes more than 3 times a second - Forms: visible labels, errors tied to fields (
aria-describedby), no colour-only cues
Codex cannot operate a screen reader. When the pass needs one, write the exact steps and expected announcements for the user to run, and record their results.
Triage a violation
- Read the rule id and
helpUrlin the axe result, then the failingtargetselector. - Reproduce in the browser; confirm it is real (axe false positives are rare but happen with custom shadow DOM and off-screen content).
- Fix at the shared component when possible, not per page.
- Add or keep a test that scans the state where it occurred.
| Common rule | Usual fix |
|---|---|
button-name, link-name | Visible text or aria-label on icon buttons |
label | <label for> or wrap the input; placeholder is not a label |
color-contrast | Adjust tokens in the design system, not one-off overrides |
aria-* attribute rules | Remove ARIA that native elements already provide |
region, landmark-* | Wrap content in main, nav, header, footer |
nested-interactive | Do not put a button inside a link or clickable card |
Verification
- Introduce a deliberate defect (remove a label) and confirm the test fails with a readable message naming the rule and selector; then revert.
- Run the suite twice; scans must be deterministic. Wait for content to settle before
analyze()(use a web-first assertion on the final element, not a timeout). - Confirm CI uploads the violations JSON or HTML report as an artifact.
Output template
## Accessibility test report: <scope>
Target: WCAG 2.2 AA | Tools: axe-core <version> via <runner> | Manual: keyboard, VoiceOver
### Automated
| Page/state | Critical | Serious | Moderate | Minor | Needs review |
### Findings
1. [serious] `color-contrast` - `.price--sale` 3.1:1 on #fff (SC 1.4.3). Users with low
vision cannot read sale prices. Fix: use `--color-sale-700` (5.2:1).
### Manual pass
- Keyboard: <result> - Screen reader: <result or steps handed to user>
### Not covered
<states not scanned, third-party widgets excluded, criteria needing human review>
Gotchas
- Scanning only the initial page load misses modals, menus, validation errors and loaded data.
aria-hidden="true"on a focusable element creates a ghost tab stop; axe flags it, fix it.- jsdom passes contrast trivially. A component suite with zero contrast failures proves nothing.
- Adding
roleandaria-*to fix a rule often makes it worse. Prefer the native element. - Axe versions add rules; pin the version and update deliberately so a dependency bump does not suddenly fail every build.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Accessibility Testing"
short_description: "axe in CI, humans for what axe cannot see"
default_prompt: "$accessibility-testing Add axe accessibility checks to our Playwright suite and fail CI on serious issues" references/manual-checklist.md
# Manual accessibility pass, mapped to WCAG 2.2 AA
Use this for a release pass or when writing findings that need a success criterion (SC)
number. Each row is something automated scanners cannot decide on their own. Record every
row as pass, fail (with element and user impact), or not applicable.
## Keyboard
| Check | How | SC |
|---|---|---|
| Every control reachable | Tab through the whole page; count stops against visible controls | 2.1.1 Keyboard |
| No keyboard trap | Tab into and out of embeds, editors, date pickers, iframes | 2.1.2 No Keyboard Trap |
| Focus order matches meaning | Order of stops follows reading order, not DOM accidents from CSS grid/flex `order` | 2.4.3 Focus Order |
| Focus visible | Every stop has a visible indicator with 3:1 contrast against adjacent colours | 2.4.7 Focus Visible, 1.4.11 |
| Focus not obscured | Sticky headers, cookie banners and chat bubbles do not fully hide the focused element | 2.4.11 Focus Not Obscured (Minimum) |
| Composite widgets | Tabs, menus, listboxes, grids use arrow keys inside, one Tab stop overall | 2.1.1, 4.1.2 |
| Dialogs | Focus moves in on open, is contained, Escape closes, focus returns to the trigger | 2.4.3 |
| Single-key shortcuts | Can be turned off or remapped, or only work when the component has focus | 2.1.4 Character Key Shortcuts |
## Screen reader
Run with VoiceOver (macOS: Cmd+F5; Safari) and NVDA (Windows; Firefox or Chrome). Navigate by
headings (VO+Cmd+H / H), landmarks (rotor / D) and form controls (F).
| Check | Expected | SC |
|---|---|---|
| Page title | Unique, describes the page | 2.4.2 Page Titled |
| Headings | One `h1`, levels not skipped for styling, headings describe sections | 1.3.1, 2.4.6 |
| Landmarks | `main`, `nav`, `header`, `footer` present; multiple `nav`s labelled | 1.3.1 |
| Control names | Each control announces a name that matches its visible label | 4.1.2, 2.5.3 Label in Name |
| States | Expanded/collapsed, selected, pressed, checked, invalid are announced | 4.1.2 |
| Status messages | Form errors, "added to cart", search result counts announced without moving focus (`role="status"` / `aria-live`) | 4.1.3 Status Messages |
| Images | Informative images described; decorative images silent (`alt=""`) | 1.1.1 |
| Language | `lang` on `html`; inline language changes marked | 3.1.1, 3.1.2 |
## Visual and layout
| Check | How | SC |
|---|---|---|
| Text contrast | 4.5:1 normal, 3:1 large (24px, or 18.66px bold) | 1.4.3 |
| Non-text contrast | Input borders, icons that carry meaning, focus rings at 3:1 | 1.4.11 |
| Resize text | Browser zoom 200%: nothing clipped or overlapping | 1.4.4 |
| Reflow | Viewport 320 CSS px wide (1280px at 400%): single column, no horizontal scroll except tables, maps, code | 1.4.10 |
| Text spacing | Apply line-height 1.5, paragraph spacing 2x, letter 0.12em, word 0.16em: no loss of content | 1.4.12 |
| Colour alone | Errors, required fields, chart series, links in text distinguishable without colour | 1.4.1 |
| Orientation | Works in portrait and landscape unless essential | 1.3.4 |
| Hover/focus content | Tooltips dismissable (Escape), hoverable, persistent | 1.4.13 |
## Pointer and motion
| Check | How | SC |
|---|---|---|
| Target size | Targets at least 24x24 CSS px, or spaced so a 24px circle does not overlap a neighbour | 2.5.8 Target Size (Minimum) |
| Dragging | Every drag action has a single-pointer alternative (buttons, inputs) | 2.5.7 Dragging Movements |
| Pointer cancellation | Actions fire on up-event; sliding off cancels | 2.5.2 |
| Motion | Parallax and auto-animations respect `prefers-reduced-motion`; auto-moving content can be paused | 2.2.2, 2.3.3 (AAA, recommended) |
| Flashing | Nothing flashes more than three times per second | 2.3.1 |
## Forms and authentication
| Check | Expected | SC |
|---|---|---|
| Labels | Persistent visible labels; placeholder is not the label | 3.3.2 |
| Error identification | Error text names the field and the problem, linked via `aria-describedby` | 3.3.1, 3.3.3 |
| Autocomplete | Personal data fields carry `autocomplete` tokens (`email`, `given-name`, `postal-code`) | 1.3.5 |
| Redundant entry | Information already given in the flow is pre-filled or selectable | 3.3.7 |
| Accessible authentication | No cognitive test (puzzle, transcription) without an alternative; paste and password managers allowed | 3.3.8 |
| Timeouts | Users warned and can extend sessions | 2.2.1 |
## Recording a finding
```markdown
- [fail] SC 4.1.3 Status Messages - Cart drawer: "Added to cart" is visual only; NVDA
and VoiceOver announce nothing after activating "Add to cart". Screen reader users do
not know the action succeeded. Fix: render the message in a persistent
`<div role="status">` that exists before the update.
```
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.