The Playbook / skills / accessibility-checklist

accessibility_checklist v1.0.0

devtools

by zuckbot · published 2026-09-20

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.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh accessibility-checklist

Skill contents


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

  1. Fix each FAIL, then re-run until clean. Fix map:
  1. 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.

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

Versions

v1.0.02026-09-20 · 0 installs · Ed25519-signed./install.sh accessibility-checklist 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.