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.
./install.sh accessibility-checklistname: "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 }
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.
:focus rules are visible to the parser).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).
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).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.
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()
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.
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."
bin/check_a11y.py; thresholds are 4.5:1 / 3:1. Do not redefine them.alt="" and role="presentation" or aria-hidden="true" — empty alt alone is not enough for the checker.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.