a11yhat 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
a11yhat-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vincent Gonzalez / F-Keys Creative LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
a11yhat-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: a11yhat
3
+ Version: 0.1.0
4
+ Summary: a11yhat: a WCAG 2.2 accessibility machine, structured by POUR — four crew leads, every success criterion a recorded check. A hardHats crew.
5
+ Author: Vincent Gonzalez
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vince-gonzalez/a11yhat
8
+ Project-URL: Repository, https://github.com/vince-gonzalez/a11yhat
9
+ Keywords: accessibility,wcag,a11y,section508,audit,pour
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # a11yhat
19
+
20
+ An open-source **WCAG 2.2 accessibility machine**, structured the way the work is
21
+ organized — by the four principles, **Perceivable · Operable · Understandable ·
22
+ Robust (POUR)**. One crew in the **hardHats** family (hardHats is the framework;
23
+ a11yhat is a crew built with it).
24
+
25
+ ## How it works
26
+ Each principle is a **crew lead** — a module that owns its success criteria. Every
27
+ applicable criterion gets a recorded result; nothing is silently skipped:
28
+
29
+ - **PASS** — checked and met.
30
+ - **FAIL** — checked and failed, with the specific elements.
31
+ - **CHECK** — a representative measurement ran; a human must confirm the rest
32
+ (keyboard operation, caption quality, reading order — anything a parser can't
33
+ judge alone). Each CHECK names the exact thing to look at.
34
+ - **NA** — not present on this page.
35
+
36
+ | Crew lead | Principle | Measured deterministically | Representative check + human confirm |
37
+ |---|---|---|---|
38
+ | `perceivable.py` | 1 | **contrast (1.4.3, luminance math)**, alt text (1.1.1), headings/labels/tables (1.3.1), autoplay (1.4.2), resize/zoom (1.4.4) | media captions (1.2.x), sensory wording (1.3.3), use of color (1.4.1), images of text (1.4.5) |
39
+ | `operable.py` | 2 | bypass (2.4.1), title (2.4.2), positive tabindex (2.4.3), link purpose (2.4.4), focus-visible suppression (2.4.7), mouse-only onclick (2.1.1) | keyboard operation, traps, timing, flashing, multiple ways |
40
+ | `understandable.py` | 3 | page language (3.1.1), auto-context-change on input (3.2.2), control labels (3.3.2) | language of parts, predictability, error identification/prevention |
41
+ | `robust.py` | 4 | duplicate ids (4.1.1), accessible name of every interactive element (4.1.2) | status-message live regions (4.1.3) |
42
+
43
+ Shared foundation: `dom.py` (a dependency-free DOM), `color.py` (WCAG luminance),
44
+ `base.py` (the uniform `Finding` record and the `Crew` base).
45
+
46
+ ## Install & run
47
+ ```
48
+ pip install a11yhat # once on PyPI
49
+ python -m a11yhat page.html # full POUR report, exit 1 on any FAIL
50
+ python -m a11yhat page.html --json
51
+ cat page.html | python -m a11yhat -
52
+ ```
53
+
54
+ ## Grounding
55
+ Criteria, topics, and pass/fail conditions follow the **DHS Trusted Tester for Web
56
+ (5.1.3)** process. Standard library only — nothing is installed into your
57
+ environment. Automated checks catch the machine-decidable failures; the CHECK
58
+ items are the ones a tester must confirm, each with the exact thing to review. It
59
+ is an honest tool: it does the fast 80% and tells you precisely what the human 20%
60
+ is — it does not pretend a scanner can certify a page.
61
+
62
+ ## License
63
+ MIT. Copyright Vincent Gonzalez / F-Keys Creative LLC.
64
+
65
+ ## Tests
66
+ `python -m unittest discover -s tests` — every deterministic check is proven to
67
+ catch its defect and to clear on good input, and a clean page produces zero
68
+ failures.
@@ -0,0 +1,51 @@
1
+ # a11yhat
2
+
3
+ An open-source **WCAG 2.2 accessibility machine**, structured the way the work is
4
+ organized — by the four principles, **Perceivable · Operable · Understandable ·
5
+ Robust (POUR)**. One crew in the **hardHats** family (hardHats is the framework;
6
+ a11yhat is a crew built with it).
7
+
8
+ ## How it works
9
+ Each principle is a **crew lead** — a module that owns its success criteria. Every
10
+ applicable criterion gets a recorded result; nothing is silently skipped:
11
+
12
+ - **PASS** — checked and met.
13
+ - **FAIL** — checked and failed, with the specific elements.
14
+ - **CHECK** — a representative measurement ran; a human must confirm the rest
15
+ (keyboard operation, caption quality, reading order — anything a parser can't
16
+ judge alone). Each CHECK names the exact thing to look at.
17
+ - **NA** — not present on this page.
18
+
19
+ | Crew lead | Principle | Measured deterministically | Representative check + human confirm |
20
+ |---|---|---|---|
21
+ | `perceivable.py` | 1 | **contrast (1.4.3, luminance math)**, alt text (1.1.1), headings/labels/tables (1.3.1), autoplay (1.4.2), resize/zoom (1.4.4) | media captions (1.2.x), sensory wording (1.3.3), use of color (1.4.1), images of text (1.4.5) |
22
+ | `operable.py` | 2 | bypass (2.4.1), title (2.4.2), positive tabindex (2.4.3), link purpose (2.4.4), focus-visible suppression (2.4.7), mouse-only onclick (2.1.1) | keyboard operation, traps, timing, flashing, multiple ways |
23
+ | `understandable.py` | 3 | page language (3.1.1), auto-context-change on input (3.2.2), control labels (3.3.2) | language of parts, predictability, error identification/prevention |
24
+ | `robust.py` | 4 | duplicate ids (4.1.1), accessible name of every interactive element (4.1.2) | status-message live regions (4.1.3) |
25
+
26
+ Shared foundation: `dom.py` (a dependency-free DOM), `color.py` (WCAG luminance),
27
+ `base.py` (the uniform `Finding` record and the `Crew` base).
28
+
29
+ ## Install & run
30
+ ```
31
+ pip install a11yhat # once on PyPI
32
+ python -m a11yhat page.html # full POUR report, exit 1 on any FAIL
33
+ python -m a11yhat page.html --json
34
+ cat page.html | python -m a11yhat -
35
+ ```
36
+
37
+ ## Grounding
38
+ Criteria, topics, and pass/fail conditions follow the **DHS Trusted Tester for Web
39
+ (5.1.3)** process. Standard library only — nothing is installed into your
40
+ environment. Automated checks catch the machine-decidable failures; the CHECK
41
+ items are the ones a tester must confirm, each with the exact thing to review. It
42
+ is an honest tool: it does the fast 80% and tells you precisely what the human 20%
43
+ is — it does not pretend a scanner can certify a page.
44
+
45
+ ## License
46
+ MIT. Copyright Vincent Gonzalez / F-Keys Creative LLC.
47
+
48
+ ## Tests
49
+ `python -m unittest discover -s tests` — every deterministic check is proven to
50
+ catch its defect and to clear on good input, and a clean page produces zero
51
+ failures.
File without changes
@@ -0,0 +1,6 @@
1
+ """Enable `python -m a11yhat page.html`."""
2
+ import sys
3
+
4
+ from .run import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,53 @@
1
+ """
2
+ Shared result model for hardHats. Every success criterion a crew checks returns a
3
+ Finding, so the four crew leads (Perceivable, Operable, Understandable, Robust)
4
+ produce one uniform record and the runner can roll them up by principle.
5
+
6
+ A criterion is never silently skipped: it is PASS, FAIL, REVIEW (a representative
7
+ measurement ran but a human must confirm), or NA (not present on this page). That
8
+ is the Trusted Tester stance - every applicable criterion gets a recorded result.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ PASS, FAIL, REVIEW, NA = "PASS", "FAIL", "REVIEW", "NA"
15
+
16
+ PRINCIPLE = {"1": "Perceivable", "2": "Operable", "3": "Understandable", "4": "Robust"}
17
+
18
+
19
+ @dataclass
20
+ class Finding:
21
+ sc: str # "1.1.1"
22
+ level: str # "A" / "AA"
23
+ name: str # short criterion name
24
+ topic: str # Trusted Tester topic reference
25
+ status: str = REVIEW
26
+ detail: list = field(default_factory=list) # specific problems or notes
27
+ measurement: str = "" # the number/evidence the check produced
28
+
29
+ @property
30
+ def principle(self) -> str:
31
+ return PRINCIPLE[self.sc[0]]
32
+
33
+
34
+ class Crew:
35
+ """A principle's crew lead. Each check() appends one Finding per criterion."""
36
+
37
+ principle_num = ""
38
+
39
+ def __init__(self, doc, css: str = "", html: str = ""):
40
+ self.doc = doc
41
+ self.css = css or ""
42
+ self.html = html or ""
43
+ self.findings: list[Finding] = []
44
+
45
+ def record(self, sc, level, name, topic, status, detail=None, measurement=""):
46
+ self.findings.append(Finding(sc, level, name, topic, status,
47
+ list(detail or []), measurement))
48
+
49
+ def run(self) -> list[Finding]:
50
+ for name in dir(self):
51
+ if name.startswith("sc_"):
52
+ getattr(self, name)()
53
+ return self.findings
@@ -0,0 +1,42 @@
1
+ """WCAG contrast math. Relative luminance and ratio, per WCAG 2.x definitions."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ _NAMED = {"black": (0, 0, 0), "white": (255, 255, 255), "red": (255, 0, 0),
7
+ "green": (0, 128, 0), "blue": (0, 0, 255), "gray": (128, 128, 128),
8
+ "grey": (128, 128, 128), "silver": (192, 192, 192), "navy": (0, 0, 128)}
9
+
10
+
11
+ def parse_color(value: str):
12
+ """#rgb, #rrggbb, rgb()/rgba(), or a few named colors -> (r,g,b) or None."""
13
+ v = value.strip().lower()
14
+ if v in _NAMED:
15
+ return _NAMED[v]
16
+ m = re.fullmatch(r"#([0-9a-f]{3})", v)
17
+ if m:
18
+ return tuple(int(c * 2, 16) for c in m.group(1))
19
+ m = re.fullmatch(r"#([0-9a-f]{6})", v)
20
+ if m:
21
+ h = m.group(1)
22
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
23
+ m = re.match(r"rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)", v)
24
+ if m:
25
+ return tuple(min(255, int(float(x))) for x in m.groups())
26
+ return None
27
+
28
+
29
+ def _lin(c: float) -> float:
30
+ c /= 255.0
31
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
32
+
33
+
34
+ def luminance(rgb) -> float:
35
+ r, g, b = (_lin(x) for x in rgb)
36
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
37
+
38
+
39
+ def ratio(fg, bg) -> float:
40
+ l1, l2 = luminance(fg), luminance(bg)
41
+ hi, lo = max(l1, l2), min(l1, l2)
42
+ return (hi + 0.05) / (lo + 0.05)
@@ -0,0 +1,93 @@
1
+ """
2
+ A small, dependency-free DOM for the hardHats accessibility checks.
3
+
4
+ Standard-library html.parser only (nothing is pip-installed into his env). The
5
+ checks need to ask real structural questions - every img, heading levels in
6
+ order, form controls and their labels, interactive elements, duplicate ids - so
7
+ they query this tree instead of guessing with regex.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from html.parser import HTMLParser
12
+
13
+ VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
14
+ "meta", "param", "source", "track", "wbr"}
15
+ INTERACTIVE = {"a", "button", "input", "select", "textarea", "details", "summary"}
16
+
17
+
18
+ class Node:
19
+ __slots__ = ("tag", "attrs", "parent", "children", "text", "line")
20
+
21
+ def __init__(self, tag, attrs=None, line=0):
22
+ self.tag = tag
23
+ self.attrs = attrs or {}
24
+ self.parent = None
25
+ self.children: list[Node] = []
26
+ self.text = ""
27
+ self.line = line
28
+
29
+ def get(self, name, default=None):
30
+ return self.attrs.get(name, default)
31
+
32
+ def has(self, name):
33
+ return name in self.attrs
34
+
35
+ def walk(self):
36
+ yield self
37
+ for c in self.children:
38
+ yield from c.walk()
39
+
40
+ def find(self, *tags):
41
+ tset = set(tags)
42
+ return [n for n in self.walk() if n.tag in tset]
43
+
44
+ def inner_text(self):
45
+ out = [self.text]
46
+ for c in self.children:
47
+ out.append(c.inner_text())
48
+ return " ".join(t for t in out if t).strip()
49
+
50
+ def role(self):
51
+ return (self.get("role") or "").strip().lower()
52
+
53
+ def is_interactive(self):
54
+ if self.tag in INTERACTIVE and not (self.tag == "a" and not self.has("href")):
55
+ return True
56
+ if self.role() in {"button", "link", "checkbox", "menuitem", "tab", "switch"}:
57
+ return True
58
+ return self.has("onclick") or self.has("tabindex")
59
+
60
+
61
+ class _Builder(HTMLParser):
62
+ def __init__(self):
63
+ super().__init__(convert_charrefs=True)
64
+ self.root = Node("#document")
65
+ self.stack = [self.root]
66
+
67
+ def handle_starttag(self, tag, attrs):
68
+ node = Node(tag, {k: (v or "") for k, v in attrs}, self.getpos()[0])
69
+ node.parent = self.stack[-1]
70
+ self.stack[-1].children.append(node)
71
+ if tag not in VOID:
72
+ self.stack.append(node)
73
+
74
+ def handle_startendtag(self, tag, attrs):
75
+ node = Node(tag, {k: (v or "") for k, v in attrs}, self.getpos()[0])
76
+ node.parent = self.stack[-1]
77
+ self.stack[-1].children.append(node)
78
+
79
+ def handle_endtag(self, tag):
80
+ for i in range(len(self.stack) - 1, 0, -1):
81
+ if self.stack[i].tag == tag:
82
+ del self.stack[i:]
83
+ break
84
+
85
+ def handle_data(self, data):
86
+ if data.strip():
87
+ self.stack[-1].text = (self.stack[-1].text + " " + data.strip()).strip()
88
+
89
+
90
+ def parse(html: str) -> Node:
91
+ b = _Builder()
92
+ b.feed(html)
93
+ return b.root
@@ -0,0 +1,164 @@
1
+ """
2
+ Operable - WCAG Principle 2 crew lead. UI components and navigation must be
3
+ operable. Keyboard and focus-order "sense" can only be confirmed by a human, so
4
+ those criteria run a representative measurement (enumerate the controls, catch the
5
+ static giveaways) and flag the rest for the keyboard pass.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .base import FAIL, NA, PASS, REVIEW, Crew
12
+
13
+ VAGUE_LINK = {"click here", "here", "read more", "more", "link", "this", "learn more",
14
+ "details", "continue", "go", "click", "download"}
15
+
16
+
17
+ class Operable(Crew):
18
+ principle_num = "2"
19
+
20
+ # ---- 2.1.1 Keyboard (Topic 4) ----
21
+ def sc_211_keyboard(self):
22
+ mouse_only = []
23
+ for el in self.doc.walk():
24
+ if el.has("onclick") and el.tag not in ("a", "button", "input", "select", "textarea") \
25
+ and not el.role() and not el.has("tabindex"):
26
+ mouse_only.append(el.tag)
27
+ interactive = [e for e in self.doc.walk() if e.is_interactive()]
28
+ status = FAIL if mouse_only else REVIEW
29
+ detail = []
30
+ if mouse_only:
31
+ detail.append(f"{len(mouse_only)} element(s) with onclick but no role/tabindex "
32
+ f"(keyboard cannot reach): {', '.join(sorted(set(mouse_only))[:4])}")
33
+ detail.append(f"confirm all {len(interactive)} interactive elements work with Tab/Enter/Space")
34
+ self.record("2.1.1", "A", "Keyboard", "Topic 4 Keyboard access & focus", status, detail,
35
+ f"{len(interactive)} interactive, {len(mouse_only)} mouse-only")
36
+
37
+ # ---- 2.1.2 No keyboard trap (Topic 4) ----
38
+ def sc_212_trap(self):
39
+ self.record("2.1.2", "A", "No keyboard trap", "Topic 4 Keyboard access & focus", REVIEW,
40
+ ["tab through the page; focus must never get stuck in a widget or dialog"],
41
+ "manual")
42
+
43
+ # ---- 2.2.1 / 2.2.2 Timing & moving content (Topics 8, 2) ----
44
+ def sc_221_timing(self):
45
+ refresh = re.search(r'<meta[^>]+http-equiv=["\']refresh["\'][^>]*content=["\'](\d+)', self.html, re.I)
46
+ if refresh and int(refresh.group(1)) > 0:
47
+ self.record("2.2.1", "A", "Timing adjustable", "Topic 8 Adjustable time limits", FAIL,
48
+ [f"meta refresh after {refresh.group(1)}s with no user control"],
49
+ f"refresh={refresh.group(1)}s")
50
+ else:
51
+ self.record("2.2.1", "A", "Timing adjustable", "Topic 8 Adjustable time limits", REVIEW,
52
+ ["no auto-refresh found; confirm any session/interaction time limits are "
53
+ "adjustable"], "no meta-refresh")
54
+
55
+ def sc_222_moving(self):
56
+ moving = len(self.doc.find("marquee", "blink"))
57
+ anim = len(re.findall(r"@keyframes|animation\s*:", self.css, re.I))
58
+ autop = len([m for m in self.doc.find("audio", "video") if m.has("autoplay")])
59
+ n = moving + anim + autop
60
+ self.record("2.2.2", "A", "Pause, stop, hide", "Topic 2 Auto-updating content",
61
+ FAIL if moving else (REVIEW if n else PASS),
62
+ ([f"moving/auto-updating content: {moving} marquee, {anim} CSS animation, "
63
+ f"{autop} autoplay - must be pausable if it runs over 5s"] if n else []),
64
+ f"{n} moving source(s)")
65
+
66
+ # ---- 2.3.1 Three flashes (Topic 3) ----
67
+ def sc_231_flash(self):
68
+ risk = len(self.doc.find("video", "canvas")) + len(re.findall(r"@keyframes", self.css, re.I))
69
+ self.record("2.3.1", "A", "Three flashes or below", "Topic 3 Flashing",
70
+ REVIEW if risk else PASS,
71
+ (["nothing may flash more than 3 times per second; review video/animation"]
72
+ if risk else []), f"{risk} animation/video source(s)")
73
+
74
+ # ---- 2.4.1 Bypass blocks (Topic 9) ----
75
+ def sc_241_bypass(self):
76
+ skip = any("skip" in (a.inner_text() or "").lower() for a in self.doc.find("a") if a.has("href"))
77
+ landmark = bool(self.doc.find("main") or any(e.role() == "main" for e in self.doc.walk()))
78
+ ok = skip or landmark
79
+ self.record("2.4.1", "A", "Bypass blocks", "Topic 9 Repetitive content",
80
+ PASS if ok else FAIL,
81
+ ([] if ok else ["no skip link and no <main>/role=main landmark to bypass "
82
+ "repeated navigation"]),
83
+ f"skip-link={skip}, main={landmark}")
84
+
85
+ # ---- 2.4.2 Page titled (Topic 12) ----
86
+ def sc_242_title(self):
87
+ t = self.doc.find("title")
88
+ text = t[0].inner_text() if t else ""
89
+ self.record("2.4.2", "A", "Page titled", "Topic 12 Page titles & frames",
90
+ PASS if text.strip() else FAIL,
91
+ ([] if text.strip() else ["missing or empty <title>"]),
92
+ f'title="{text[:40]}"')
93
+
94
+ # ---- 2.4.3 Focus order (Topic 4) ----
95
+ def sc_243_focusorder(self):
96
+ pos = [int(e.get("tabindex")) for e in self.doc.walk()
97
+ if e.get("tabindex", "").lstrip("-").isdigit() and int(e.get("tabindex")) > 0]
98
+ self.record("2.4.3", "A", "Focus order", "Topic 4 Keyboard access & focus",
99
+ FAIL if pos else REVIEW,
100
+ ([f"{len(pos)} positive tabindex value(s) override natural order"] if pos
101
+ else ["confirm the tab order follows the visual/reading order"]),
102
+ f"{len(pos)} positive tabindex")
103
+
104
+ # ---- 2.4.4 Link purpose (Topic 6) ----
105
+ def sc_244_linkpurpose(self):
106
+ empty, vague = [], []
107
+ for a in self.doc.find("a"):
108
+ if not a.has("href"):
109
+ continue
110
+ txt = (a.inner_text() or a.get("aria-label") or a.get("title") or "").strip().lower()
111
+ if not txt:
112
+ empty.append(a.get("href", "")[:30])
113
+ elif txt in VAGUE_LINK:
114
+ vague.append(txt)
115
+ detail = []
116
+ if empty:
117
+ detail.append(f"{len(empty)} link(s) with no text/label")
118
+ if vague:
119
+ detail.append(f"non-descriptive link text: {', '.join(sorted(set(vague))[:5])}")
120
+ self.record("2.4.4", "A", "Link purpose (in context)", "Topic 6 Links",
121
+ FAIL if empty else (REVIEW if vague else PASS), detail,
122
+ f"{len(self.doc.find('a'))} links, {len(empty)} empty, {len(vague)} vague")
123
+
124
+ # ---- 2.4.5 Multiple ways (Topic 19) ----
125
+ def sc_245_multiple(self):
126
+ nav = len(self.doc.find("nav")) or sum(1 for e in self.doc.walk() if e.role() == "navigation")
127
+ search = bool([i for i in self.doc.find("input") if i.get("type") == "search"]) \
128
+ or "search" in self.html.lower()
129
+ ways = (1 if nav else 0) + (1 if search else 0)
130
+ self.record("2.4.5", "AA", "Multiple ways", "Topic 19 Multiple ways", REVIEW,
131
+ [f"confirm at least two ways to find pages (nav={bool(nav)}, search={search}); "
132
+ "a sitemap or site search counts"], f"{ways} way(s) detected")
133
+
134
+ # ---- 2.4.6 Headings and labels (AA) ----
135
+ def sc_246_headings(self):
136
+ empty_h = [h.tag for h in self.doc.find("h1", "h2", "h3", "h4", "h5", "h6")
137
+ if not h.inner_text().strip()]
138
+ self.record("2.4.6", "AA", "Headings and labels", "Topic 10 Content structure",
139
+ FAIL if empty_h else REVIEW,
140
+ ([f"{len(empty_h)} empty heading(s)"] if empty_h
141
+ else ["confirm headings and labels describe their topic or purpose"]),
142
+ f"{len(empty_h)} empty headings")
143
+
144
+ # ---- 2.4.7 Focus visible (Topic 4, AA) ----
145
+ def sc_247_focusvisible(self):
146
+ blocks = re.findall(r"(:focus(?:-visible)?[^{]*)\{([^}]*)\}", self.css, re.I)
147
+ if not blocks:
148
+ self.record("2.4.7", "AA", "Focus visible", "Topic 4 Keyboard access & focus", REVIEW,
149
+ ["no :focus style found; confirm the browser default focus ring is visible "
150
+ "and not suppressed elsewhere"], "no :focus rule")
151
+ return
152
+ bad = []
153
+ for sel, body in blocks:
154
+ b = body.lower()
155
+ removes = re.search(r"outline\s*:\s*(none|0)\b", b)
156
+ replaces = (re.search(r"box-shadow\s*:\s*(?!none)", b)
157
+ or re.search(r"border(-[a-z]+)?\s*:\s*(?!none|0\b)\S", b)
158
+ or re.search(r"outline\s*:\s*(?!none|0\b)\S", b))
159
+ if removes and not replaces:
160
+ bad.append(sel.strip())
161
+ self.record("2.4.7", "AA", "Focus visible", "Topic 4 Keyboard access & focus",
162
+ FAIL if bad else PASS,
163
+ ([f"focus outline removed with no visible replacement: {bad[0]}"] if bad else []),
164
+ f"{len(blocks)} focus rule(s), {len(bad)} suppressed")
@@ -0,0 +1,206 @@
1
+ """
2
+ Perceivable - WCAG Principle 1 crew lead. Information and UI components must be
3
+ presentable in ways users can perceive. Every applicable criterion gets a result;
4
+ contrast (1.4.3) is measured, the rest are checked structurally or flagged for
5
+ confirmation with the specific thing to look at.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .base import FAIL, NA, PASS, REVIEW, Crew
12
+ from .color import parse_color, ratio
13
+
14
+ SENSORY = re.compile(r"\b(round|square|circular|the (left|right|top|bottom)|"
15
+ r"on the (left|right)|above|below|the (green|red|blue) (button|box|link))\b", re.I)
16
+
17
+
18
+ def css_rules(css: str):
19
+ """Yield (selector, {prop: value}) for each rule block in a stylesheet."""
20
+ for block in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
21
+ sel, body = block[0].strip(), block[1]
22
+ decls = {}
23
+ for part in body.split(";"):
24
+ if ":" in part:
25
+ k, v = part.split(":", 1)
26
+ decls[k.strip().lower()] = v.strip()
27
+ if decls:
28
+ yield sel, decls
29
+
30
+
31
+ class Perceivable(Crew):
32
+ principle_num = "1"
33
+
34
+ # ---- 1.1.1 Non-text content (Topic 7) ----
35
+ def sc_111_images(self):
36
+ imgs = self.doc.find("img")
37
+ no_alt = [i for i in imgs if not i.has("alt")]
38
+ bad_alt = []
39
+ for i in imgs:
40
+ alt = i.get("alt", "").strip()
41
+ if alt and (re.search(r"\.(png|jpe?g|gif|svg|webp)$", alt, re.I)
42
+ or re.fullmatch(r"(image|img|photo|picture|icon|logo)\d*", alt, re.I)):
43
+ bad_alt.append(alt[:40])
44
+ svg_unnamed = [s for s in self.doc.find("svg")
45
+ if s.role() == "img" and not (s.has("aria-label") or s.find("title"))]
46
+ canvas_bare = [c for c in self.doc.find("canvas") if not c.inner_text()]
47
+ detail = []
48
+ if no_alt:
49
+ detail.append(f"{len(no_alt)} <img> without an alt attribute")
50
+ if bad_alt:
51
+ detail.append(f"alt looks like a filename/label: {', '.join(bad_alt[:3])}")
52
+ if svg_unnamed:
53
+ detail.append(f"{len(svg_unnamed)} role=img <svg> with no title/aria-label")
54
+ if canvas_bare:
55
+ detail.append(f"{len(canvas_bare)} <canvas> with no text alternative")
56
+ if not imgs and not self.doc.find("svg", "canvas"):
57
+ self.record("1.1.1", "A", "Non-text content", "Topic 7 Images", NA,
58
+ measurement="no images on the page")
59
+ return
60
+ status = FAIL if (no_alt or svg_unnamed or canvas_bare) else (REVIEW if bad_alt else PASS)
61
+ self.record("1.1.1", "A", "Non-text content", "Topic 7 Images", status, detail,
62
+ f"{len(imgs)} img, {len(no_alt)} missing alt")
63
+
64
+ # ---- 1.2.x Time-based media (Topics 16, 17) ----
65
+ def sc_12_media(self):
66
+ av = self.doc.find("audio", "video")
67
+ if not av:
68
+ self.record("1.2.1", "A", "Audio/video alternatives", "Topic 16/17 Media", NA,
69
+ measurement="no audio or video elements")
70
+ return
71
+ tracks = sum(1 for v in av for t in v.find("track") if t.get("kind") == "captions")
72
+ self.record("1.2.2", "A", "Captions & transcripts", "Topic 17 Synchronized media", REVIEW,
73
+ [f"{len(av)} media element(s); confirm captions, transcript, and audio "
74
+ f"description (found {tracks} caption track(s))"],
75
+ f"{len(av)} media, {tracks} caption tracks")
76
+
77
+ # ---- 1.3.1 Info and relationships (Topics 10, 14, 5) ----
78
+ def sc_131_structure(self):
79
+ detail = []
80
+ # headings do not skip levels
81
+ levels = [int(h.tag[1]) for h in self.doc.find("h1", "h2", "h3", "h4", "h5", "h6")]
82
+ prev = 0
83
+ for lv in levels:
84
+ if prev and lv > prev + 1:
85
+ detail.append(f"heading jumps from h{prev} to h{lv}")
86
+ prev = lv
87
+ # form controls have a programmatic label
88
+ unlabeled = []
89
+ labels_for = {l.get("for") for l in self.doc.find("label") if l.has("for")}
90
+ for ctl in self.doc.find("input", "select", "textarea"):
91
+ if ctl.get("type", "").lower() in ("hidden", "submit", "button", "reset", "image"):
92
+ continue
93
+ named = (ctl.has("aria-label") or ctl.has("aria-labelledby")
94
+ or ctl.has("title") or ctl.get("id") in labels_for
95
+ or (ctl.parent and ctl.parent.tag == "label"))
96
+ if not named:
97
+ unlabeled.append(ctl.get("name") or ctl.get("id") or ctl.tag)
98
+ # data tables have header cells
99
+ headerless = [t for t in self.doc.find("table")
100
+ if t.find("td") and not (t.find("th") or any(
101
+ c.get("scope") for c in t.find("td")))]
102
+ if unlabeled:
103
+ detail.append(f"{len(unlabeled)} form control(s) without a label")
104
+ if headerless:
105
+ detail.append(f"{len(headerless)} data table(s) without <th> header cells")
106
+ status = FAIL if detail else PASS
107
+ self.record("1.3.1", "A", "Info and relationships", "Topic 10/14/5 Structure", status,
108
+ detail, f"{len(levels)} headings, "
109
+ f"{len(self.doc.find('input','select','textarea'))} controls, "
110
+ f"{len(self.doc.find('table'))} tables")
111
+
112
+ # ---- 1.3.2 Meaningful sequence (Topic 15) ----
113
+ def sc_132_sequence(self):
114
+ reorder = len(re.findall(r"\b(order\s*:\s*-?\d|float\s*:\s*(left|right)|"
115
+ r"position\s*:\s*absolute|direction\s*:\s*rtl)\b", self.css, re.I))
116
+ self.record("1.3.2", "A", "Meaningful sequence", "Topic 15 CSS content & positioning",
117
+ REVIEW if reorder else PASS,
118
+ ([f"{reorder} CSS rule(s) reorder/position content - confirm the DOM order "
119
+ f"still reads correctly with CSS off"] if reorder else []),
120
+ f"{reorder} reordering rules")
121
+
122
+ # ---- 1.3.3 Sensory characteristics (Topic 13) ----
123
+ def sc_133_sensory(self):
124
+ hits = set(m.group(0) for m in SENSORY.finditer(self.doc.inner_text()))
125
+ self.record("1.3.3", "A", "Sensory characteristics", "Topic 13 Sensory & contrast",
126
+ REVIEW if hits else PASS,
127
+ ([f"instructions may rely on shape/position/color alone: "
128
+ f"{', '.join(list(hits)[:4])}"] if hits else []),
129
+ f"{len(hits)} sensory phrase(s)")
130
+
131
+ # ---- 1.4.1 Use of color (Topic 13) ----
132
+ def sc_141_color(self):
133
+ links = self.doc.find("a")
134
+ plain = [a for a in links if a.has("href") and "text-decoration" in self.css.lower()
135
+ and re.search(r"text-decoration\s*:\s*none", self.css, re.I)]
136
+ self.record("1.4.1", "A", "Use of color", "Topic 13 Sensory & contrast", REVIEW,
137
+ ["confirm no information is conveyed by color alone (links, required fields, "
138
+ f"states); {('underlines removed via CSS' if plain else 'check in-text links')}"],
139
+ f"{len(links)} links")
140
+
141
+ # ---- 1.4.2 Audio control (Topic 2) ----
142
+ def sc_142_audio(self):
143
+ autop = [m for m in self.doc.find("audio", "video")
144
+ if m.has("autoplay") and not m.has("muted")]
145
+ self.record("1.4.2", "A", "Audio control", "Topic 2 Auto-playing content",
146
+ FAIL if autop else PASS,
147
+ ([f"{len(autop)} media element(s) autoplay sound with no control"] if autop else []),
148
+ f"{len(autop)} autoplaying")
149
+
150
+ # ---- 1.4.3 Contrast (minimum) - MEASURED (Topic 13) ----
151
+ def sc_143_contrast(self):
152
+ pairs = [] # (ratio, where, fg, bg)
153
+ # inline styles that set both color and a background
154
+ for el in self.doc.walk():
155
+ st = el.get("style", "").lower()
156
+ fg = re.search(r"(?<!-)color\s*:\s*([^;]+)", st)
157
+ bg = re.search(r"background(?:-color)?\s*:\s*([^;]+)", st)
158
+ if fg and bg:
159
+ c, b = parse_color(fg.group(1)), parse_color(bg.group(1))
160
+ if c and b:
161
+ pairs.append((ratio(c, b), f"<{el.tag} style>", fg.group(1).strip(), bg.group(1).strip()))
162
+ # stylesheet rules that set both
163
+ for sel, decls in css_rules(self.css):
164
+ if "color" in decls and ("background" in decls or "background-color" in decls):
165
+ c = parse_color(decls["color"])
166
+ b = parse_color(decls.get("background-color") or decls.get("background", ""))
167
+ if c and b:
168
+ pairs.append((ratio(c, b), sel.strip()[:40], decls["color"],
169
+ decls.get("background-color") or decls["background"]))
170
+ if not pairs:
171
+ self.record("1.4.3", "AA", "Contrast (minimum)", "Topic 13 Sensory & contrast", REVIEW,
172
+ ["no element declares both text and background color; measure rendered "
173
+ "contrast with a tool (needs 4.5:1 normal, 3:1 large text)"], "0 pairs measured")
174
+ return
175
+ fails = [p for p in pairs if p[0] < 4.5]
176
+ worst = min(pairs, key=lambda p: p[0])
177
+ detail = [f"{w}: {r:.2f}:1 ({fg} on {bg}) - below 4.5:1"
178
+ for r, w, fg, bg in sorted(fails)[:5]]
179
+ self.record("1.4.3", "AA", "Contrast (minimum)", "Topic 13 Sensory & contrast",
180
+ FAIL if fails else PASS, detail,
181
+ f"{len(pairs)} pairs measured, lowest {worst[0]:.2f}:1, {len(fails)} below 4.5:1")
182
+
183
+ # ---- 1.4.4 Resize text (Topic 18) ----
184
+ def sc_144_resize(self):
185
+ detail = []
186
+ vp = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]*>', self.html, re.I)
187
+ if vp and re.search(r"user-scalable\s*=\s*no|maximum-scale\s*=\s*(1(\.0)?|0)", vp.group(0), re.I):
188
+ detail.append("viewport blocks zoom (user-scalable=no / maximum-scale<=1)")
189
+ tiny = sorted(set(int(m.group(1)) for m in re.finditer(r"font-size\s*:\s*(\d+)px", self.css)
190
+ if int(m.group(1)) < 13))
191
+ if tiny:
192
+ detail.append(f"font-size below 13px floor: {', '.join(f'{p}px' for p in tiny)}")
193
+ self.record("1.4.4", "AA", "Resize text", "Topic 18 Resize text",
194
+ FAIL if any("blocks zoom" in d for d in detail) else (REVIEW if tiny else PASS),
195
+ detail, f"zoom blocked: {bool(vp and 'detail' and any('blocks' in d for d in detail))}")
196
+
197
+ # ---- 1.4.5 Images of text (Topic 7/13) ----
198
+ def sc_145_imgtext(self):
199
+ imgs = self.doc.find("img")
200
+ if not imgs:
201
+ self.record("1.4.5", "AA", "Images of text", "Topic 7 Images", NA,
202
+ measurement="no images")
203
+ return
204
+ self.record("1.4.5", "AA", "Images of text", "Topic 7 Images", REVIEW,
205
+ [f"confirm none of the {len(imgs)} images are pictures of text that could be "
206
+ "real text (logos are exempt)"], f"{len(imgs)} images to review")
@@ -0,0 +1,70 @@
1
+ """
2
+ Robust - WCAG Principle 4 crew lead. Content must be robust enough for assistive
3
+ technology. Name/role/value is the heart of it, so the accessible name of every
4
+ interactive element is checked directly; parsing (duplicate ids) is deterministic;
5
+ status messages are flagged for the dynamic-content pass.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+
11
+ from .base import FAIL, NA, PASS, REVIEW, Crew
12
+
13
+ NAMED_ROLE_NEEDS = {
14
+ "checkbox": "aria-checked", "switch": "aria-checked", "radio": "aria-checked",
15
+ "slider": "aria-valuenow", "combobox": "aria-expanded", "tab": "aria-selected",
16
+ }
17
+
18
+
19
+ class Robust(Crew):
20
+ principle_num = "4"
21
+
22
+ # ---- 4.1.1 Parsing / duplicate ids (Topic 20) ----
23
+ def sc_411_parsing(self):
24
+ ids = [e.get("id") for e in self.doc.walk() if e.has("id") and e.get("id")]
25
+ dups = [i for i, n in Counter(ids).items() if n > 1]
26
+ self.record("4.1.1", "A", "Parsing (unique ids)", "Topic 20 Parsing",
27
+ FAIL if dups else PASS,
28
+ ([f"duplicate id(s): {', '.join(dups[:6])}"] if dups else []),
29
+ f"{len(ids)} ids, {len(dups)} duplicated")
30
+
31
+ # ---- 4.1.2 Name, role, value (Topics 4/5/6) ----
32
+ def sc_412_namerolevalue(self):
33
+ labels_for = {l.get("for") for l in self.doc.find("label") if l.has("for")}
34
+ nameless, roleless_widget = [], []
35
+ for el in self.doc.walk():
36
+ if not el.is_interactive():
37
+ continue
38
+ name = (el.inner_text() or el.get("aria-label") or el.get("aria-labelledby")
39
+ or el.get("title") or el.get("alt") or el.get("value")
40
+ or el.get("placeholder") or "").strip()
41
+ associated = el.get("id") in labels_for or (el.parent and el.parent.tag == "label")
42
+ if not name and not associated:
43
+ nameless.append(el.tag + (f"[{el.role()}]" if el.role() else ""))
44
+ role = el.role()
45
+ if role in NAMED_ROLE_NEEDS and not el.has(NAMED_ROLE_NEEDS[role]):
46
+ roleless_widget.append(f"{role} missing {NAMED_ROLE_NEEDS[role]}")
47
+ detail = []
48
+ if nameless:
49
+ detail.append(f"{len(nameless)} interactive element(s) with no accessible name: "
50
+ f"{', '.join(sorted(set(nameless))[:4])}")
51
+ if roleless_widget:
52
+ detail.append(f"custom widget(s) missing a required state: "
53
+ f"{', '.join(sorted(set(roleless_widget))[:4])}")
54
+ total = sum(1 for e in self.doc.walk() if e.is_interactive())
55
+ if not total:
56
+ self.record("4.1.2", "A", "Name, role, value", "Topic 5 Forms / 4 Controls", NA,
57
+ measurement="no interactive elements")
58
+ return
59
+ self.record("4.1.2", "A", "Name, role, value", "Topic 5 Forms / 4 Controls",
60
+ FAIL if detail else PASS, detail,
61
+ f"{total} interactive, {len(nameless)} unnamed")
62
+
63
+ # ---- 4.1.3 Status messages (AA) ----
64
+ def sc_413_status(self):
65
+ live = [e for e in self.doc.walk()
66
+ if e.has("aria-live") or e.role() in ("status", "alert", "log")]
67
+ self.record("4.1.3", "AA", "Status messages", "Topic (dynamic content)", REVIEW,
68
+ [f"confirm dynamic updates (errors, cart counts, results) are announced via a "
69
+ f"live region ({len(live)} aria-live/status region(s) present)"],
70
+ f"{len(live)} live region(s)")
@@ -0,0 +1,91 @@
1
+ """
2
+ a11yhat - runner. Parses an HTML page, deploys the four crew leads (Perceivable,
3
+ Operable, Understandable, Robust), and reports every applicable success criterion
4
+ with its result and the measurement behind it. A crew in the hardHats family.
5
+
6
+ python -m a11yhat page.html
7
+ python -m a11yhat page.html --json
8
+ cat page.html | python -m a11yhat -
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import sys
15
+
16
+ try:
17
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
18
+ except (AttributeError, ValueError):
19
+ pass
20
+
21
+ from .base import FAIL, NA, PASS, REVIEW
22
+ from .dom import parse
23
+ from .operable import Operable
24
+ from .perceivable import Perceivable
25
+ from .robust import Robust
26
+ from .understandable import Understandable
27
+
28
+ CREWS = [Perceivable, Operable, Understandable, Robust]
29
+ MARK = {PASS: "PASS", FAIL: "FAIL", REVIEW: "CHECK", NA: " -- "}
30
+ ORDER = {PASS: 1, FAIL: 0, REVIEW: 2, NA: 3}
31
+
32
+
33
+ def audit(html: str):
34
+ css = "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", html, re.I | re.S))
35
+ doc = parse(html)
36
+ findings = []
37
+ for crew in CREWS:
38
+ findings += crew(doc, css, html).run()
39
+ return findings
40
+
41
+
42
+ def text_report(findings) -> int:
43
+ print("=" * 72)
44
+ print(" a11yhat - WCAG 2.2 A/AA machine (POUR)")
45
+ print("=" * 72)
46
+ fails = reviews = 0
47
+ for crew in CREWS:
48
+ pnum = crew.principle_num
49
+ rows = sorted([f for f in findings if f.sc[0] == pnum],
50
+ key=lambda f: (ORDER[f.status], f.sc))
51
+ head = {"1": "PERCEIVABLE", "2": "OPERABLE", "3": "UNDERSTANDABLE", "4": "ROBUST"}[pnum]
52
+ nf = sum(1 for f in rows if f.status == FAIL)
53
+ nc = sum(1 for f in rows if f.status == REVIEW)
54
+ print(f"\n[{pnum}] {head} — crew lead: {crew.__name__} "
55
+ f"({nf} fail, {nc} to check, {len(rows)} criteria)")
56
+ print("-" * 72)
57
+ for f in rows:
58
+ print(f" {MARK[f.status]} {f.sc:<6} {f.level:<2} {f.name}")
59
+ if f.measurement:
60
+ print(f" └ {f.measurement}")
61
+ for d in f.detail:
62
+ print(f" • {d}")
63
+ fails += nf
64
+ reviews += nc
65
+ total = len(findings)
66
+ print("\n" + "=" * 72)
67
+ verdict = "FAIL" if fails else ("NEEDS HUMAN REVIEW" if reviews else "PASS (automated)")
68
+ print(f" VERDICT: {verdict} — {fails} failing, {reviews} to confirm, "
69
+ f"{total} criteria across all four principles")
70
+ print("=" * 72)
71
+ return 1 if fails else 0
72
+
73
+
74
+ def main():
75
+ args = sys.argv[1:]
76
+ as_json = "--json" in args
77
+ args = [a for a in args if a != "--json"]
78
+ src = args[0] if args else "-"
79
+ html = sys.stdin.read() if src == "-" else open(src, encoding="utf-8", errors="replace").read()
80
+ findings = audit(html)
81
+ if as_json:
82
+ print(json.dumps([{"sc": f.sc, "level": f.level, "principle": f.principle,
83
+ "name": f.name, "topic": f.topic, "status": f.status,
84
+ "measurement": f.measurement, "detail": f.detail}
85
+ for f in findings], indent=1))
86
+ return 1 if any(f.status == FAIL for f in findings) else 0
87
+ return text_report(findings)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ sys.exit(main())
@@ -0,0 +1,98 @@
1
+ """
2
+ Understandable - WCAG Principle 3 crew lead. Content and operation must be
3
+ understandable. Language is checked deterministically; predictability and input
4
+ assistance are confirmed by a representative measurement (what forms/controls
5
+ exist, what is required) plus the human check each one needs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .base import FAIL, NA, PASS, REVIEW, Crew
12
+
13
+
14
+ class Understandable(Crew):
15
+ principle_num = "3"
16
+
17
+ # ---- 3.1.1 Language of page (Topic 11) ----
18
+ def sc_311_lang(self):
19
+ m = re.search(r'<html\b[^>]*\blang\s*=\s*["\']?([a-zA-Z][a-zA-Z-]*)', self.html, re.I)
20
+ self.record("3.1.1", "A", "Language of page", "Topic 11 Language",
21
+ PASS if m else FAIL,
22
+ ([] if m else ["<html> has no lang attribute; set e.g. lang=\"en\""]),
23
+ f'lang="{m.group(1) if m else "(none)"}"')
24
+
25
+ # ---- 3.1.2 Language of parts (Topic 11, AA) ----
26
+ def sc_312_langparts(self):
27
+ tagged = len(re.findall(r'<[a-z0-9]+[^>]*\blang\s*=', self.html, re.I)) - 1 # minus <html>
28
+ self.record("3.1.2", "AA", "Language of parts", "Topic 11 Language", REVIEW,
29
+ [f"confirm any passage in another language carries its own lang attribute "
30
+ f"({max(0, tagged)} non-root element(s) already tagged)"],
31
+ f"{max(0, tagged)} tagged parts")
32
+
33
+ # ---- 3.2.1 / 3.2.2 Predictable (on focus / on input) ----
34
+ def sc_321_onfocus(self):
35
+ risk = len(re.findall(r"onfocus\s*=|onchange\s*=\s*[\"'][^\"']*(submit|location|open)", self.html, re.I))
36
+ self.record("3.2.1", "A", "On focus", "Topic (predictable)",
37
+ REVIEW if risk else PASS,
38
+ (["focusing a control must not change context (submit, navigate, open a "
39
+ f"window); {risk} onfocus/onchange handler(s) to check"] if risk else []),
40
+ f"{risk} focus/change handler(s)")
41
+
42
+ def sc_322_oninput(self):
43
+ selects_submit = len(re.findall(r"onchange\s*=\s*[\"'][^\"']*(submit|location)", self.html, re.I))
44
+ self.record("3.2.2", "A", "On input", "Topic (predictable)",
45
+ FAIL if selects_submit else REVIEW,
46
+ ([f"{selects_submit} control(s) change context on input (auto-submit/navigate) "
47
+ "without warning"] if selects_submit
48
+ else ["confirm changing a setting does not auto-submit or navigate unexpectedly"]),
49
+ f"{selects_submit} auto-context-change")
50
+
51
+ # ---- 3.2.3 / 3.2.4 Consistent navigation & identification (AA) ----
52
+ def sc_323_consistency(self):
53
+ self.record("3.2.3", "AA", "Consistent navigation & identification",
54
+ "Topic (predictable)", REVIEW,
55
+ ["across the site, repeated navigation must keep the same order and repeated "
56
+ "components the same name (a multi-page check)"], "cross-page")
57
+
58
+ # ---- 3.3.1 Error identification (Topic 5) ----
59
+ def sc_331_errorid(self):
60
+ forms = self.doc.find("form")
61
+ required = [c for c in self.doc.find("input", "select", "textarea")
62
+ if c.has("required") or c.get("aria-required") == "true"]
63
+ if not forms and not required:
64
+ self.record("3.3.1", "A", "Error identification", "Topic 5 Forms", NA,
65
+ measurement="no forms")
66
+ return
67
+ self.record("3.3.1", "A", "Error identification", "Topic 5 Forms", REVIEW,
68
+ [f"{len(forms)} form(s), {len(required)} required field(s): submit with bad "
69
+ "input and confirm each error is named in text (not color alone)"],
70
+ f"{len(forms)} forms, {len(required)} required")
71
+
72
+ # ---- 3.3.2 Labels or instructions (Topic 5) ----
73
+ def sc_332_labels(self):
74
+ controls = [c for c in self.doc.find("input", "select", "textarea")
75
+ if c.get("type", "").lower() not in ("hidden", "submit", "button", "reset")]
76
+ labels_for = {l.get("for") for l in self.doc.find("label") if l.has("for")}
77
+ missing = [c for c in controls if not (
78
+ c.has("aria-label") or c.has("aria-labelledby") or c.has("title")
79
+ or c.has("placeholder") or c.get("id") in labels_for
80
+ or (c.parent and c.parent.tag == "label"))]
81
+ if not controls:
82
+ self.record("3.3.2", "A", "Labels or instructions", "Topic 5 Forms", NA,
83
+ measurement="no input controls")
84
+ return
85
+ self.record("3.3.2", "A", "Labels or instructions", "Topic 5 Forms",
86
+ FAIL if missing else PASS,
87
+ ([f"{len(missing)} control(s) with no visible label or instruction"] if missing
88
+ else []), f"{len(controls)} controls, {len(missing)} without label")
89
+
90
+ # ---- 3.3.3 / 3.3.4 Error suggestion & prevention (AA) ----
91
+ def sc_334_prevention(self):
92
+ legal = re.search(r"(password|credit\s*card|payment|purchase|checkout|delete|confirm)",
93
+ self.html, re.I)
94
+ self.record("3.3.4", "AA", "Error prevention (legal/financial)", "Topic 5 Forms", REVIEW,
95
+ ["for legal, financial, or data-deletion submissions, confirm the user can "
96
+ "review/reverse/confirm before committing"
97
+ + (" - this page mentions one" if legal else "")],
98
+ f"legal/financial context={bool(legal)}")
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: a11yhat
3
+ Version: 0.1.0
4
+ Summary: a11yhat: a WCAG 2.2 accessibility machine, structured by POUR — four crew leads, every success criterion a recorded check. A hardHats crew.
5
+ Author: Vincent Gonzalez
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vince-gonzalez/a11yhat
8
+ Project-URL: Repository, https://github.com/vince-gonzalez/a11yhat
9
+ Keywords: accessibility,wcag,a11y,section508,audit,pour
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # a11yhat
19
+
20
+ An open-source **WCAG 2.2 accessibility machine**, structured the way the work is
21
+ organized — by the four principles, **Perceivable · Operable · Understandable ·
22
+ Robust (POUR)**. One crew in the **hardHats** family (hardHats is the framework;
23
+ a11yhat is a crew built with it).
24
+
25
+ ## How it works
26
+ Each principle is a **crew lead** — a module that owns its success criteria. Every
27
+ applicable criterion gets a recorded result; nothing is silently skipped:
28
+
29
+ - **PASS** — checked and met.
30
+ - **FAIL** — checked and failed, with the specific elements.
31
+ - **CHECK** — a representative measurement ran; a human must confirm the rest
32
+ (keyboard operation, caption quality, reading order — anything a parser can't
33
+ judge alone). Each CHECK names the exact thing to look at.
34
+ - **NA** — not present on this page.
35
+
36
+ | Crew lead | Principle | Measured deterministically | Representative check + human confirm |
37
+ |---|---|---|---|
38
+ | `perceivable.py` | 1 | **contrast (1.4.3, luminance math)**, alt text (1.1.1), headings/labels/tables (1.3.1), autoplay (1.4.2), resize/zoom (1.4.4) | media captions (1.2.x), sensory wording (1.3.3), use of color (1.4.1), images of text (1.4.5) |
39
+ | `operable.py` | 2 | bypass (2.4.1), title (2.4.2), positive tabindex (2.4.3), link purpose (2.4.4), focus-visible suppression (2.4.7), mouse-only onclick (2.1.1) | keyboard operation, traps, timing, flashing, multiple ways |
40
+ | `understandable.py` | 3 | page language (3.1.1), auto-context-change on input (3.2.2), control labels (3.3.2) | language of parts, predictability, error identification/prevention |
41
+ | `robust.py` | 4 | duplicate ids (4.1.1), accessible name of every interactive element (4.1.2) | status-message live regions (4.1.3) |
42
+
43
+ Shared foundation: `dom.py` (a dependency-free DOM), `color.py` (WCAG luminance),
44
+ `base.py` (the uniform `Finding` record and the `Crew` base).
45
+
46
+ ## Install & run
47
+ ```
48
+ pip install a11yhat # once on PyPI
49
+ python -m a11yhat page.html # full POUR report, exit 1 on any FAIL
50
+ python -m a11yhat page.html --json
51
+ cat page.html | python -m a11yhat -
52
+ ```
53
+
54
+ ## Grounding
55
+ Criteria, topics, and pass/fail conditions follow the **DHS Trusted Tester for Web
56
+ (5.1.3)** process. Standard library only — nothing is installed into your
57
+ environment. Automated checks catch the machine-decidable failures; the CHECK
58
+ items are the ones a tester must confirm, each with the exact thing to review. It
59
+ is an honest tool: it does the fast 80% and tells you precisely what the human 20%
60
+ is — it does not pretend a scanner can certify a page.
61
+
62
+ ## License
63
+ MIT. Copyright Vincent Gonzalez / F-Keys Creative LLC.
64
+
65
+ ## Tests
66
+ `python -m unittest discover -s tests` — every deterministic check is proven to
67
+ catch its defect and to clear on good input, and a clean page produces zero
68
+ failures.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ a11yhat/__init__.py
5
+ a11yhat/__main__.py
6
+ a11yhat/base.py
7
+ a11yhat/color.py
8
+ a11yhat/dom.py
9
+ a11yhat/operable.py
10
+ a11yhat/perceivable.py
11
+ a11yhat/robust.py
12
+ a11yhat/run.py
13
+ a11yhat/understandable.py
14
+ a11yhat.egg-info/PKG-INFO
15
+ a11yhat.egg-info/SOURCES.txt
16
+ a11yhat.egg-info/dependency_links.txt
17
+ a11yhat.egg-info/entry_points.txt
18
+ a11yhat.egg-info/top_level.txt
19
+ tests/test_wcag.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ a11yhat = a11yhat.run:main
@@ -0,0 +1 @@
1
+ a11yhat
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "a11yhat"
7
+ version = "0.1.0"
8
+ description = "a11yhat: a WCAG 2.2 accessibility machine, structured by POUR — four crew leads, every success criterion a recorded check. A hardHats crew."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{ name = "Vincent Gonzalez" }]
12
+ keywords = ["accessibility", "wcag", "a11y", "section508", "audit", "pour"]
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Quality Assurance",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/vince-gonzalez/a11yhat"
24
+ Repository = "https://github.com/vince-gonzalez/a11yhat"
25
+
26
+ [project.scripts]
27
+ a11yhat = "a11yhat.run:main"
28
+
29
+ [tool.setuptools]
30
+ packages = ["a11yhat"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,105 @@
1
+ """
2
+ Self-test for the hardHats WCAG machine. Every deterministic check must catch its
3
+ defect (FAIL on bad input) AND clear on good input (PASS). A compliance tool that
4
+ cannot be shown to fail is not trustworthy.
5
+
6
+ python -m unittest discover -s tests
7
+ """
8
+ import unittest
9
+
10
+ from a11yhat.run import audit
11
+
12
+
13
+ def status(html, sc):
14
+ for f in audit(html):
15
+ if f.sc == sc:
16
+ return f.status
17
+ return None
18
+
19
+
20
+ WRAP = "<!doctype html><html lang='en'><head><title>T</title></head><body>{}</body></html>"
21
+
22
+
23
+ class DeterministicChecks(unittest.TestCase):
24
+
25
+ def one(self, sc, bad_body, good_body, bad="FAIL", good="PASS"):
26
+ self.assertEqual(status(WRAP.format(bad_body), sc), bad, f"{sc} should {bad} on bad input")
27
+ self.assertEqual(status(WRAP.format(good_body), sc), good, f"{sc} should {good} on good input")
28
+
29
+ def test_111_img_alt(self):
30
+ self.one("1.1.1", "<img src=a.png>", "<img src=a.png alt='A red barn at dusk'>")
31
+
32
+ def test_131_heading_skip(self):
33
+ self.one("1.3.1", "<h1>a</h1><h3>b</h3>", "<h1>a</h1><h2>b</h2>")
34
+
35
+ def test_143_contrast(self):
36
+ self.one("1.4.3",
37
+ "<p style='color:#777;background:#fff'>x</p>",
38
+ "<p style='color:#000;background:#fff'>x</p>")
39
+
40
+ def test_142_autoplay(self):
41
+ self.one("1.4.2", "<video autoplay src=v.mp4></video>", "<video src=v.mp4></video>")
42
+
43
+ def test_241_bypass(self):
44
+ self.one("2.4.1", "<nav>n</nav><p>x</p>", "<main><p>x</p></main>")
45
+
46
+ def test_242_title(self):
47
+ # title lives in <head>; test full documents here
48
+ self.assertEqual(status("<html lang=en><head></head><body>x</body></html>", "2.4.2"), "FAIL")
49
+ self.assertEqual(status("<html lang=en><head><title>Hi</title></head><body>x</body></html>",
50
+ "2.4.2"), "PASS")
51
+
52
+ def test_244_link_purpose(self):
53
+ self.one("2.4.4", "<a href=/x></a>", "<a href=/x>Annual accessibility report</a>")
54
+
55
+ def test_247_focus_visible(self):
56
+ self.one("2.4.7",
57
+ "<style>:focus{outline:none}</style><a href=/x>x</a>",
58
+ "<style>:focus{outline:2px solid blue}</style><a href=/x>x</a>")
59
+
60
+ def test_311_lang(self):
61
+ self.assertEqual(status("<html><head><title>t</title></head><body>x</body></html>",
62
+ "3.1.1"), "FAIL")
63
+ self.assertEqual(status("<html lang=en><head><title>t</title></head><body>x</body></html>",
64
+ "3.1.1"), "PASS")
65
+
66
+ def test_332_labels(self):
67
+ self.one("3.3.2",
68
+ "<form><input type=text name=q></form>",
69
+ "<form><label for=q>Search</label><input type=text id=q name=q></form>")
70
+
71
+ def test_411_dup_id(self):
72
+ self.one("4.1.1", "<span id=x></span><span id=x></span>", "<span id=x></span><span id=y></span>")
73
+
74
+ def test_412_name_role_value(self):
75
+ self.one("4.1.2", "<button></button>", "<button>Save</button>")
76
+
77
+
78
+ class CleanPagePasses(unittest.TestCase):
79
+ """A well-built page must produce zero FAIL findings."""
80
+
81
+ CLEAN = """<!doctype html><html lang="en"><head><title>Accessible page</title>
82
+ <style>body{color:#111;background:#fff} :focus{outline:2px solid #005fcc}</style></head>
83
+ <body>
84
+ <a href="#main">Skip to content</a>
85
+ <nav>Navigation</nav>
86
+ <main id="main">
87
+ <h1>Welcome</h1>
88
+ <h2>About</h2>
89
+ <img src="barn.jpg" alt="A red barn at dusk">
90
+ <form>
91
+ <label for="email">Email address</label>
92
+ <input type="text" id="email" name="email" required>
93
+ <button type="submit">Subscribe</button>
94
+ </form>
95
+ <a href="/report">Read the annual accessibility report</a>
96
+ </main>
97
+ </body></html>"""
98
+
99
+ def test_no_failures(self):
100
+ fails = [f.sc for f in audit(self.CLEAN) if f.status == "FAIL"]
101
+ self.assertEqual(fails, [], f"clean page should have no failures, got: {fails}")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()