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 +21 -0
- a11yhat-0.1.0/PKG-INFO +68 -0
- a11yhat-0.1.0/README.md +51 -0
- a11yhat-0.1.0/a11yhat/__init__.py +0 -0
- a11yhat-0.1.0/a11yhat/__main__.py +6 -0
- a11yhat-0.1.0/a11yhat/base.py +53 -0
- a11yhat-0.1.0/a11yhat/color.py +42 -0
- a11yhat-0.1.0/a11yhat/dom.py +93 -0
- a11yhat-0.1.0/a11yhat/operable.py +164 -0
- a11yhat-0.1.0/a11yhat/perceivable.py +206 -0
- a11yhat-0.1.0/a11yhat/robust.py +70 -0
- a11yhat-0.1.0/a11yhat/run.py +91 -0
- a11yhat-0.1.0/a11yhat/understandable.py +98 -0
- a11yhat-0.1.0/a11yhat.egg-info/PKG-INFO +68 -0
- a11yhat-0.1.0/a11yhat.egg-info/SOURCES.txt +19 -0
- a11yhat-0.1.0/a11yhat.egg-info/dependency_links.txt +1 -0
- a11yhat-0.1.0/a11yhat.egg-info/entry_points.txt +2 -0
- a11yhat-0.1.0/a11yhat.egg-info/top_level.txt +1 -0
- a11yhat-0.1.0/pyproject.toml +30 -0
- a11yhat-0.1.0/setup.cfg +4 -0
- a11yhat-0.1.0/tests/test_wcag.py +105 -0
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.
|
a11yhat-0.1.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|
a11yhat-0.1.0/setup.cfg
ADDED
|
@@ -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()
|