---
name: accessibility-audit
description: Run a 30-point WCAG 2.2 AA accessibility audit on a website URL and report pass/fail/manual per check with fixes. Use when asked to audit, check, or test a site's accessibility or a11y.
---

# 30-point accessibility audit (WCAG 2.2 AA)

Audit a live site against the 30-point checklist below and produce a report the site owner can act on. Tools catch roughly 30% of issues, so every report must separate what was **verified automatically**, what was **checked by inspection**, and what **still needs a human** (keyboard, zoom, screen reader).

## Inputs

- A URL (required). Audit the home page plus up to 5 representative inner pages found from its navigation (for example a case study, a contact page, a CV page). Ask only if no URL was given.
- Optional scope: specific pages, or "home only".

## Step 1: Get a real browser on the page

Preferred: Playwright + axe-core in a headless Chromium.

```bash
mkdir -p a11y && cd a11y && npm init -y >/dev/null && npm i axe-core@4 playwright-core
# Chromium: use the system one (e.g. /opt/pw-browsers/chromium) via CHROMIUM=... ; do not run `playwright install` if a browser is already present
node audit.js https://example.com/ https://example.com/about/ > results.json
```

Write `audit.js` from the script at the end of this file. It returns, per page: axe-core violations (wcag2a/aa, 21a/aa, 22aa tags), lang/title/headings/landmarks, skip link, images and alt, empty/generic/duplicate link text, media and captions, live regions, ARIA usage, redundant roles, clickable divs, positive tabindex, form fields and labels, animations and `prefers-reduced-motion`, focus CSS and `outline:none` rules, targets under 24x24, a scripted Tab walk (order, focus indicator, obscured focus, traps), reflow at 320px, portrait and landscape, text-spacing override, and 200% text resize. It also saves screenshots at 320px and after tabbing.

If the host is blocked by a network policy, say so plainly, name the host, and tell the person how to allow it. Then fall back to a web-fetch read of each page for structure (title, headings, links, alt text, labels) and mark every check that needs a rendered page as **Not tested**. Never invent results.

If a browser extension or desktop browser is available instead, you may run the same checks there (run axe via the console, or tab through by hand).

## Step 2: Score each of the 30 checks

Status values: **Pass**, **Fail**, **Partial** (some pages or elements fail), **Manual** (needs a human, give exact steps), **N/A** (feature absent, say why), **Not tested** (tool could not reach it).

### Perceivable
| # | Check | WCAG SC | How to verify |
|---|---|---|---|
| 1 | Meaningful images have alt text | 1.1.1 | axe `image-alt`, `svg-img-alt`, `role-img-alt`. Review every alt by eye: meaningful images need a description, decorative ones need `alt=""` or `aria-hidden`. Icon-only links/buttons need a name. |
| 2 | Text contrast 4.5:1 minimum (3:1 large text) | 1.4.3 | axe `color-contrast`. Check `incomplete` results (gradients, images behind text) manually. Check hover/visited states too. |
| 3 | UI & focus contrast 3:1 | 1.4.11 | Focus indicator color vs adjacent background, input borders, icon buttons. Use the focus screenshot and computed outline colors from the Tab walk. |
| 4 | Color is never the only cue | 1.4.1 | axe `link-in-text-block`. Inline links need underline or a non-color cue; errors/states need text or icons. |
| 5 | Text resizes to 200% | 1.4.4 | `textResize200`: no clipping, no horizontal scroll. Fonts in `px` that don't scale are a flag. Confirm with browser zoom 200%. |
| 6 | Content reflows at 320px | 1.4.10 | `reflow320.scrollWidth` must equal `clientWidth`; list overflowing elements. Review the 320px screenshot. |
| 7 | Captions & transcripts | 1.2.1–1.2.5 | Any `video`/`audio`/embedded player needs captions (`track kind=captions`) and a transcript. N/A if no media. |
| 8 | Text spacing is adjustable | 1.4.12 | `textSpacingClipped` must be empty after the override styles. |
| 9 | Portrait & landscape work | 1.3.4 | No orientation lock; `portrait` and `landscape` have no horizontal overflow. |
| 10 | No images of text | 1.4.5 | Review images: headings, quotes, or logos-with-taglines rendered as images (logos are exempt). |

### Operable
| # | Check | WCAG SC | How to verify |
|---|---|---|---|
| 11 | Fully keyboard operable | 2.1.1 | Tab walk reaches every link, button and field; no `clickableDivs`; menus, modals, carousels work with Enter/Space/Esc/arrows (manual). |
| 12 | No keyboard traps | 2.1.2 | `keyboardTrap` is absent and the walk wraps. Manually test any modal, embed or iframe. |
| 13 | Logical tab order | 2.4.3 | `tabOrder` follows visual order; `tabOrderJumpsUp` and `positiveTabindex` are empty. Hidden off-canvas menus must not receive focus while closed. |
| 14 | Focus always visible | 2.4.7, 2.4.11 | `noFocusIndicator` empty; `outlineNone` rules have a replacement `:focus-visible` style; `obscured` (sticky header covering focus) empty. |
| 15 | Skip to main content link | 2.4.1 | `skipLink` exists, is the first focusable, its target exists, and it becomes visible on focus (check the screenshot or by hand). Landmarks (`main`, `nav`) also help. |
| 16 | Targets 24x24px minimum | 2.5.8 | axe `target-size` plus `smallTargets` (inline links in sentences are exempt; small targets with 24px spacing pass). |
| 17 | Time limits adjustable | 2.2.1 | `metaRefresh` false; no session timeouts or auto-advancing content without controls. Usually N/A for static sites. |
| 18 | Moving content can pause | 2.2.2, 2.3.3 | Infinite animations, carousels, autoplay video, typing effects longer than 5s need a pause control or must honor `prefers-reduced-motion` (`reducedMotionCss`). |
| 19 | Page titles & headings | 2.4.2, 2.4.6, 1.3.1 | Unique descriptive `<title>` per page; one `h1`; no skipped levels (axe `heading-order`); visual headings marked up as headings. |
| 20 | Meaningful link text | 2.4.4 | `emptyLinks`, `genericLinks` empty; `dupLinkText` pointing to different URLs flagged; new-tab links say so. |

### Understandable
| # | Check | WCAG SC | How to verify |
|---|---|---|---|
| 21 | Page lang attribute set | 3.1.1, 3.1.2 | `lang` present and valid on every page (axe `html-has-lang`, `html-lang-valid`). |
| 22 | Programmatic form labels | 1.3.1, 3.3.2, 4.1.2 | Every field in `formFields` has a `label`; placeholder alone fails; `autocomplete` on name/email (1.3.5). |
| 23 | Clear, helpful errors | 3.3.1, 3.3.3 | Submit the form empty and with a bad email (do not send real data): errors are in text, tied to fields (`aria-describedby`), and announced. Manual. |
| 24 | Consistent navigation | 3.2.3, 3.2.4, 3.2.6 | Compare nav order and labels across audited pages; help/contact in the same place. |
| 25 | No surprise context changes | 3.2.1, 3.2.2 | `finalUrl` unchanged after the Tab walk; no auto-submit on select; new windows announced. |

### Robust
| # | Check | WCAG SC | How to verify |
|---|---|---|---|
| 26 | Valid, semantic HTML | 1.3.1 | Landmarks present, lists marked up as lists, buttons are `<button>`, axe `list`, `listitem`, `region`, `landmark-*`. Optional: W3C validator. |
| 27 | ARIA only when needed | 4.1.2 | `redundantRoles` empty; ARIA in `ariaUse` is justified; axe `aria-*` rules pass. |
| 28 | Name, role, state exposed | 4.1.2 | axe `button-name`, `link-name`, `aria-allowed-attr`; toggles expose `aria-expanded`/`aria-pressed`; current page uses `aria-current`. |
| 29 | Status via live regions | 4.1.3 | Form success/error, async results, "copied" toasts use `role=status`/`aria-live`. Flag if the form has no live region. |
| 30 | Tested with NVDA, VoiceOver, TalkBack | — | Always **Manual**. Give a short script: read the page by headings (H key / rotor), list links, tab through, fill and submit the form, and note anything announced wrongly. |

### Follow-ups the script can't settle on its own

- **Contrast `incomplete` (gradients, images, overlapping elements):** for each node, set its text to `transparent`, screenshot the element, and compare the text color with the rendered background pixels (use the median, not the worst pixel). Low outliers usually come from color swatches, borders or a sticky bar overlapping the element. Look at a screenshot before you call one a Fail.
- **Obscured focus (2.4.11):** an `obscured` hit fails only if the focused element is *entirely* hidden. Focus each control and compare its rect with the sticky header or footer rect. The usual fix is `html { scroll-padding-top/bottom: <bar height + 12px>; }`.
- **Text spacing and resize clipping:** hits on `.sr-only` / visually hidden spans (1×1px, `clip: rect(0 0 0 0)`) are intentional, not failures.
- **Hidden inputs that take focus** (a 1×1 terminal or search input) need a visible focus style on their visible container, such as `:focus-within`.
- **Reduced motion:** reload with `reducedMotion: 'reduce'` in the browser context and confirm no infinite animation is still `running`.
- **Earlier text-only findings:** re-check them in the DOM. A text read can't see `aria-label` or `aria-hidden`, so "unnamed link" and "missing alt" findings are often false alarms.
- **Form errors:** submit empty and with a bad email only. Never send a real message; mark the success announcement as Manual.

## Step 3: Report

Write the report as a document (a Claude Doc if available, otherwise Markdown) with:

1. **Summary**: score as `passes / automatable checks`, the count of fails, partials and manual checks, and the top 3 fixes by user impact.
2. **Results table**: #, check, status, evidence (element, page, measured value), fix (concrete code or CSS).
3. **Manual test script**: the checks marked Manual with exact steps, including screen reader commands.
4. **Method and limits**: tools, pages audited, date, and what could not be reached.

Keep evidence specific (selector, page, measured ratio or size). Prioritize fixes: Critical (blocks a task), Serious, Moderate, Minor. Never mark a check Pass without evidence for it.

## audit.js

```js
// Usage: node audit.js <url> [more urls...]  -> prints JSON findings per page
const { chromium } = require('playwright-core');
const fs = require('fs');
const axeSource = fs.readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
const exe = process.env.CHROMIUM || '/opt/pw-browsers/chromium';

async function auditPage(browser, url) {
  const out = { url };
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  const page = await ctx.newPage();
  await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 });

  // axe-core, WCAG 2.0/2.1/2.2 A + AA
  await page.addScriptTag({ content: axeSource });
  const axe = await page.evaluate(() => axe.run(document, {
    runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'] },
  }));
  out.axe = {
    violations: axe.violations.map(v => ({ id: v.id, impact: v.impact, help: v.help, tags: v.tags.filter(t => t.startsWith('wcag')), nodes: v.nodes.slice(0, 8).map(n => ({ target: n.target.join(' '), summary: n.failureSummary })), count: v.nodes.length })),
    incomplete: axe.incomplete.map(v => ({ id: v.id, help: v.help, count: v.nodes.length })),
  };

  // Static facts: lang, title, headings, landmarks, images, links, media, live regions, ARIA use, forms
  out.facts = await page.evaluate(() => {
    const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
    const name = el => (el.getAttribute('aria-label') || (el.getAttribute('aria-labelledby') || '').split(' ').map(id => document.getElementById(id)?.textContent || '').join(' ') || el.innerText || el.getAttribute('title') || [...el.querySelectorAll('img[alt],svg title')].map(i => i.alt || i.textContent).join(' ') || '').trim();
    const links = [...document.querySelectorAll('a[href]')];
    const generic = /^(click here|here|read more|more|learn more|link|details|view)$/i;
    return {
      lang: document.documentElement.getAttribute('lang'),
      title: document.title,
      metaViewport: document.querySelector('meta[name=viewport]')?.content || null,
      metaRefresh: !!document.querySelector('meta[http-equiv=refresh i]'),
      headings: [...document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role=heading]')].map(h => `${h.tagName}${h.getAttribute('aria-level') ? '(' + h.getAttribute('aria-level') + ')' : ''}: ${h.innerText.trim().slice(0, 80)}`),
      landmarks: [...document.querySelectorAll('header,nav,main,footer,aside,[role=banner],[role=navigation],[role=main],[role=contentinfo],[role=complementary],[role=search]')].map(e => e.tagName.toLowerCase() + (e.getAttribute('role') ? `[role=${e.getAttribute('role')}]` : '') + (e.getAttribute('aria-label') ? `"${e.getAttribute('aria-label')}"` : '')),
      skipLink: (() => { const a = links.slice(0, 5).find(a => /skip/i.test(a.textContent)); if (!a) return null; const id = a.getAttribute('href').replace(/^#/, ''); return { text: a.textContent.trim(), href: a.getAttribute('href'), targetExists: !!document.getElementById(id), isFirstFocusable: document.querySelector('a[href],button,input,select,textarea,[tabindex]') === a }; })(),
      images: [...document.querySelectorAll('img,svg,[role=img]')].filter(vis).map(i => ({ tag: i.tagName.toLowerCase(), src: (i.currentSrc || i.getAttribute('src') || '').split('/').pop(), alt: i.getAttribute('alt'), ariaHidden: i.closest('[aria-hidden=true]') ? true : false, role: i.getAttribute('role'), label: i.getAttribute('aria-label') })),
      emptyLinks: links.filter(a => vis(a) && !name(a)).map(a => a.outerHTML.slice(0, 200)),
      genericLinks: links.filter(a => generic.test(name(a))).map(a => name(a) + ' -> ' + a.getAttribute('href')),
      dupLinkText: (() => { const m = {}; links.filter(vis).forEach(a => { const n = name(a).toLowerCase(); (m[n] ||= new Set()).add(a.href); }); return Object.entries(m).filter(([n, s]) => n && s.size > 1).map(([n, s]) => `${n}: ${[...s].join(', ')}`); })(),
      newTabLinks: links.filter(a => a.target === '_blank').map(a => name(a)),
      media: [...document.querySelectorAll('video,audio,iframe')].map(m => ({ tag: m.tagName.toLowerCase(), src: m.src, autoplay: m.autoplay, tracks: m.querySelectorAll ? m.querySelectorAll('track').length : 0, controls: m.controls })),
      liveRegions: [...document.querySelectorAll('[aria-live],[role=status],[role=alert],[role=log],output')].map(e => e.outerHTML.slice(0, 160)),
      ariaUse: [...document.querySelectorAll('*')].flatMap(e => [...e.attributes].filter(a => a.name === 'role' || a.name.startsWith('aria-')).map(a => `${e.tagName.toLowerCase()}[${a.name}=${a.value}]`)).slice(0, 80),
      redundantRoles: [...document.querySelectorAll('button[role=button],a[href][role=link],nav[role=navigation],main[role=main],header[role=banner],footer[role=contentinfo],ul[role=list],li[role=listitem]')].map(e => e.outerHTML.slice(0, 120)),
      clickableDivs: [...document.querySelectorAll('div[onclick],span[onclick],[role=button]:not(button)')].map(e => e.outerHTML.slice(0, 160)),
      positiveTabindex: [...document.querySelectorAll('[tabindex]')].filter(e => +e.getAttribute('tabindex') > 0).map(e => e.outerHTML.slice(0, 120)),
      formFields: [...document.querySelectorAll('input:not([type=hidden]),select,textarea')].map(f => ({ type: f.type, id: f.id, name: f.name, label: (f.labels && f.labels[0]?.innerText.trim()) || f.getAttribute('aria-label') || null, required: f.required, autocomplete: f.getAttribute('autocomplete'), describedBy: f.getAttribute('aria-describedby') })),
      forms: [...document.querySelectorAll('form')].map(f => ({ action: f.getAttribute('action'), novalidate: f.noValidate })),
      animations: document.getAnimations().map(a => ({ name: a.animationName || a.constructor.name, iterations: a.effect?.getTiming().iterations, duration: a.effect?.getTiming().duration })).slice(0, 20),
      reducedMotionCss: [...document.styleSheets].some(s => { try { return [...s.cssRules].some(r => r.media && /prefers-reduced-motion/.test(r.media.mediaText)); } catch { return null; } }),
      focusStylesInCss: [...document.styleSheets].map(s => { try { return [...s.cssRules].map(r => r.cssText).filter(t => /:focus/.test(t)).slice(0, 15); } catch { return []; } }).flat(),
      outlineNone: [...document.styleSheets].map(s => { try { return [...s.cssRules].map(r => r.cssText).filter(t => /outline:\s*(none|0)/.test(t)); } catch { return []; } }).flat().slice(0, 15),
      textImages: [...document.querySelectorAll('img')].filter(i => /text|banner|quote|headline/i.test(i.alt || '')).map(i => i.alt),
    };
  });

  // Target size (2.5.8): interactive elements < 24x24 CSS px (inline links in sentences are exempt)
  out.smallTargets = await page.evaluate(() => [...document.querySelectorAll('a[href],button,input,select,textarea,[role=button],[tabindex="0"]')].filter(e => { const r = e.getBoundingClientRect(); if (!r.width || !r.height) return false; const inline = e.tagName === 'A' && getComputedStyle(e).display === 'inline' && e.closest('p,li') && e.closest('p,li').innerText.length > e.innerText.length + 20; return !inline && (r.width < 24 || r.height < 24); }).map(e => { const r = e.getBoundingClientRect(); return `${Math.round(r.width)}x${Math.round(r.height)} ${e.outerHTML.slice(0, 120)}`; }));

  // Keyboard walk (2.1.1, 2.1.2, 2.4.3, 2.4.7, 2.4.11): tab through, record order, focus indicator and traps
  await page.evaluate(() => window.scrollTo(0, 0));
  await page.mouse.click(1, 1).catch(() => {});
  const seen = []; let repeats = 0; let last = null;
  for (let i = 0; i < 120; i++) {
    await page.keyboard.press('Tab');
    const info = await page.evaluate(() => {
      const el = document.activeElement; if (!el || el === document.body) return null;
      const s = getComputedStyle(el); const r = el.getBoundingClientRect();
      const label = (el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('title') || el.tagName).trim().slice(0, 50);
      const indicator = (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) ? `outline ${s.outlineWidth} ${s.outlineStyle} ${s.outlineColor}` : (s.boxShadow !== 'none' ? `box-shadow ${s.boxShadow}` : null);
      const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
      const top = (cx >= 0 && cy >= 0 && cx < innerWidth && cy < innerHeight) ? document.elementFromPoint(cx, cy) : null;
      const obscured = top && !el.contains(top) && !top.contains(el) ? top.tagName.toLowerCase() + '.' + top.className : null;
      return { key: el.tagName + '|' + label + '|' + Math.round(r.top + scrollY), tag: el.tagName.toLowerCase(), label, indicator, y: Math.round(r.top + scrollY), x: Math.round(r.left), obscured };
    });
    if (!info) { if (seen.length) break; else continue; }
    if (last && info.key === last) { repeats++; if (repeats > 3) { out.keyboardTrap = info; break; } } else repeats = 0;
    if (seen.length && info.key === seen[0].key) break; // wrapped around
    last = info.key; seen.push(info);
  }
  out.tabOrder = seen.map(s => ({ tag: s.tag, label: s.label, y: s.y, indicator: s.indicator, obscured: s.obscured }));
  out.noFocusIndicator = seen.filter(s => !s.indicator).map(s => `${s.tag}: ${s.label}`);
  out.tabOrderJumpsUp = seen.filter((s, i) => i && s.y < seen[i - 1].y - 200).map(s => `${s.tag}: ${s.label} (y=${s.y})`);
  await page.screenshot({ path: `shot-focus-${new URL(url).pathname.replace(/\W+/g, '_') || 'home'}.png` }).catch(() => {});

  // Reflow at 320px (1.4.10) and portrait/landscape (1.3.4)
  for (const [w, h, tag] of [[320, 640, 'reflow320'], [640, 360, 'landscape'], [360, 740, 'portrait']]) {
    await page.setViewportSize({ width: w, height: h }); await page.waitForTimeout(400);
    out[tag] = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, overflowing: [...document.querySelectorAll('body *')].filter(e => { const r = e.getBoundingClientRect(); return r.right > innerWidth + 1 && r.width > 0 && getComputedStyle(e).position !== 'fixed' && !e.closest('pre,code,table,[style*="overflow"]'); }).slice(0, 8).map(e => e.tagName.toLowerCase() + (e.className && typeof e.className === 'string' ? '.' + e.className.split(' ')[0] : '')) }));
  }
  await page.setViewportSize({ width: 320, height: 640 });
  await page.screenshot({ path: `shot-320-${new URL(url).pathname.replace(/\W+/g, '_') || 'home'}.png`, fullPage: true }).catch(() => {});

  // Text spacing (1.4.12): apply WCAG bookmarklet values, look for clipped text
  await page.setViewportSize({ width: 1280, height: 900 });
  await page.addStyleTag({ content: '*{line-height:1.5!important;letter-spacing:.12em!important;word-spacing:.16em!important}p{margin-bottom:2em!important}' });
  await page.waitForTimeout(300);
  out.textSpacingClipped = await page.evaluate(() => [...document.querySelectorAll('body *')].filter(e => { const s = getComputedStyle(e); return e.children.length === 0 && e.innerText?.trim() && (s.overflow === 'hidden' || s.overflowY === 'hidden' || s.textOverflow === 'ellipsis') && (e.scrollHeight > e.clientHeight + 2 || e.scrollWidth > e.clientWidth + 2); }).slice(0, 10).map(e => e.tagName.toLowerCase() + ': ' + e.innerText.trim().slice(0, 50)));

  // 200% text resize (1.4.4): zoom text only via root font-size, check clipping / overlap
  await page.reload({ waitUntil: 'networkidle' });
  await page.addStyleTag({ content: 'html{font-size:200%!important}' });
  await page.waitForTimeout(300);
  out.textResize200 = await page.evaluate(() => ({ hScroll: document.documentElement.scrollWidth > innerWidth + 1, clipped: [...document.querySelectorAll('body *')].filter(e => { const s = getComputedStyle(e); return e.children.length === 0 && e.innerText?.trim() && (s.overflow === 'hidden') && (e.scrollHeight > e.clientHeight + 2 || e.scrollWidth > e.clientWidth + 2); }).slice(0, 10).map(e => e.tagName.toLowerCase() + ': ' + e.innerText.trim().slice(0, 50)), pxFontSizes: [...new Set([...document.querySelectorAll('p,li,a,span,h1,h2,h3')].map(e => getComputedStyle(e).fontSize))].slice(0, 12) }));

  // Context changes on focus (3.2.1): did tabbing ever change the URL?
  out.finalUrl = page.url();
  await ctx.close();
  return out;
}

(async () => {
  const urls = process.argv.slice(2);
  const browser = await chromium.launch({ executablePath: exe });
  const results = [];
  for (const u of urls) { try { results.push(await auditPage(browser, u)); } catch (e) { results.push({ url: u, error: String(e) }); } }
  await browser.close();
  console.log(JSON.stringify(results, null, 2));
})();
```
