@softspark/ai-toolkit 2.4.1 → 2.6.1

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.
Files changed (48) hide show
  1. package/AGENTS.md +33 -20
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +29 -13
  4. package/app/.claude-plugin/plugin.json +3 -2
  5. package/app/ARCHITECTURE.md +11 -0
  6. package/app/agents/code-reviewer.md +6 -7
  7. package/app/agents/frontend-specialist.md +33 -2
  8. package/app/agents/seo-specialist.md +1 -1
  9. package/app/personas/frontend-lead.md +48 -5
  10. package/app/skills/a11y-validate/SKILL.md +377 -0
  11. package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
  12. package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
  13. package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
  14. package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
  15. package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
  16. package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
  17. package/app/skills/clean-code/reference/python.md +3 -3
  18. package/app/skills/design-engineering/SKILL.md +2 -5
  19. package/app/skills/hipaa-validate/SKILL.md +39 -23
  20. package/app/skills/hipaa-validate/scripts/hipaa_scan.py +64 -7
  21. package/app/skills/review/SKILL.md +30 -6
  22. package/app/skills/seo-validate/SKILL.md +460 -0
  23. package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
  24. package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
  25. package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
  26. package/app/skills/seo-validate/reference/schema-types.md +465 -0
  27. package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
  28. package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
  29. package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
  30. package/bin/ai-toolkit.js +24 -9
  31. package/kb/reference/architecture-overview.md +1 -1
  32. package/kb/reference/comparison.md +1 -1
  33. package/kb/reference/opencode-compatibility.md +161 -0
  34. package/kb/reference/skills-catalog.md +3 -1
  35. package/llms-full.txt +177 -6
  36. package/llms.txt +1 -0
  37. package/manifest.json +3 -3
  38. package/package.json +6 -3
  39. package/scripts/config_cli.py +4 -10
  40. package/scripts/doctor.py +3 -3
  41. package/scripts/generate_opencode.py +117 -0
  42. package/scripts/generate_opencode_agents.py +126 -0
  43. package/scripts/generate_opencode_commands.py +158 -0
  44. package/scripts/generate_opencode_json.py +133 -0
  45. package/scripts/generate_opencode_plugin.py +169 -0
  46. package/scripts/install_steps/ai_tools.py +117 -1
  47. package/scripts/install_steps/install_state.py +1 -1
  48. 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 Optional, List, Dict
6
+ from typing import Any
7
7
 
8
8
  def search(
9
9
  query: str,
10
10
  limit: int = 10,
11
- filters: Optional[Dict[str, str]] = None
12
- ) -> List[Dict[str, any]]:
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 2s linear;
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