---
name: "accessibility_checklist"
description: "Run machine-verifiable accessibility checks on agent-generated HTML: missing alt text, label/aria associations, heading order, tabindex, lang/title, focus styles, plus a computable WCAG contrast-ratio check. Trigger after generating or editing any HTML page, email, widget, or artifact. Reports agent-verifiable findings and flags what still needs a human; never claims legal WCAG conformance."
metadata: { "includeInPrompt": true }
---

# Accessibility Checklist

## Purpose
A fast, executable sweep an agent runs against HTML it just generated — before shipping. Every check below is computable from static markup (no browser, no human judgment required to *find* the problem). The sweep is grounded in WCAG 2.2 AA criterion names, but it **raises the bar; it does not certify conformance**. Real conformance claims require human testing — say so whenever asked.

## Workflow
1. **Save the page.** Write the final HTML to a file (inline any critical CSS so `:focus` rules are visible to the parser).
2. **Save the checker, then run it.** The checker below is stdlib-only (no dependencies) and part of this skill — save it as `check_a11y.py` in the skill's working directory, then run:
   ```bash
   python3 check_a11y.py page.html
   ```
   Exit 0 = all checks pass; exit 1 with one `FAIL [code] line N: message` per issue (plus a summary line).
3. **Fix each FAIL**, then re-run until clean. Fix map:
   - `missing-alt` → add a real `alt`; if decorative, `alt=""` **plus** `role="presentation"` or `aria-hidden="true"`.
   - `unlabeled-control` → give the control `aria-label`/`aria-labelledby`, or an `id`…
   - `label-not-linked` → …and a `<label for="that-id">` pointing at it.
   - `button-no-name`, `iframe-no-name`, `empty-anchor` → add text content / `title` / `href` (actions are `<button>`, not `<a>`).
   - `heading-order`, `multiple-h1` → one `h1`, no skipped levels.
   - `positive-tabindex` → remove `tabindex` or set `0`; never use positive values.
   - `missing-lang` → `<html lang="en">` (or the page's actual language); `missing-title` → descriptive `<title>`.
   - `no-focus-styles` → add a visible `:focus-visible` rule (e.g. a 2px outline).
4. **Contrast check** (needs color pairs, not markup — run separately):
   ```bash
   python3 -c \
     "from check_a11y import contrast_ratio; print(round(contrast_ratio('#777777','#ffffff'),2))"
   ```
   Thresholds: **4.5:1** for body text, **3:1** for large text (≥18pt / ≥14pt bold). A declared pair scoring below fails — pick darker/lighter values.
5. **Report** per the Output Contract below.

### The checker (save as `check_a11y.py`)

```python
#!/usr/bin/env python3
"""Machine-verifiable accessibility checks for agent-generated HTML.
Usage: python3 check_a11y.py page.html
Stdlib only (html.parser)."""
import sys
from html.parser import HTMLParser

VOID_INPUT_TYPES = {"hidden", "submit", "button", "image", "reset"}

class A11yParser(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.issues = []
        self.labels_for = set()
        self.control_ids = {}
        self.headings = []
        self.html_lang = None
        self.in_style = False
        self.title_text = ""
        self.style_css = ""

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        line = self.getpos()[0]
        if tag == "html":
            self.html_lang = a.get("lang")
        if tag == "style":
            self.in_style = True
        if tag == "title":
            self._in_title = True
        if tag == "img":
            alt = a.get("alt")
            if alt is None:
                self.issues.append(("missing-alt", line,
                    f"<img> without alt (src={a.get('src','?')!r})"))
            elif alt.strip() == "" and not (a.get("role") == "presentation"
                                            or a.get("aria-hidden") == "true"):
                self.issues.append(("decorative-alt-no-role", line,
                    "<img> with empty alt not marked decorative"))
        if tag in ("input", "select", "textarea"):
            t = a.get("type", "text").lower() if tag == "input" else ""
            if tag != "input" or t not in VOID_INPUT_TYPES:
                if not (a.get("id") or a.get("aria-label")
                        or a.get("aria-labelledby")):
                    self.issues.append(("unlabeled-control", line,
                        f"<{tag}> with no id, aria-label, or aria-labelledby"))
                if a.get("id"):
                    self.control_ids[a["id"]] = (tag, line)
        if tag == "button":
            if not (a.get("aria-label") or a.get("aria-labelledby")):
                self._button_open = line
                self._button_text = ""
            else:
                self._button_open = None
        if tag == "label" and a.get("for"):
            self.labels_for.add(a["for"])
        if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
            self.headings.append((int(tag[1]), line))
        if a.get("tabindex") is not None:
            try:
                if int(a["tabindex"]) > 0:
                    self.issues.append(("positive-tabindex", line,
                        f"<{tag}> tabindex={a['tabindex']} (>0 disrupts focus order)"))
            except ValueError:
                pass
        if tag == "a" and a.get("href") is None and a.get("id") is None \
                and a.get("name") is None and not a.get("role"):
            self.issues.append(("empty-anchor", line, "<a> with no href (use <button> for actions)"))
        if tag == "iframe":
            if not (a.get("title") or a.get("aria-label")
                    or a.get("aria-labelledby")):
                self.issues.append(("iframe-no-name", line,
                    "<iframe> without title or accessible name"))

    def handle_startendtag(self, tag, attrs):
        self.handle_starttag(tag, attrs)

    def handle_data(self, data):
        if getattr(self, "_in_title", False):
            self.title_text += data
        if self.in_style:
            self.style_css += data
        if getattr(self, "_button_open", None) is not None:
            self._button_text += data

    def handle_endtag(self, tag):
        if tag == "style":
            self.in_style = False
        if tag == "title":
            self._in_title = False
        if tag == "button" and getattr(self, "_button_open", None) is not None:
            if not self._button_text.strip():
                self.issues.append(("button-no-name", self._button_open,
                    "<button> with no accessible name"))
            self._button_open = None

    def finalize(self):
        for cid, (tag, line) in self.control_ids.items():
            if cid not in self.labels_for:
                self.issues.append(("label-not-linked", line,
                    f"<{tag} id={cid!r}> has no <label for> referencing it"))
        if self.html_lang is None:
            self.issues.append(("missing-lang", 1, "<html> missing lang attribute"))
        if not self.title_text.strip():
            self.issues.append(("missing-title", 1, "empty or missing <title>"))
        if not self.headings:
            self.issues.append(("no-headings", 1, "no headings found"))
        else:
            levels = [h[0] for h in self.headings]
            if levels[0] != 1:
                self.issues.append(("heading-order", self.headings[0][1],
                    f"first heading is h{levels[0]}, not h1"))
            for (prev_lvl, _), (lvl, line) in zip(self.headings, self.headings[1:]):
                if lvl - prev_lvl > 1:
                    self.issues.append(("heading-order", line,
                        f"heading level skips h{prev_lvl} -> h{lvl}"))
            if levels.count(1) > 1:
                self.issues.append(("multiple-h1", self.headings[0][1],
                    f"{levels.count(1)} <h1> elements found"))
        if "focus" not in self.style_css.lower():
            self.issues.append(("no-focus-styles", 1,
                "no :focus/:focus-visible CSS found — focus indicator not visible"))
        return self.issues


def contrast_ratio(fg, bg):
    """WCAG relative-luminance contrast ratio for two hex colors (#rrggbb)."""
    def lum(h):
        h = h.lstrip("#")
        rgb = [int(h[i:i+2], 16) / 255 for i in (0, 2, 4)]
        lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
               for c in rgb]
        return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
    l1, l2 = lum(fg), lum(bg)
    return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)


def main():
    path = sys.argv[1]
    html = open(path, encoding="utf-8", errors="replace").read()
    p = A11yParser()
    p.feed(html)
    issues = p.finalize()
    for code, line, msg in issues:
        print(f"FAIL [{code}] line {line}: {msg}")
    print(f"\n{len(issues)} issue(s) in {path}")
    sys.exit(1 if issues else 0)


if __name__ == "__main__":
    main()
```

## Checks vs. criteria (verified list only)
Only cite these — do not invent other criterion numbers.

| Check | Criterion | Who verifies |
|---|---|---|
| `missing-alt`, `decorative-alt-no-role` | 1.1.1 Non-text Content | Agent finds missing/markup issues; **human** judges whether alt text actually describes the image |
| `unlabeled-control`, `label-not-linked`, `button-no-name` | 1.3.1 Info and Relationships; 4.1.2 Name, Role, Value | Agent-verifiable |
| `heading-order`, `multiple-h1`, `no-headings` | 1.3.1 Info and Relationships | Agent-verifiable |
| Contrast ratio | 1.4.3 Contrast Minimum | Agent-verifiable for declared color pairs; **human** for text over images/gradients/video |
| `positive-tabindex` | 2.4.3 Focus Order | Agent-verifiable |
| `no-focus-styles` | 2.4.7 Focus Visible | Partial — CSS presence is evidence, not proof the indicator is actually visible; **human** confirms visually |
| `missing-lang` | 3.1.1 Language of Page | Agent-verifiable |
| `missing-title` | 2.4.2 Page Titled | Agent-verifiable |

Explicitly out of reach for a static sweep — flag as "needs a human": keyboard traps beyond positive tabindex (focus loops, modal focus containment), screen-reader reading order, motion/animation sensitivity, and whether alt text / link text are *meaningful* rather than merely present.

## Output Contract
Report findings as:
```
PASS/FAIL per check: <code> line <N> — <what's wrong>
Fixed: <what you changed>
Needs a human: <residual items from the table above>
```
Never write "WCAG compliant" or "passes WCAG 2.2 AA". The correct claim is: "**N machine checks pass; M items still need human review.**"

## Operating Rules
1. Run the sweep on every HTML artifact before shipping it. Zero FAILs is the ship gate, not a conformance certificate.
2. Use only the criterion numbers listed above. If a fix touches an unlisted criterion, describe it in plain words — do not guess a number.
3. Keep the checker stdlib-only so it runs anywhere; do not add dependencies.
4. Contrast uses the WCAG relative-luminance formula already in `bin/check_a11y.py`; thresholds are 4.5:1 / 3:1. Do not redefine them.
5. Decorative images need **both** `alt=""` and `role="presentation"` or `aria-hidden="true"` — empty alt alone is not enough for the checker.
