
A11y Audit
- 62 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
a11y-audit is a Claude skill that scans HTML files for WCAG 2.1 violations and checks color contrast against AA/AAA thresholds.
About
a11y-audit is a Claude skill that scans HTML files for WCAG 2.1 accessibility violations and checks color contrast against AA/AAA standards. A developer runs its two Python scripts to catch missing alt text, broken heading hierarchies, unlabeled form inputs, and low-contrast colors before release. It supports directory scans, CSS parsing, and JSON output so it can gate a CI pipeline on Level A findings.
- Scans HTML files for WCAG 2.1 violations (alt text, heading order, form labels, ARIA, link text)
- Checks color contrast against WCAG AA (4.5:1) and AAA (7:1) thresholds
- JSON output and Level-A strict mode for CI gating
A11y Audit by the numbers
- 62 all-time installs (skills.sh)
- Ranked #1,157 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
a11y-audit capabilities & compatibility
Free; runs local Python scripts with no API keys required.
- Capabilities
- seo audit · contrast checker · wcag scanner
- Use cases
- testing · code review
- Pricing
- Free
What a11y-audit says it does
automated scanning of HTML files for WCAG 2.1 compliance violations and color contrast checking against AA/AAA standards
It catches missing alt text, broken heading hierarchies, unlabeled form inputs, and insufficient color contrast early in development.
Scans HTML files for WCAG 2.1 violations including structural, semantic, and interactive element issues.
npx skills add https://github.com/borghei/claude-skills --skill a11y-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 62 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Audit web page HTML and CSS for WCAG 2.1 accessibility and color-contrast violations before shipping.
Who is it for?
Developers who need automated WCAG scanning of HTML templates and stylesheets in CI or PR review.
When should I use this skill?
When you need to check accessibility, audit WCAG compliance, scan HTML for a11y issues, or check color contrast.
What you get
A prioritized list of WCAG violations by conformance level plus contrast ratios, ready to remediate and re-scan.
- WCAG violation report
- color contrast ratio report
- JSON output for CI
By the numbers
- scans 8 categories of WCAG checks (alt text, heading hierarchy, form labels, ARIA, link text, lang, tab order, landmarks
- 3 severity levels mapped to WCAG A/AA/AAA
Files
Accessibility Audit
Category: Engineering
Domain: Web Accessibility
Overview
The Accessibility Audit skill provides automated scanning of HTML files for WCAG 2.1 compliance violations and color contrast checking against AA/AAA standards. It catches missing alt text, broken heading hierarchies, unlabeled form inputs, and insufficient color contrast early in development.
Quick Start
# Scan HTML for WCAG violations
python scripts/a11y_scanner.py --file index.html
# Scan a directory of HTML files
python scripts/a11y_scanner.py --dir ./src/templates
# Check color contrast
python scripts/contrast_checker.py --foreground "#333333" --background "#ffffff"
# Parse CSS file for contrast issues
python scripts/contrast_checker.py --css styles.css
# JSON output for CI
python scripts/a11y_scanner.py --file index.html --format jsonTools Overview
a11y_scanner.py
Scans HTML files for WCAG 2.1 violations including structural, semantic, and interactive element issues.
| Feature | Description |
|---|---|
| Image alt text | Detects missing or empty alt on non-decorative images |
| Heading hierarchy | Validates h1-h6 levels are sequential |
| Form labels | Ensures inputs have associated label elements |
| ARIA attributes | Checks ARIA usage correctness |
| Link text | Flags generic text like "click here" or "read more" |
| Language attribute | Checks for lang on html element |
| Tab order | Detects positive tabindex values |
| Landmarks | Validates semantic landmark usage |
contrast_checker.py
Checks color contrast ratios against WCAG AA and AAA thresholds.
| Feature | Description |
|---|---|
| Ratio calculation | Computes relative luminance contrast ratio |
| AA compliance | 4.5:1 normal text, 3:1 large text |
| AAA compliance | 7:1 normal text, 4.5:1 large text |
| CSS parsing | Extracts color/background pairs from CSS |
| Color suggestions | Recommends nearest compliant color |
Workflows
Full Accessibility Audit
1. Scan HTML - Run a11y_scanner.py on all templates 2. Check contrast - Run contrast_checker.py on stylesheets 3. Triage - Prioritize Level A violations first 4. Remediate - Fix critical issues (alt text, form labels, headings) 5. Re-scan - Verify fixes pass all checks
CI Integration
# Gate on Level A violations
python scripts/a11y_scanner.py --dir ./templates --format json --level A --strict
# Check CSS contrast
python scripts/contrast_checker.py --css ./static/css/main.css --format jsonDevelopment Workflow
1. Pre-commit - Quick scan of changed HTML files 2. PR review - Full scan as part of review checklist 3. Staging audit - Comprehensive scan before release 4. Monitoring - Regular scheduled audits
Reference Documentation
- WCAG Guidelines - Conformance levels, success criteria, common fixes
Common Patterns Quick Reference
WCAG Levels
| Level | Description | Typical Requirement |
|---|---|---|
| A | Minimum baseline | Legal compliance |
| AA | Industry standard | Most regulations, ADA |
| AAA | Enhanced | Best practice goal |
Contrast Ratios
| Context | AA | AAA |
|---|---|---|
| Normal text (<18pt) | 4.5:1 | 7:1 |
| Large text (>=18pt bold or >=14pt) | 3:1 | 4.5:1 |
| UI components | 3:1 | 3:1 |
Quick Fixes
| Issue | Fix |
|---|---|
| Missing alt text | <img alt="Description of image"> |
| Skipped heading | Use sequential h1 through h6 |
| No form label | <label for="inputId">Label</label> |
| Generic link text | Replace "click here" with descriptive text |
| Missing lang | <html lang="en"> |
| Positive tabindex | Use tabindex="0" or tabindex="-1" only |
Severity Mapping
- CRITICAL - WCAG Level A violations
- WARNING - WCAG Level AA violations
- INFO - WCAG Level AAA recommendations
<!--
sample-page.html — Deliberately broken accessibility example
This HTML page contains common WCAG violations for the a11y-audit
scanner to detect. Violations include:
- Missing alt text on images
- Skipped heading levels (h1 -> h3 -> h5)
- Form inputs without labels
- Poor color contrast
- Missing lang attribute
- No skip navigation link
- Missing ARIA landmarks
- Auto-playing media
- Non-descriptive link text
- Missing focus indicators
-->
<!DOCTYPE html>
<html>
<head>
<title>Acme Corp — Dashboard</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
/* VIOLATION: removing focus outlines globally */
outline: none;
}
*:focus { outline: none; }
.header {
background: #1a1a2e;
color: #fff;
padding: 16px 24px;
}
/* VIOLATION: very low contrast — light gray on white */
.subtle-text {
color: #ccc;
background: #fff;
font-size: 12px;
}
/* VIOLATION: low contrast link color */
.content a {
color: #aab3ff;
background: #f0f0ff;
}
.card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin: 12px;
width: 300px;
display: inline-block;
vertical-align: top;
}
.btn-primary {
background: #3b82f6;
color: #fff;
border: none;
padding: 10px 20px;
cursor: pointer;
border-radius: 4px;
}
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }
</style>
</head>
<!-- VIOLATION: no lang attribute on <html> -->
<body>
<!-- VIOLATION: no skip-navigation link -->
<!-- VIOLATION: no <main>, <nav>, <header> landmark elements -->
<div class="header">
<!-- VIOLATION: image with empty alt attribute used decoratively but is informational -->
<img src="/logo.png" alt="">
<span style="font-size:24px; font-weight:bold;">Acme Corp Dashboard</span>
</div>
<div style="padding: 24px;">
<!-- VIOLATION: skips from h1 to h3 -->
<h1>Welcome Back, Sarah</h1>
<h3>Your Weekly Summary</h3>
<p class="subtle-text">Last updated: March 28, 2026 at 14:32 UTC</p>
<div class="card">
<!-- VIOLATION: image missing alt attribute entirely -->
<img src="/charts/revenue-q1.png" width="280" height="160">
<!-- VIOLATION: skips from h3 to h5 -->
<h5>Q1 Revenue</h5>
<p>Total revenue this quarter: $1.24M</p>
<!-- VIOLATION: non-descriptive link text -->
<a href="/reports/q1-revenue">Click here</a>
</div>
<div class="card">
<img src="/charts/users-growth.png" width="280" height="160">
<h5>User Growth</h5>
<p>Active users increased 23% month-over-month.</p>
<!-- VIOLATION: non-descriptive link text -->
<a href="/reports/user-growth">Read more</a>
</div>
<div class="card">
<!-- VIOLATION: decorative image should have alt="" but has no alt at all -->
<img src="/icons/alert-triangle.svg" width="32" height="32">
<h5>Open Incidents</h5>
<p class="subtle-text">3 incidents require your attention.</p>
<a href="/incidents">More info</a>
</div>
<!-- VIOLATION: form inputs without associated labels -->
<h3>Quick Search</h3>
<form action="/search" method="GET">
<input type="text" name="q" placeholder="Search reports...">
<input type="date" name="from">
<input type="date" name="to">
<select name="department">
<option value="">Select department</option>
<option value="eng">Engineering</option>
<option value="sales">Sales</option>
<option value="mktg">Marketing</option>
</select>
<button class="btn-primary" type="submit">Search</button>
</form>
<!-- VIOLATION: table without caption or summary, no <thead>/<th> scope -->
<h3>Recent Transactions</h3>
<table>
<tr>
<td><strong>Date</strong></td>
<td><strong>Description</strong></td>
<td><strong>Amount</strong></td>
<td><strong>Status</strong></td>
</tr>
<tr>
<td>2026-03-27</td>
<td>Enterprise license — Globex Corp</td>
<td>$48,000</td>
<!-- VIOLATION: color alone conveys meaning (green = paid) -->
<td style="color: green;">Paid</td>
</tr>
<tr>
<td>2026-03-25</td>
<td>Pro plan upgrade — Initech LLC</td>
<td>$2,400</td>
<td style="color: green;">Paid</td>
</tr>
<tr>
<td>2026-03-24</td>
<td>Starter plan — Wayne Enterprises</td>
<td>$600</td>
<!-- VIOLATION: color alone conveys meaning (red = overdue) -->
<td style="color: red;">Overdue</td>
</tr>
<tr>
<td>2026-03-22</td>
<td>Add-on: Analytics — Stark Industries</td>
<td>$1,200</td>
<td style="color: orange;">Pending</td>
</tr>
</table>
<!-- VIOLATION: auto-playing video without controls -->
<h3>Product Tour</h3>
<video src="/media/product-tour.mp4" autoplay muted width="640"></video>
<!-- VIOLATION: onClick on non-interactive element without keyboard support -->
<div onclick="togglePanel()" style="cursor:pointer; padding:12px; background:#f5f5f5; margin:12px 0;">
Click to expand advanced filters
</div>
<!-- VIOLATION: tabindex > 0 disrupts natural tab order -->
<a href="/settings" tabindex="5">Account Settings</a>
<a href="/billing" tabindex="3">Billing</a>
<a href="/support" tabindex="1">Support</a>
</div>
<script>
function togglePanel() {
// no-op placeholder
}
</script>
</body>
</html>
WCAG 2.1 Guidelines Reference
Conformance Levels
Level A (Minimum)
Must-fix issues. Failure means content is inaccessible to some users.
| Criterion | Title | Key Requirement |
|---|---|---|
| 1.1.1 | Non-text Content | All images have alt text |
| 1.2.1 | Audio-only / Video-only | Provide alternatives |
| 1.3.1 | Info and Relationships | Use semantic HTML (headings, lists, tables) |
| 1.3.2 | Meaningful Sequence | Reading order matches visual order |
| 1.3.3 | Sensory Characteristics | Don't rely on shape/color alone for instructions |
| 1.4.1 | Use of Color | Color is not the only means of conveying info |
| 1.4.2 | Audio Control | Auto-playing audio can be paused/stopped |
| 2.1.1 | Keyboard | All functionality keyboard accessible |
| 2.1.2 | No Keyboard Trap | Users can tab away from all components |
| 2.4.1 | Bypass Blocks | Provide skip navigation |
| 2.4.2 | Page Titled | Pages have descriptive titles |
| 2.4.3 | Focus Order | Focus order is logical |
| 2.4.4 | Link Purpose | Link text is descriptive |
| 3.1.1 | Language of Page | html lang attribute set |
| 3.3.1 | Error Identification | Errors are clearly described |
| 3.3.2 | Labels or Instructions | Form inputs have labels |
| 4.1.1 | Parsing | Valid HTML |
| 4.1.2 | Name, Role, Value | Custom widgets have proper ARIA |
Level AA (Standard)
Industry standard. Required by most accessibility regulations.
| Criterion | Title | Key Requirement |
|---|---|---|
| 1.4.3 | Contrast (Minimum) | 4.5:1 for normal text, 3:1 for large |
| 1.4.4 | Resize Text | Text resizable to 200% without loss |
| 1.4.5 | Images of Text | Use real text instead of images |
| 1.4.11 | Non-text Contrast | 3:1 for UI components |
| 2.4.5 | Multiple Ways | More than one way to reach pages |
| 2.4.6 | Headings and Labels | Descriptive headings and labels |
| 2.4.7 | Focus Visible | Keyboard focus indicator visible |
| 3.1.2 | Language of Parts | Identify language changes |
| 3.2.3 | Consistent Navigation | Navigation consistent across pages |
| 3.3.3 | Error Suggestion | Suggest corrections for errors |
Level AAA (Enhanced)
Best practice target. Not typically required by regulation.
| Criterion | Title | Key Requirement |
|---|---|---|
| 1.4.6 | Contrast (Enhanced) | 7:1 for normal text, 4.5:1 for large |
| 2.4.9 | Link Purpose (Link Only) | Link text alone is descriptive |
| 2.4.10 | Section Headings | Content organized with headings |
Contrast Requirements
Relative Luminance Formula
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
where R, G, B are linearized:
if sRGB <= 0.04045: linear = sRGB / 12.92
else: linear = ((sRGB + 0.055) / 1.055) ^ 2.4
Contrast Ratio = (L1 + 0.05) / (L2 + 0.05)
where L1 is lighter, L2 is darkerThresholds
| Context | AA | AAA |
|---|---|---|
| Normal text (< 18pt) | 4.5:1 | 7:1 |
| Large text (>= 18pt, or >= 14pt bold) | 3:1 | 4.5:1 |
| UI components and graphical objects | 3:1 | 3:1 |
Common Fixes
Images
<!-- Informative image -->
<img src="chart.png" alt="Sales increased 25% from Q1 to Q2">
<!-- Decorative image -->
<img src="divider.png" alt="" role="presentation">
<!-- Complex image -->
<img src="diagram.png" alt="Network architecture" aria-describedby="diagram-desc">
<div id="diagram-desc">Detailed description of the network architecture...</div>Forms
<!-- Explicit label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email">
<!-- Implicit label -->
<label>
Phone number
<input type="tel" name="phone">
</label>
<!-- ARIA label (when visual label not possible) -->
<input type="search" aria-label="Search products">Headings
<!-- Correct hierarchy -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
<!-- Wrong: skips h2 -->
<h1>Page Title</h1>
<h3>Subsection</h3> <!-- Should be h2 -->Navigation
<!-- Skip link -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- Landmarks -->
<header role="banner">...</header>
<nav aria-label="Main navigation">...</nav>
<main id="main-content">...</main>
<footer role="contentinfo">...</footer>Testing Checklist
1. Tab through entire page - all interactive elements reachable? 2. Can you complete all tasks with keyboard only? 3. Do all images have appropriate alt text? 4. Are heading levels sequential? 5. Do all form fields have labels? 6. Is color contrast sufficient? 7. Does the page work at 200% zoom? 8. Is the page title descriptive? 9. Do links make sense out of context? 10. Are error messages clear and helpful?
#!/usr/bin/env python3
"""
Accessibility Scanner - Scan HTML files for WCAG 2.1 violations.
Checks for missing alt text, heading hierarchy, form labels, ARIA usage,
link text quality, language attributes, and landmark regions.
Author: Claude Skills Engineering Team
License: MIT
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, asdict
from html.parser import HTMLParser
from pathlib import Path
from typing import List, Dict, Optional, Set, Tuple
@dataclass
class Finding:
"""An accessibility finding."""
severity: str # critical (A), warning (AA), info (AAA)
wcag_level: str # A, AA, AAA
criterion: str # e.g., "1.1.1"
file: str
line: int
element: str
message: str
recommendation: str
class A11yHTMLParser(HTMLParser):
"""HTML parser that collects accessibility-relevant information."""
GENERIC_LINK_TEXT = {
"click here", "here", "read more", "more", "link", "learn more",
"click", "this", "go", "start",
}
LANDMARK_ELEMENTS = {"header", "nav", "main", "aside", "footer", "section", "article"}
FORM_INPUT_TYPES = {"text", "email", "password", "search", "tel", "url", "number", "date", "file"}
def __init__(self, filename: str):
super().__init__()
self.filename = filename
self.findings: List[Finding] = []
self.heading_levels: List[Tuple[int, int]] = [] # (level, line)
self.has_lang = False
self.has_main = False
self.has_h1 = False
self.current_tag = ""
self.current_attrs: Dict[str, Optional[str]] = {}
self.tag_stack: List[str] = []
self.label_for_ids: Set[str] = set()
self.input_ids: List[Tuple[str, int]] = [] # (id, line)
self.inputs_with_aria_label: Set[str] = set()
self._in_label = False
self._label_has_input = False
self._current_data = ""
self._in_a = False
self._a_line = 0
self._a_text = ""
self._a_has_aria = False
def handle_starttag(self, tag: str, attrs: list):
tag = tag.lower()
attr_dict = {k.lower(): v for k, v in attrs}
line = self.getpos()[0]
self.tag_stack.append(tag)
# Check html lang attribute
if tag == "html":
if "lang" in attr_dict and attr_dict["lang"]:
self.has_lang = True
else:
self.findings.append(Finding(
severity="critical", wcag_level="A", criterion="3.1.1",
file=self.filename, line=line, element="<html>",
message="Missing or empty 'lang' attribute on <html> element.",
recommendation='Add lang attribute: <html lang="en">',
))
# Check images for alt text
if tag == "img":
role = attr_dict.get("role", "")
if role == "presentation" or attr_dict.get("aria-hidden") == "true":
pass # Decorative, skip
elif "alt" not in attr_dict:
self.findings.append(Finding(
severity="critical", wcag_level="A", criterion="1.1.1",
file=self.filename, line=line, element=f'<img src="{attr_dict.get("src", "")}">',
message="Image missing 'alt' attribute.",
recommendation="Add descriptive alt text, or alt='' for decorative images.",
))
elif attr_dict["alt"] == "" and role != "presentation":
# Empty alt without presentation role - could be intentional
pass
# Track headings
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
level = int(tag[1])
self.heading_levels.append((level, line))
if level == 1:
self.has_h1 = True
# Track landmarks
if tag == "main" or attr_dict.get("role") == "main":
self.has_main = True
# Track form labels
if tag == "label":
self._in_label = True
self._label_has_input = False
for_id = attr_dict.get("for", "")
if for_id:
self.label_for_ids.add(for_id)
# Track form inputs
if tag == "input":
input_type = attr_dict.get("type", "text").lower()
if input_type in self.FORM_INPUT_TYPES:
input_id = attr_dict.get("id", "")
has_aria = "aria-label" in attr_dict or "aria-labelledby" in attr_dict
has_title = "title" in attr_dict
if input_id:
self.input_ids.append((input_id, line))
if has_aria:
if input_id:
self.inputs_with_aria_label.add(input_id)
elif not input_id and not self._in_label and not has_title:
self.findings.append(Finding(
severity="critical", wcag_level="A", criterion="1.3.1",
file=self.filename, line=line,
element=f'<input type="{input_type}">',
message="Form input has no associated label, aria-label, or title.",
recommendation="Add a <label for='id'>, aria-label, or wrap input in <label>.",
))
if self._in_label:
self._label_has_input = True
if tag == "textarea" or tag == "select":
input_id = attr_dict.get("id", "")
if input_id:
self.input_ids.append((input_id, line))
if "aria-label" in attr_dict or "aria-labelledby" in attr_dict:
if input_id:
self.inputs_with_aria_label.add(input_id)
# Track anchor tags for link text
if tag == "a":
self._in_a = True
self._a_line = line
self._a_text = ""
self._a_has_aria = "aria-label" in attr_dict
# Check tabindex
tabindex = attr_dict.get("tabindex", "")
if tabindex:
try:
idx = int(tabindex)
if idx > 0:
self.findings.append(Finding(
severity="warning", wcag_level="AA", criterion="2.4.3",
file=self.filename, line=line, element=f"<{tag} tabindex=\"{idx}\">",
message=f"Positive tabindex ({idx}) disrupts natural tab order.",
recommendation="Use tabindex='0' for focusable or tabindex='-1' for programmatic focus.",
))
except ValueError:
pass
# Check for autoplaying media
if tag in ("video", "audio"):
if "autoplay" in attr_dict:
self.findings.append(Finding(
severity="critical", wcag_level="A", criterion="1.4.2",
file=self.filename, line=line, element=f"<{tag} autoplay>",
message="Auto-playing media without user control.",
recommendation="Remove autoplay or add controls and muted attributes.",
))
def handle_endtag(self, tag: str):
tag = tag.lower()
if self.tag_stack and self.tag_stack[-1] == tag:
self.tag_stack.pop()
if tag == "label":
self._in_label = False
if tag == "a" and self._in_a:
self._in_a = False
text = self._a_text.strip().lower()
if text and text in self.GENERIC_LINK_TEXT and not self._a_has_aria:
self.findings.append(Finding(
severity="warning", wcag_level="AA", criterion="2.4.4",
file=self.filename, line=self._a_line,
element=f'<a>...{self._a_text.strip()}...</a>',
message=f"Generic link text '{self._a_text.strip()}' is not descriptive.",
recommendation="Use descriptive link text that explains the destination.",
))
def handle_data(self, data: str):
if self._in_a:
self._a_text += data
def finalize(self):
"""Run post-parse checks."""
self._check_heading_hierarchy()
self._check_unlabeled_inputs()
self._check_missing_landmarks()
def _check_heading_hierarchy(self):
"""Check heading levels are sequential."""
if not self.heading_levels:
return
prev_level = 0
for level, line in self.heading_levels:
if level > prev_level + 1 and prev_level > 0:
self.findings.append(Finding(
severity="warning", wcag_level="AA", criterion="1.3.1",
file=self.filename, line=line, element=f"<h{level}>",
message=f"Heading level skipped: h{prev_level} to h{level}.",
recommendation=f"Use h{prev_level + 1} instead, or restructure heading hierarchy.",
))
prev_level = level
if not self.has_h1:
self.findings.append(Finding(
severity="warning", wcag_level="AA", criterion="1.3.1",
file=self.filename, line=0, element="(document)",
message="No <h1> element found in page.",
recommendation="Add exactly one <h1> element for the page title.",
))
def _check_unlabeled_inputs(self):
"""Check for inputs without matching labels."""
for input_id, line in self.input_ids:
if input_id not in self.label_for_ids and input_id not in self.inputs_with_aria_label:
self.findings.append(Finding(
severity="critical", wcag_level="A", criterion="1.3.1",
file=self.filename, line=line,
element=f'<input id="{input_id}">',
message=f"Input '{input_id}' has no matching <label for='{input_id}'>.",
recommendation=f'Add <label for="{input_id}">Label text</label> or aria-label.',
))
def _check_missing_landmarks(self):
"""Check for missing landmark regions."""
if not self.has_main:
self.findings.append(Finding(
severity="info", wcag_level="AAA", criterion="1.3.1",
file=self.filename, line=0, element="(document)",
message="No <main> landmark found.",
recommendation="Wrap primary content in <main> element.",
))
def scan_file(filepath: Path) -> List[Finding]:
"""Scan a single HTML file."""
content = filepath.read_text(errors="replace")
parser = A11yHTMLParser(str(filepath))
try:
parser.feed(content)
except Exception:
pass
parser.finalize()
return parser.findings
def format_text(all_findings: Dict[str, List[Finding]]) -> str:
"""Format as human-readable text."""
lines = []
lines.append("=" * 60)
lines.append("ACCESSIBILITY SCAN REPORT (WCAG 2.1)")
lines.append("=" * 60)
total = sum(len(f) for f in all_findings.values())
lines.append(f"\nFiles scanned: {len(all_findings)}")
lines.append(f"Total findings: {total}")
for filepath, findings in all_findings.items():
if not findings:
continue
lines.append(f"\n--- {filepath} ---")
critical = [f for f in findings if f.severity == "critical"]
warnings = [f for f in findings if f.severity == "warning"]
info = [f for f in findings if f.severity == "info"]
for sev, group in [("CRITICAL [Level A]", critical),
("WARNING [Level AA]", warnings),
("INFO [Level AAA]", info)]:
if not group:
continue
lines.append(f"\n [{sev}]")
for f in group:
loc = f"line {f.line}" if f.line > 0 else "global"
lines.append(f" WCAG {f.criterion} ({loc}): {f.message}")
lines.append(f" Element: {f.element}")
lines.append(f" Fix: {f.recommendation}")
if total == 0:
lines.append("\nNo accessibility issues found.")
lines.append("\n" + "=" * 60)
return "\n".join(lines)
def format_json(all_findings: Dict[str, List[Finding]]) -> str:
"""Format as JSON."""
total_findings = []
for filepath, findings in all_findings.items():
for f in findings:
total_findings.append(asdict(f))
return json.dumps({
"files_scanned": len(all_findings),
"findings": total_findings,
"summary": {
"total": len(total_findings),
"critical_a": sum(1 for f in total_findings if f["severity"] == "critical"),
"warning_aa": sum(1 for f in total_findings if f["severity"] == "warning"),
"info_aaa": sum(1 for f in total_findings if f["severity"] == "info"),
}
}, indent=2)
def main():
parser = argparse.ArgumentParser(
description="Scan HTML files for WCAG 2.1 accessibility violations."
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--file", "-f", help="Path to a single HTML file")
group.add_argument("--dir", "-d", help="Path to directory of HTML files")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format")
parser.add_argument("--level", choices=["A", "AA", "AAA"], default="AAA",
help="Minimum WCAG level to report")
parser.add_argument("--strict", action="store_true", help="Exit non-zero on any finding at level")
args = parser.parse_args()
level_map = {"A": {"critical"}, "AA": {"critical", "warning"}, "AAA": {"critical", "warning", "info"}}
include_severities = level_map[args.level]
all_findings: Dict[str, List[Finding]] = {}
if args.file:
path = Path(args.file)
if not path.exists():
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(2)
findings = scan_file(path)
all_findings[str(path)] = [f for f in findings if f.severity in include_severities]
else:
dir_path = Path(args.dir)
if not dir_path.is_dir():
print(f"Error: Not a directory: {args.dir}", file=sys.stderr)
sys.exit(2)
for root, _, files in os.walk(dir_path):
for fname in files:
if fname.endswith((".html", ".htm")):
filepath = Path(root) / fname
findings = scan_file(filepath)
filtered = [f for f in findings if f.severity in include_severities]
all_findings[str(filepath)] = filtered
if args.format == "json":
print(format_json(all_findings))
else:
print(format_text(all_findings))
total = sum(len(f) for f in all_findings.values())
if args.strict and total > 0:
sys.exit(1)
elif any(f.severity == "critical" for findings in all_findings.values() for f in findings):
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Contrast Checker - Check color contrast ratios against WCAG AA/AAA standards.
Calculates relative luminance contrast ratios between foreground and background
colors and validates against WCAG 2.1 thresholds for normal and large text.
Author: Claude Skills Engineering Team
License: MIT
"""
import argparse
import json
import math
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List, Tuple, Optional, Dict
@dataclass
class ContrastResult:
"""Result of a contrast check."""
foreground: str
background: str
ratio: float
aa_normal: bool # 4.5:1
aa_large: bool # 3:1
aaa_normal: bool # 7:1
aaa_large: bool # 4.5:1
suggestion: Optional[str] = None
# Named CSS colors (common subset)
CSS_COLORS = {
"black": "#000000", "white": "#ffffff", "red": "#ff0000",
"green": "#008000", "blue": "#0000ff", "yellow": "#ffff00",
"cyan": "#00ffff", "magenta": "#ff00ff", "gray": "#808080",
"grey": "#808080", "silver": "#c0c0c0", "maroon": "#800000",
"olive": "#808000", "navy": "#000080", "purple": "#800080",
"teal": "#008080", "aqua": "#00ffff", "orange": "#ffa500",
"pink": "#ffc0cb", "brown": "#a52a2a", "coral": "#ff7f50",
"crimson": "#dc143c", "darkblue": "#00008b", "darkgreen": "#006400",
"darkred": "#8b0000", "gold": "#ffd700", "indigo": "#4b0082",
"ivory": "#fffff0", "khaki": "#f0e68c", "lavender": "#e6e6fa",
"lime": "#00ff00", "linen": "#faf0e6", "mintcream": "#f5fffa",
"salmon": "#fa8072", "tomato": "#ff6347", "turquoise": "#40e0d0",
"violet": "#ee82ee", "wheat": "#f5deb3",
}
def parse_color(color_str: str) -> Optional[Tuple[int, int, int]]:
"""Parse a color string into RGB tuple."""
color_str = color_str.strip().lower()
# Named colors
if color_str in CSS_COLORS:
color_str = CSS_COLORS[color_str]
# Hex: #rgb or #rrggbb
if color_str.startswith("#"):
hex_val = color_str[1:]
if len(hex_val) == 3:
hex_val = "".join(c * 2 for c in hex_val)
if len(hex_val) == 6:
try:
r = int(hex_val[0:2], 16)
g = int(hex_val[2:4], 16)
b = int(hex_val[4:6], 16)
return (r, g, b)
except ValueError:
return None
# rgb(r, g, b) or rgba(r, g, b, a)
rgb_match = re.match(r'rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color_str)
if rgb_match:
return (int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)))
return None
def relative_luminance(r: int, g: int, b: int) -> float:
"""Calculate relative luminance per WCAG 2.1 definition."""
def linearize(val: int) -> float:
srgb = val / 255.0
if srgb <= 0.04045:
return srgb / 12.92
return ((srgb + 0.055) / 1.055) ** 2.4
r_lin = linearize(r)
g_lin = linearize(g)
b_lin = linearize(b)
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
def contrast_ratio(fg: Tuple[int, int, int], bg: Tuple[int, int, int]) -> float:
"""Calculate contrast ratio between two colors."""
l1 = relative_luminance(*fg)
l2 = relative_luminance(*bg)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def suggest_compliant_color(fg: Tuple[int, int, int], bg: Tuple[int, int, int],
target_ratio: float = 4.5) -> Optional[str]:
"""Suggest a modified foreground color that meets the target ratio."""
bg_lum = relative_luminance(*bg)
current_ratio = contrast_ratio(fg, bg)
if current_ratio >= target_ratio:
return None
# Try darkening or lightening the foreground
best_color = None
best_diff = float("inf")
for step in range(256):
# Try darker
factor = 1.0 - (step / 255.0)
dr = max(0, min(255, int(fg[0] * factor)))
dg = max(0, min(255, int(fg[1] * factor)))
db = max(0, min(255, int(fg[2] * factor)))
dark_ratio = contrast_ratio((dr, dg, db), bg)
if dark_ratio >= target_ratio:
diff = sum(abs(a - b) for a, b in zip(fg, (dr, dg, db)))
if diff < best_diff:
best_diff = diff
best_color = f"#{dr:02x}{dg:02x}{db:02x}"
break
for step in range(256):
# Try lighter
factor = step / 255.0
lr = max(0, min(255, fg[0] + int((255 - fg[0]) * factor)))
lg = max(0, min(255, fg[1] + int((255 - fg[1]) * factor)))
lb = max(0, min(255, fg[2] + int((255 - fg[2]) * factor)))
light_ratio = contrast_ratio((lr, lg, lb), bg)
if light_ratio >= target_ratio:
diff = sum(abs(a - b) for a, b in zip(fg, (lr, lg, lb)))
if diff < best_diff:
best_diff = diff
best_color = f"#{lr:02x}{lg:02x}{lb:02x}"
break
return best_color
def check_contrast(fg_str: str, bg_str: str) -> Optional[ContrastResult]:
"""Check contrast between two colors."""
fg = parse_color(fg_str)
bg = parse_color(bg_str)
if fg is None or bg is None:
return None
ratio = contrast_ratio(fg, bg)
ratio_rounded = round(ratio, 2)
suggestion = None
if ratio < 4.5:
suggestion = suggest_compliant_color(fg, bg, 4.5)
return ContrastResult(
foreground=fg_str,
background=bg_str,
ratio=ratio_rounded,
aa_normal=ratio >= 4.5,
aa_large=ratio >= 3.0,
aaa_normal=ratio >= 7.0,
aaa_large=ratio >= 4.5,
suggestion=suggestion,
)
def extract_css_colors(css_content: str) -> List[Tuple[str, str, str]]:
"""Extract color/background-color pairs from CSS."""
pairs = []
# Parse CSS rules (simplified)
rule_pattern = re.compile(r'([^{]+)\{([^}]+)\}')
for match in rule_pattern.finditer(css_content):
selector = match.group(1).strip()
body = match.group(2)
color = None
bg_color = None
# Extract color property
color_match = re.search(r'(?<![a-z-])color\s*:\s*([^;]+)', body)
if color_match:
color = color_match.group(1).strip()
# Extract background-color
bg_match = re.search(r'background-color\s*:\s*([^;]+)', body)
if bg_match:
bg_color = bg_match.group(1).strip()
# Also check shorthand background
if not bg_color:
bg_short = re.search(r'background\s*:\s*([^;]+)', body)
if bg_short:
val = bg_short.group(1).strip()
# Try to extract a color from shorthand
if parse_color(val.split()[0]) is not None:
bg_color = val.split()[0]
if color and bg_color:
pairs.append((selector, color, bg_color))
return pairs
def format_text_single(result: ContrastResult) -> str:
"""Format a single contrast result as text."""
lines = []
lines.append("=" * 50)
lines.append("COLOR CONTRAST CHECK")
lines.append("=" * 50)
lines.append(f"Foreground: {result.foreground}")
lines.append(f"Background: {result.background}")
lines.append(f"Contrast Ratio: {result.ratio}:1")
lines.append("")
lines.append("WCAG Compliance:")
lines.append(f" AA Normal Text (4.5:1): {'PASS' if result.aa_normal else 'FAIL'}")
lines.append(f" AA Large Text (3.0:1): {'PASS' if result.aa_large else 'FAIL'}")
lines.append(f" AAA Normal Text (7.0:1): {'PASS' if result.aaa_normal else 'FAIL'}")
lines.append(f" AAA Large Text (4.5:1): {'PASS' if result.aaa_large else 'FAIL'}")
if result.suggestion:
lines.append(f"\nSuggested foreground for AA compliance: {result.suggestion}")
lines.append("=" * 50)
return "\n".join(lines)
def format_text_css(results: List[Tuple[str, ContrastResult]]) -> str:
"""Format CSS contrast results as text."""
lines = []
lines.append("=" * 60)
lines.append("CSS COLOR CONTRAST REPORT")
lines.append("=" * 60)
failures = [(s, r) for s, r in results if not r.aa_normal]
passes = [(s, r) for s, r in results if r.aa_normal]
lines.append(f"\nColor pairs found: {len(results)}")
lines.append(f"AA failures: {len(failures)}")
lines.append(f"AA passes: {len(passes)}")
if failures:
lines.append("\n[FAILURES]")
for selector, r in failures:
lines.append(f" {selector}")
lines.append(f" {r.foreground} on {r.background} = {r.ratio}:1")
if r.suggestion:
lines.append(f" Suggested fix: {r.suggestion}")
lines.append("")
if passes:
lines.append("\n[PASSES]")
for selector, r in passes:
lines.append(f" {selector}: {r.ratio}:1")
lines.append("=" * 60)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Check color contrast ratios against WCAG AA/AAA standards."
)
parser.add_argument("--foreground", "--fg", help="Foreground color (hex, rgb, or name)")
parser.add_argument("--background", "--bg", help="Background color (hex, rgb, or name)")
parser.add_argument("--css", help="Path to CSS file to analyze")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format")
args = parser.parse_args()
if args.css:
path = Path(args.css)
if not path.exists():
print(f"Error: File not found: {args.css}", file=sys.stderr)
sys.exit(2)
content = path.read_text()
pairs = extract_css_colors(content)
results = []
for selector, fg, bg in pairs:
result = check_contrast(fg, bg)
if result:
results.append((selector, result))
if args.format == "json":
data = []
for selector, r in results:
entry = asdict(r)
entry["selector"] = selector
data.append(entry)
print(json.dumps({"pairs": data, "total": len(data),
"failures": sum(1 for _, r in results if not r.aa_normal)}, indent=2))
else:
print(format_text_css(results))
if any(not r.aa_normal for _, r in results):
sys.exit(1)
elif args.foreground and args.background:
result = check_contrast(args.foreground, args.background)
if result is None:
print("Error: Could not parse one or both colors.", file=sys.stderr)
sys.exit(2)
if args.format == "json":
print(json.dumps(asdict(result), indent=2))
else:
print(format_text_single(result))
if not result.aa_normal:
sys.exit(1)
else:
parser.error("Provide either --foreground and --background, or --css")
if __name__ == "__main__":
main()
Related skills
FAQ
What contrast ratios does it check?
WCAG AA requires 4.5:1 for normal text and 3:1 for large text; AAA requires 7:1 for normal text and 4.5:1 for large text.
Can it run in CI?
Yes. Run a11y_scanner.py with --format json --level A --strict to gate a build on Level A violations.