@softspark/ai-toolkit 2.4.1 → 2.5.0
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.
- package/AGENTS.md +32 -19
- package/CHANGELOG.md +23 -0
- package/README.md +11 -11
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +6 -7
- package/app/agents/frontend-specialist.md +33 -2
- package/app/agents/seo-specialist.md +1 -1
- package/app/personas/frontend-lead.md +48 -5
- package/app/skills/a11y-validate/SKILL.md +377 -0
- package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
- package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
- package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
- package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
- package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
- package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
- package/app/skills/clean-code/reference/python.md +3 -3
- package/app/skills/design-engineering/SKILL.md +2 -5
- package/app/skills/review/SKILL.md +30 -6
- package/app/skills/seo-validate/SKILL.md +460 -0
- package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
- package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
- package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
- package/app/skills/seo-validate/reference/schema-types.md +465 -0
- package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
- package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
- package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
- package/bin/ai-toolkit.js +4 -4
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/comparison.md +1 -1
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +10 -6
- package/manifest.json +3 -3
- package/package.json +2 -2
- package/scripts/config_cli.py +4 -10
- package/scripts/doctor.py +3 -3
- package/scripts/install_steps/ai_tools.py +1 -1
- package/scripts/plugin.py +1 -1
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Accessibility scanner -- pattern-matching heuristics for WCAG violations.
|
|
3
|
+
|
|
4
|
+
Stdlib only. No external dependencies.
|
|
5
|
+
Scans HTML/JSX/TSX/Vue/Astro/Svelte files for common a11y issues across
|
|
6
|
+
10 check categories mapped to WCAG 2.1 success criteria.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Constants
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
FILE_GLOBS = ("*.html", "*.htm", "*.jsx", "*.tsx", "*.vue", "*.astro", "*.svelte")
|
|
21
|
+
|
|
22
|
+
SKIP_DIRS = {
|
|
23
|
+
"node_modules", "vendor", ".git", "dist", "build", "out", ".next",
|
|
24
|
+
".nuxt", ".svelte-kit", "__pycache__", ".venv", "venv", "coverage",
|
|
25
|
+
"ios", "android", ".dart_tool", "storybook-static",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
SKIP_FILES = {
|
|
29
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Valid ARIA attributes (subset covering the most common ones)
|
|
33
|
+
VALID_ARIA_ATTRS = {
|
|
34
|
+
"aria-activedescendant", "aria-atomic", "aria-autocomplete",
|
|
35
|
+
"aria-busy", "aria-checked", "aria-colcount", "aria-colindex",
|
|
36
|
+
"aria-colspan", "aria-controls", "aria-current", "aria-describedby",
|
|
37
|
+
"aria-details", "aria-disabled", "aria-dropeffect", "aria-errormessage",
|
|
38
|
+
"aria-expanded", "aria-flowto", "aria-grabbed", "aria-haspopup",
|
|
39
|
+
"aria-hidden", "aria-invalid", "aria-keyshortcuts", "aria-label",
|
|
40
|
+
"aria-labelledby", "aria-level", "aria-live", "aria-modal",
|
|
41
|
+
"aria-multiline", "aria-multiselectable", "aria-orientation",
|
|
42
|
+
"aria-owns", "aria-placeholder", "aria-posinset", "aria-pressed",
|
|
43
|
+
"aria-readonly", "aria-relevant", "aria-required", "aria-roledescription",
|
|
44
|
+
"aria-rowcount", "aria-rowindex", "aria-rowspan", "aria-selected",
|
|
45
|
+
"aria-setsize", "aria-sort", "aria-valuemax", "aria-valuemin",
|
|
46
|
+
"aria-valuenow", "aria-valuetext",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Redundant role mappings: element -> implicit role
|
|
50
|
+
REDUNDANT_ROLES = {
|
|
51
|
+
"button": "button",
|
|
52
|
+
"a": "link",
|
|
53
|
+
"nav": "navigation",
|
|
54
|
+
"main": "main",
|
|
55
|
+
"header": "banner",
|
|
56
|
+
"footer": "contentinfo",
|
|
57
|
+
"aside": "complementary",
|
|
58
|
+
"form": "form",
|
|
59
|
+
"table": "table",
|
|
60
|
+
"img": "img",
|
|
61
|
+
"input": "textbox",
|
|
62
|
+
"select": "listbox",
|
|
63
|
+
"textarea": "textbox",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Non-interactive elements that need keyboard handling when clickable
|
|
67
|
+
NON_INTERACTIVE = {"div", "span", "li", "td", "section", "article", "p"}
|
|
68
|
+
|
|
69
|
+
# Focusable elements
|
|
70
|
+
FOCUSABLE_ELEMENTS = {"a", "button", "input", "select", "textarea", "details", "summary"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# File collection
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def collect_files(scan_path: Path) -> list[Path]:
|
|
78
|
+
"""Collect all matching source files under scan_path."""
|
|
79
|
+
files: list[Path] = []
|
|
80
|
+
for dirpath, dirnames, filenames in os.walk(scan_path):
|
|
81
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
82
|
+
dp = Path(dirpath)
|
|
83
|
+
for fname in filenames:
|
|
84
|
+
fpath = dp / fname
|
|
85
|
+
if fpath.name in SKIP_FILES:
|
|
86
|
+
continue
|
|
87
|
+
if any(fpath.match(g) for g in FILE_GLOBS):
|
|
88
|
+
files.append(fpath)
|
|
89
|
+
return files
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Finding helper
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def finding(
|
|
97
|
+
severity: str,
|
|
98
|
+
category: str,
|
|
99
|
+
file: str,
|
|
100
|
+
line: int,
|
|
101
|
+
rule: str,
|
|
102
|
+
message: str,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""Create a standardized finding dict."""
|
|
105
|
+
return {
|
|
106
|
+
"severity": severity,
|
|
107
|
+
"category": category,
|
|
108
|
+
"file": file,
|
|
109
|
+
"line": line,
|
|
110
|
+
"rule": rule,
|
|
111
|
+
"message": message,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Check 1: Images
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
_RE_IMG_TAG = re.compile(r"<(?:img|Image)\b", re.IGNORECASE)
|
|
120
|
+
_RE_ALT_ATTR = re.compile(r"""\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})""")
|
|
121
|
+
_RE_ROLE_PRES = re.compile(r"""\brole\s*=\s*["'](?:presentation|none)["']""")
|
|
122
|
+
_RE_DECORATIVE_ALT = re.compile(r"""\balt\s*=\s*["']\s*["']""")
|
|
123
|
+
|
|
124
|
+
def check_images(lines: list[str], rel: str) -> list[dict]:
|
|
125
|
+
"""Check images for missing/empty alt, decorative without role."""
|
|
126
|
+
results: list[dict] = []
|
|
127
|
+
for i, line in enumerate(lines, 1):
|
|
128
|
+
if not _RE_IMG_TAG.search(line):
|
|
129
|
+
continue
|
|
130
|
+
alt_match = _RE_ALT_ATTR.search(line)
|
|
131
|
+
if not alt_match:
|
|
132
|
+
results.append(finding(
|
|
133
|
+
"HIGH", "images", rel, i, "1.1.1",
|
|
134
|
+
"Image missing alt attribute",
|
|
135
|
+
))
|
|
136
|
+
continue
|
|
137
|
+
alt_value = alt_match.group(1) or alt_match.group(2) or ""
|
|
138
|
+
if alt_value.strip() == "" and not _RE_ROLE_PRES.search(line):
|
|
139
|
+
if _RE_DECORATIVE_ALT.search(line):
|
|
140
|
+
results.append(finding(
|
|
141
|
+
"WARN", "images", rel, i, "1.1.1",
|
|
142
|
+
"Empty alt on image without role=\"presentation\" -- "
|
|
143
|
+
"add role if decorative, or provide descriptive alt",
|
|
144
|
+
))
|
|
145
|
+
return results
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Check 2: ARIA
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
_RE_ARIA_ATTR = re.compile(r"\b(aria-[\w-]+)\s*=")
|
|
153
|
+
_RE_ARIA_HIDDEN_TRUE = re.compile(r"""aria-hidden\s*=\s*["']true["']""")
|
|
154
|
+
_RE_ROLE_ATTR = re.compile(r"""\brole\s*=\s*["'](\w+)["']""")
|
|
155
|
+
_RE_OPEN_TAG = re.compile(r"<(\w+)\b")
|
|
156
|
+
|
|
157
|
+
def check_aria(lines: list[str], rel: str) -> list[dict]:
|
|
158
|
+
"""Check for invalid ARIA attrs, aria-hidden on focusable, redundant roles."""
|
|
159
|
+
results: list[dict] = []
|
|
160
|
+
for i, line in enumerate(lines, 1):
|
|
161
|
+
# Invalid aria-* attributes
|
|
162
|
+
for m in _RE_ARIA_ATTR.finditer(line):
|
|
163
|
+
attr = m.group(1).lower()
|
|
164
|
+
if attr.startswith("aria-") and attr not in VALID_ARIA_ATTRS:
|
|
165
|
+
results.append(finding(
|
|
166
|
+
"WARN", "aria", rel, i, "4.1.2",
|
|
167
|
+
f"Possibly invalid ARIA attribute: {attr}",
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
# aria-hidden="true" on focusable elements
|
|
171
|
+
if _RE_ARIA_HIDDEN_TRUE.search(line):
|
|
172
|
+
tag_match = _RE_OPEN_TAG.search(line)
|
|
173
|
+
if tag_match and tag_match.group(1).lower() in FOCUSABLE_ELEMENTS:
|
|
174
|
+
results.append(finding(
|
|
175
|
+
"HIGH", "aria", rel, i, "4.1.2",
|
|
176
|
+
"aria-hidden=\"true\" on focusable element creates "
|
|
177
|
+
"orphaned focus",
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
# Redundant ARIA roles
|
|
181
|
+
role_match = _RE_ROLE_ATTR.search(line)
|
|
182
|
+
tag_match = _RE_OPEN_TAG.search(line)
|
|
183
|
+
if role_match and tag_match:
|
|
184
|
+
tag = tag_match.group(1).lower()
|
|
185
|
+
role = role_match.group(1).lower()
|
|
186
|
+
if REDUNDANT_ROLES.get(tag) == role:
|
|
187
|
+
results.append(finding(
|
|
188
|
+
"WARN", "aria", rel, i, "4.1.2",
|
|
189
|
+
f"Redundant ARIA role=\"{role}\" on <{tag}> "
|
|
190
|
+
f"(implicit role is already \"{role}\")",
|
|
191
|
+
))
|
|
192
|
+
return results
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Check 3: Headings
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
_RE_HEADING = re.compile(r"<[hH]([1-6])\b")
|
|
200
|
+
|
|
201
|
+
def check_headings(lines: list[str], rel: str) -> list[dict]:
|
|
202
|
+
"""Check for skipped heading levels, missing h1, multiple h1."""
|
|
203
|
+
results: list[dict] = []
|
|
204
|
+
headings: list[tuple[int, int]] = [] # (level, line_number)
|
|
205
|
+
|
|
206
|
+
for i, line in enumerate(lines, 1):
|
|
207
|
+
for m in _RE_HEADING.finditer(line):
|
|
208
|
+
headings.append((int(m.group(1)), i))
|
|
209
|
+
|
|
210
|
+
if not headings:
|
|
211
|
+
return results
|
|
212
|
+
|
|
213
|
+
# Multiple h1
|
|
214
|
+
h1_lines = [ln for lvl, ln in headings if lvl == 1]
|
|
215
|
+
if len(h1_lines) > 1:
|
|
216
|
+
for ln in h1_lines[1:]:
|
|
217
|
+
results.append(finding(
|
|
218
|
+
"WARN", "headings", rel, ln, "1.3.1",
|
|
219
|
+
"Multiple <h1> elements detected -- page should have one",
|
|
220
|
+
))
|
|
221
|
+
|
|
222
|
+
# Missing h1
|
|
223
|
+
if not h1_lines:
|
|
224
|
+
results.append(finding(
|
|
225
|
+
"WARN", "headings", rel, headings[0][1], "1.3.1",
|
|
226
|
+
"No <h1> element found in file",
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
# Skipped heading levels
|
|
230
|
+
prev_level = 0
|
|
231
|
+
for level, ln in headings:
|
|
232
|
+
if prev_level > 0 and level > prev_level + 1:
|
|
233
|
+
results.append(finding(
|
|
234
|
+
"WARN", "headings", rel, ln, "1.3.1",
|
|
235
|
+
f"Heading level skipped: h{prev_level} -> h{level}",
|
|
236
|
+
))
|
|
237
|
+
prev_level = level
|
|
238
|
+
|
|
239
|
+
return results
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Check 4: Forms
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
_RE_INPUT = re.compile(
|
|
247
|
+
r"<(?:input|select|textarea)\b(?![^>]*type\s*=\s*[\"'](?:hidden|submit|button|reset|image)[\"'])",
|
|
248
|
+
re.IGNORECASE,
|
|
249
|
+
)
|
|
250
|
+
_RE_LABEL_ASSOC = re.compile(
|
|
251
|
+
r"""(?:\baria-label\s*=|\baria-labelledby\s*=|\bid\s*=)""",
|
|
252
|
+
)
|
|
253
|
+
_RE_RADIO = re.compile(r"""type\s*=\s*["']radio["']""", re.IGNORECASE)
|
|
254
|
+
_RE_FIELDSET = re.compile(r"<fieldset\b", re.IGNORECASE)
|
|
255
|
+
|
|
256
|
+
def check_forms(lines: list[str], rel: str) -> list[dict]:
|
|
257
|
+
"""Check inputs without labels, radio groups without fieldset/legend."""
|
|
258
|
+
results: list[dict] = []
|
|
259
|
+
has_radio = False
|
|
260
|
+
has_fieldset = False
|
|
261
|
+
|
|
262
|
+
for i, line in enumerate(lines, 1):
|
|
263
|
+
if _RE_INPUT.search(line):
|
|
264
|
+
if not _RE_LABEL_ASSOC.search(line):
|
|
265
|
+
results.append(finding(
|
|
266
|
+
"HIGH", "forms", rel, i, "3.3.2",
|
|
267
|
+
"Form input without associated label "
|
|
268
|
+
"(no id+for, no aria-label, no aria-labelledby)",
|
|
269
|
+
))
|
|
270
|
+
if _RE_RADIO.search(line):
|
|
271
|
+
has_radio = True
|
|
272
|
+
if _RE_FIELDSET.search(line):
|
|
273
|
+
has_fieldset = True
|
|
274
|
+
|
|
275
|
+
if has_radio and not has_fieldset:
|
|
276
|
+
results.append(finding(
|
|
277
|
+
"WARN", "forms", rel, 1, "1.3.1",
|
|
278
|
+
"Radio group detected without <fieldset>/<legend>",
|
|
279
|
+
))
|
|
280
|
+
|
|
281
|
+
return results
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Check 5: Keyboard
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
_RE_ONCLICK = re.compile(r"\bonClick\s*=", re.IGNORECASE)
|
|
289
|
+
_RE_ONKEY = re.compile(r"\bon(?:KeyDown|KeyPress|KeyUp)\s*=", re.IGNORECASE)
|
|
290
|
+
_RE_TABINDEX_POS = re.compile(r"""\btabindex\s*=\s*["']?(\d+)["']?""", re.IGNORECASE)
|
|
291
|
+
|
|
292
|
+
def check_keyboard(lines: list[str], rel: str) -> list[dict]:
|
|
293
|
+
"""Check onClick without onKeyDown on non-interactive, tabIndex > 0."""
|
|
294
|
+
results: list[dict] = []
|
|
295
|
+
for i, line in enumerate(lines, 1):
|
|
296
|
+
tag_match = _RE_OPEN_TAG.search(line)
|
|
297
|
+
tag_name = tag_match.group(1).lower() if tag_match else ""
|
|
298
|
+
|
|
299
|
+
# onClick on non-interactive without keyboard handler
|
|
300
|
+
if tag_name in NON_INTERACTIVE and _RE_ONCLICK.search(line):
|
|
301
|
+
if not _RE_ONKEY.search(line):
|
|
302
|
+
results.append(finding(
|
|
303
|
+
"HIGH", "keyboard", rel, i, "2.1.1",
|
|
304
|
+
f"onClick on <{tag_name}> without onKeyDown/onKeyPress "
|
|
305
|
+
f"-- not keyboard-accessible",
|
|
306
|
+
))
|
|
307
|
+
|
|
308
|
+
# tabIndex > 0
|
|
309
|
+
tabindex_match = _RE_TABINDEX_POS.search(line)
|
|
310
|
+
if tabindex_match:
|
|
311
|
+
val = int(tabindex_match.group(1))
|
|
312
|
+
if val > 0:
|
|
313
|
+
results.append(finding(
|
|
314
|
+
"HIGH", "keyboard", rel, i, "2.4.3",
|
|
315
|
+
f"tabIndex={val} breaks natural tab order -- use 0 or -1",
|
|
316
|
+
))
|
|
317
|
+
return results
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Check 6: Color/contrast (basic heuristic)
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
_RE_INLINE_COLOR = re.compile(
|
|
325
|
+
r"""style\s*=\s*["'][^"']*color\s*:\s*#([0-9a-fA-F]{3,8})[^"']*"""
|
|
326
|
+
r"""background(?:-color)?\s*:\s*#([0-9a-fA-F]{3,8})""",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
|
330
|
+
"""Convert 3/6-char hex to RGB tuple."""
|
|
331
|
+
if len(h) == 3:
|
|
332
|
+
h = h[0] * 2 + h[1] * 2 + h[2] * 2
|
|
333
|
+
h = h[:6]
|
|
334
|
+
return int(h[0:2], 16), int(h[1:4][:2], 16), int(h[2:6][:2], 16)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _relative_luminance(r: int, g: int, b: int) -> float:
|
|
338
|
+
"""Calculate relative luminance per WCAG formula."""
|
|
339
|
+
def linearize(c: int) -> float:
|
|
340
|
+
s = c / 255.0
|
|
341
|
+
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
|
342
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _contrast_ratio(hex_fg: str, hex_bg: str) -> float:
|
|
346
|
+
"""Compute contrast ratio between two hex colors."""
|
|
347
|
+
l1 = _relative_luminance(*_hex_to_rgb(hex_fg))
|
|
348
|
+
l2 = _relative_luminance(*_hex_to_rgb(hex_bg))
|
|
349
|
+
lighter = max(l1, l2)
|
|
350
|
+
darker = min(l1, l2)
|
|
351
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def check_color_contrast(lines: list[str], rel: str) -> list[dict]:
|
|
355
|
+
"""Detect inline color styles with insufficient contrast (heuristic)."""
|
|
356
|
+
results: list[dict] = []
|
|
357
|
+
for i, line in enumerate(lines, 1):
|
|
358
|
+
m = _RE_INLINE_COLOR.search(line)
|
|
359
|
+
if m:
|
|
360
|
+
fg_hex, bg_hex = m.group(1), m.group(2)
|
|
361
|
+
try:
|
|
362
|
+
ratio = _contrast_ratio(fg_hex, bg_hex)
|
|
363
|
+
if ratio < 4.5:
|
|
364
|
+
results.append(finding(
|
|
365
|
+
"WARN", "color-contrast", rel, i, "1.4.3",
|
|
366
|
+
f"Inline color contrast ratio ~{ratio:.1f}:1 "
|
|
367
|
+
f"(minimum 4.5:1 for normal text)",
|
|
368
|
+
))
|
|
369
|
+
except (ValueError, IndexError):
|
|
370
|
+
pass
|
|
371
|
+
return results
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Check 7: Focus
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
_RE_OUTLINE_NONE = re.compile(
|
|
379
|
+
r"outline\s*:\s*(?:none|0)\b", re.IGNORECASE,
|
|
380
|
+
)
|
|
381
|
+
_RE_FOCUS_VISIBLE = re.compile(r":focus-visible", re.IGNORECASE)
|
|
382
|
+
|
|
383
|
+
def check_focus(lines: list[str], rel: str) -> list[dict]:
|
|
384
|
+
"""Detect outline:none/0 without alternative focus indicator."""
|
|
385
|
+
results: list[dict] = []
|
|
386
|
+
content = "\n".join(lines)
|
|
387
|
+
has_focus_visible = bool(_RE_FOCUS_VISIBLE.search(content))
|
|
388
|
+
|
|
389
|
+
for i, line in enumerate(lines, 1):
|
|
390
|
+
if _RE_OUTLINE_NONE.search(line) and not has_focus_visible:
|
|
391
|
+
results.append(finding(
|
|
392
|
+
"HIGH", "focus", rel, i, "2.4.7",
|
|
393
|
+
"outline:none/0 without :focus-visible alternative "
|
|
394
|
+
"-- removes visible focus indicator",
|
|
395
|
+
))
|
|
396
|
+
return results
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# Check 8: Media
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
_RE_VIDEO = re.compile(r"<video\b", re.IGNORECASE)
|
|
404
|
+
_RE_AUDIO = re.compile(r"<audio\b", re.IGNORECASE)
|
|
405
|
+
_RE_TRACK = re.compile(r"<track\b", re.IGNORECASE)
|
|
406
|
+
_RE_AUTOPLAY = re.compile(r"\bautoplay\b", re.IGNORECASE)
|
|
407
|
+
_RE_MUTED = re.compile(r"\bmuted\b", re.IGNORECASE)
|
|
408
|
+
|
|
409
|
+
def check_media(lines: list[str], rel: str) -> list[dict]:
|
|
410
|
+
"""Check video/audio for missing tracks and autoplay without muted."""
|
|
411
|
+
results: list[dict] = []
|
|
412
|
+
content = "\n".join(lines)
|
|
413
|
+
has_track = bool(_RE_TRACK.search(content))
|
|
414
|
+
|
|
415
|
+
for i, line in enumerate(lines, 1):
|
|
416
|
+
if _RE_VIDEO.search(line):
|
|
417
|
+
if not has_track:
|
|
418
|
+
results.append(finding(
|
|
419
|
+
"HIGH", "media", rel, i, "1.2.2",
|
|
420
|
+
"Video element without <track> for captions",
|
|
421
|
+
))
|
|
422
|
+
if _RE_AUTOPLAY.search(line) and not _RE_MUTED.search(line):
|
|
423
|
+
results.append(finding(
|
|
424
|
+
"HIGH", "media", rel, i, "1.4.2",
|
|
425
|
+
"Video autoplay without muted attribute",
|
|
426
|
+
))
|
|
427
|
+
if _RE_AUDIO.search(line):
|
|
428
|
+
if not has_track:
|
|
429
|
+
results.append(finding(
|
|
430
|
+
"HIGH", "media", rel, i, "1.2.1",
|
|
431
|
+
"Audio element without <track> for captions/transcript",
|
|
432
|
+
))
|
|
433
|
+
if _RE_AUTOPLAY.search(line) and not _RE_MUTED.search(line):
|
|
434
|
+
results.append(finding(
|
|
435
|
+
"HIGH", "media", rel, i, "1.4.2",
|
|
436
|
+
"Audio autoplay without muted attribute",
|
|
437
|
+
))
|
|
438
|
+
return results
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# Check 9: Target size
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
_RE_WH_INLINE = re.compile(
|
|
446
|
+
r"""style\s*=\s*["'][^"']*(?:width|height)\s*:\s*(\d+)px""",
|
|
447
|
+
re.IGNORECASE,
|
|
448
|
+
)
|
|
449
|
+
_RE_CLICKABLE_TAG = re.compile(r"<(?:a|button|input)\b", re.IGNORECASE)
|
|
450
|
+
|
|
451
|
+
def check_target_size(lines: list[str], rel: str) -> list[dict]:
|
|
452
|
+
"""Detect very small clickable areas from inline styles."""
|
|
453
|
+
results: list[dict] = []
|
|
454
|
+
for i, line in enumerate(lines, 1):
|
|
455
|
+
if not _RE_CLICKABLE_TAG.search(line):
|
|
456
|
+
continue
|
|
457
|
+
sizes = _RE_WH_INLINE.findall(line)
|
|
458
|
+
for size_str in sizes:
|
|
459
|
+
px = int(size_str)
|
|
460
|
+
if 0 < px < 24:
|
|
461
|
+
results.append(finding(
|
|
462
|
+
"WARN", "target-size", rel, i, "2.5.8",
|
|
463
|
+
f"Clickable element with {px}px dimension "
|
|
464
|
+
f"(minimum 24x24px recommended)",
|
|
465
|
+
))
|
|
466
|
+
break
|
|
467
|
+
return results
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# Check 10: Language
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
_RE_HTML_TAG = re.compile(r"<html\b", re.IGNORECASE)
|
|
475
|
+
_RE_LANG_ATTR = re.compile(r"""\blang\s*=\s*["']""", re.IGNORECASE)
|
|
476
|
+
|
|
477
|
+
def check_language(lines: list[str], rel: str) -> list[dict]:
|
|
478
|
+
"""Check for missing lang attribute on <html> element."""
|
|
479
|
+
results: list[dict] = []
|
|
480
|
+
for i, line in enumerate(lines, 1):
|
|
481
|
+
if _RE_HTML_TAG.search(line) and not _RE_LANG_ATTR.search(line):
|
|
482
|
+
results.append(finding(
|
|
483
|
+
"HIGH", "language", rel, i, "3.1.1",
|
|
484
|
+
"Missing lang attribute on <html> element",
|
|
485
|
+
))
|
|
486
|
+
return results
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
# Scan orchestrator
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
ALL_CHECKS = [
|
|
494
|
+
check_images,
|
|
495
|
+
check_aria,
|
|
496
|
+
check_headings,
|
|
497
|
+
check_forms,
|
|
498
|
+
check_keyboard,
|
|
499
|
+
check_color_contrast,
|
|
500
|
+
check_focus,
|
|
501
|
+
check_media,
|
|
502
|
+
check_target_size,
|
|
503
|
+
check_language,
|
|
504
|
+
]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def scan_file(fpath: Path, root: Path) -> list[dict]:
|
|
508
|
+
"""Run all checks against a single file."""
|
|
509
|
+
try:
|
|
510
|
+
content = fpath.read_text(errors="replace")
|
|
511
|
+
except (OSError, PermissionError):
|
|
512
|
+
return []
|
|
513
|
+
|
|
514
|
+
lines = content.splitlines()
|
|
515
|
+
rel = str(fpath.relative_to(root))
|
|
516
|
+
results: list[dict] = []
|
|
517
|
+
|
|
518
|
+
for check_fn in ALL_CHECKS:
|
|
519
|
+
results.extend(check_fn(lines, rel))
|
|
520
|
+
|
|
521
|
+
return results
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ---------------------------------------------------------------------------
|
|
525
|
+
# Main
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
def main() -> None:
|
|
529
|
+
parser = argparse.ArgumentParser(
|
|
530
|
+
description="Accessibility scanner -- detect common a11y issues",
|
|
531
|
+
)
|
|
532
|
+
parser.add_argument(
|
|
533
|
+
"path", nargs="?", default=".",
|
|
534
|
+
help="Directory or file to scan (default: current directory)",
|
|
535
|
+
)
|
|
536
|
+
parser.add_argument(
|
|
537
|
+
"--output", choices=["json", "text"], default="json",
|
|
538
|
+
help="Output format (default: json)",
|
|
539
|
+
)
|
|
540
|
+
parser.add_argument(
|
|
541
|
+
"--severity", choices=["high", "warn", "info", "all"], default="all",
|
|
542
|
+
help="Minimum severity to report (default: all)",
|
|
543
|
+
)
|
|
544
|
+
args = parser.parse_args()
|
|
545
|
+
|
|
546
|
+
scan_path = Path(args.path).resolve()
|
|
547
|
+
if not scan_path.exists():
|
|
548
|
+
print(json.dumps({"error": f"Path does not exist: {args.path}"}))
|
|
549
|
+
sys.exit(2)
|
|
550
|
+
|
|
551
|
+
# Determine root for relative paths
|
|
552
|
+
root = scan_path if scan_path.is_dir() else scan_path.parent
|
|
553
|
+
|
|
554
|
+
# Collect files
|
|
555
|
+
if scan_path.is_file():
|
|
556
|
+
files = [scan_path] if any(scan_path.match(g) for g in FILE_GLOBS) else []
|
|
557
|
+
else:
|
|
558
|
+
files = collect_files(scan_path)
|
|
559
|
+
|
|
560
|
+
if not files:
|
|
561
|
+
report = {
|
|
562
|
+
"scan_path": str(args.path),
|
|
563
|
+
"files_scanned": 0,
|
|
564
|
+
"findings": [],
|
|
565
|
+
"summary": {"HIGH": 0, "WARN": 0, "INFO": 0},
|
|
566
|
+
}
|
|
567
|
+
print(json.dumps(report, indent=2))
|
|
568
|
+
sys.exit(0)
|
|
569
|
+
|
|
570
|
+
# Scan
|
|
571
|
+
all_findings: list[dict] = []
|
|
572
|
+
for fpath in files:
|
|
573
|
+
all_findings.extend(scan_file(fpath, root))
|
|
574
|
+
|
|
575
|
+
# Severity filter
|
|
576
|
+
if args.severity == "high":
|
|
577
|
+
all_findings = [f for f in all_findings if f["severity"] == "HIGH"]
|
|
578
|
+
elif args.severity == "warn":
|
|
579
|
+
all_findings = [f for f in all_findings if f["severity"] in ("HIGH", "WARN")]
|
|
580
|
+
|
|
581
|
+
# Deduplicate (same file+line+rule+message)
|
|
582
|
+
seen: set[tuple[str, int, str, str]] = set()
|
|
583
|
+
deduped: list[dict] = []
|
|
584
|
+
for f in all_findings:
|
|
585
|
+
key = (f["file"], f["line"], f["rule"], f["message"])
|
|
586
|
+
if key not in seen:
|
|
587
|
+
seen.add(key)
|
|
588
|
+
deduped.append(f)
|
|
589
|
+
all_findings = deduped
|
|
590
|
+
|
|
591
|
+
# Sort: HIGH first, then WARN, then INFO
|
|
592
|
+
sev_order = {"HIGH": 0, "WARN": 1, "INFO": 2}
|
|
593
|
+
all_findings.sort(key=lambda f: (sev_order.get(f["severity"], 9), f["file"], f["line"]))
|
|
594
|
+
|
|
595
|
+
# Summary counts
|
|
596
|
+
summary = {"HIGH": 0, "WARN": 0, "INFO": 0}
|
|
597
|
+
for f in all_findings:
|
|
598
|
+
sev = f["severity"]
|
|
599
|
+
summary[sev] = summary.get(sev, 0) + 1
|
|
600
|
+
|
|
601
|
+
report = {
|
|
602
|
+
"scan_path": str(args.path),
|
|
603
|
+
"files_scanned": len(files),
|
|
604
|
+
"findings": all_findings,
|
|
605
|
+
"summary": summary,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if args.output == "json":
|
|
609
|
+
print(json.dumps(report, indent=2))
|
|
610
|
+
else:
|
|
611
|
+
_print_text_report(report)
|
|
612
|
+
|
|
613
|
+
# Exit code: 1 if HIGH findings
|
|
614
|
+
sys.exit(1 if summary["HIGH"] > 0 else 0)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _print_text_report(report: dict) -> None:
|
|
618
|
+
"""Print a terminal-friendly text report."""
|
|
619
|
+
print(f"\n=== Accessibility Scan Report ===\n")
|
|
620
|
+
print(f"Path: {report['scan_path']}")
|
|
621
|
+
print(f"Files: {report['files_scanned']}")
|
|
622
|
+
s = report["summary"]
|
|
623
|
+
print(f"HIGH: {s['HIGH']} | WARN: {s['WARN']} | INFO: {s['INFO']}")
|
|
624
|
+
print()
|
|
625
|
+
|
|
626
|
+
if not report["findings"]:
|
|
627
|
+
print("No accessibility issues found.")
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
for f in report["findings"]:
|
|
631
|
+
print(f"[{f['severity']}] {f['file']}:{f['line']}")
|
|
632
|
+
print(f" Category: {f['category']}")
|
|
633
|
+
print(f" WCAG: {f['rule']}")
|
|
634
|
+
print(f" {f['message']}")
|
|
635
|
+
print()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
if __name__ == "__main__":
|
|
639
|
+
main()
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
## Type Hints (Required for Public APIs)
|
|
4
4
|
|
|
5
5
|
```python
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
def search(
|
|
9
9
|
query: str,
|
|
10
10
|
limit: int = 10,
|
|
11
|
-
filters:
|
|
12
|
-
) ->
|
|
11
|
+
filters: dict[str, str] | None = None,
|
|
12
|
+
) -> list[dict[str, Any]]:
|
|
13
13
|
"""Search the knowledge base.
|
|
14
14
|
|
|
15
15
|
Args:
|
|
@@ -138,14 +138,11 @@ Stack tab lists, clip the active copy, animate clip-path on change for seamless
|
|
|
138
138
|
```css
|
|
139
139
|
.delete-overlay {
|
|
140
140
|
clip-path: inset(0 100% 0 0);
|
|
141
|
-
transition: clip-path
|
|
141
|
+
transition: clip-path 200ms ease-out; /* fast snap-back on release */
|
|
142
142
|
}
|
|
143
143
|
.delete-button:active .delete-overlay {
|
|
144
144
|
clip-path: inset(0 0 0 0);
|
|
145
|
-
|
|
146
|
-
/* Release snaps back fast */
|
|
147
|
-
.delete-overlay {
|
|
148
|
-
transition: clip-path 200ms ease-out;
|
|
145
|
+
transition: clip-path 2s linear; /* slow fill while holding */
|
|
149
146
|
}
|
|
150
147
|
```
|
|
151
148
|
|