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.

  • Testing & QA
  • $accessibility-testing
  • 167 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- accessibility-testing

Installs to ~/.agents/skills/accessibility-testing/ for every project on this machine.

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

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.

~/.agents/skills/accessibility-testing/SKILL.md

--- name: accessibility-testing description: Builds automated accessibility checks with axe-core (jest-axe, vitest-axe, @axe-core/playwright, cypress-axe, pa11y-ci) and pairs them with the… license: MIT ---

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

  1. Read AGENTS.md for the test runner, package manager and CI command.
  2. Find existing tooling: rg -n "axe|pa11y|toHaveNoViolations|checkA11y|AxeBuilder" -g '!node_modules'.
  3. Identify the surfaces: component tests (Jest/Vitest + Testing Library), E2E (Playwright, Cypress), Storybook (@storybook/addon-a11y), or a list of public URLs.
  4. 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).
  5. 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

SurfaceToolCatchesMisses
Unit/component (jsdom)jest-axe or vitest-axeMissing names, bad ARIA, label associationsContrast (no layout in jsdom), focus, real CSS
Real browser, app flows@axe-core/playwrightContrast, hidden content, states after interactionAnything needing human judgement
Cypress suitescypress-axeSame as browserSame
Storybookaddon-a11y + test runnerEvery story statePage-level landmarks, flows
Static URL list / crawlpa11y-ci or @axe-core/cliQuick site sweepStates 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

SituationGate
New suite on a clean appFail on any violation
Legacy app with many existing violationsFail 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 widgetsExclude 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

  1. Read the rule id and helpUrl in the axe result, then the failing target selector.
  2. Reproduce in the browser; confirm it is real (axe false positives are rare but happen with custom shadow DOM and off-screen content).
  3. Fix at the shared component when possible, not per page.
  4. Add or keep a test that scans the state where it occurred.
Common ruleUsual fix
button-name, link-nameVisible text or aria-label on icon buttons
label<label for> or wrap the input; placeholder is not a label
color-contrastAdjust tokens in the design system, not one-off overrides
aria-* attribute rulesRemove ARIA that native elements already provide
region, landmark-*Wrap content in main, nav, header, footer
nested-interactiveDo 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 role and aria-* 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.yamlgenerated from this page

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.md77 lines, 4.7 KB
# 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.

esc