fix(a11y): WCAG 2.2 AA pass across all 45 docs/ pages (86 violations -> 0) (#40)
* chore(a11y): add WCAG 2.2 AA verification tooling and patcher
verify_a11y.mjs runs Playwright + axe-core against every docs/**/index.html
served from a local Python http.server, scans against WCAG 2/2.1/2.2 AA
tags, writes scan-results.json, and exits non-zero when any page has a
critical/serious/moderate violation. Doubles as a CI gate.
patch_a11y.py applies idempotent structural fixes across all pages:
skip link, main#main, nav aria-label, aria-hidden on inline svgs,
aria-label on icon-only links, rel=noopener on target=_blank, tabindex
on scrollable code blocks, prefers-reduced-motion CSS, focus-visible,
sr-only utility, hero-gradient ::before overlay (where applicable),
noscript fallback for JS-gated reveal sections, contrast fixes for
text-mist/text-clay/text-accent and bespoke pdf-playground / venmo /
banned-word color tweaks. Marker comments make it safe to re-run.
baseline-violations.json captures the pre-fix scan (86 serious violations
across 45 pages) as a historical reference.
* fix(a11y): WCAG 2.2 AA pass across all docs/ pages
Result: axe-core reports 0 violations across all 45 pages
(45 critical/serious/moderate/minor totals all zero), down from
86 serious violations in the baseline.
Per-page changes are produced by scripts/patch_a11y.py and include:
Structural
- skip link to #main, id="main" on main, aria-label="Primary" on nav
- aria-hidden + focusable=false on every inline decorative svg
- aria-label on icon-only links (GitHub icon was the main offender)
- rel="noopener noreferrer" on every target="_blank"
- tabindex="0" + role="region" on overflow-x-auto code blocks and <pre>
- noscript fallback for JS-gated .reveal-section opacity:0
(homepage, autocontext, superjawn) so content remains readable
if JS fails to run
Motion + focus
- prefers-reduced-motion media query disables animations/transitions
and forces .reveal-section visible
- :focus-visible outline standardized across pages
- .sr-only utility class added
Contrast (the bulk of the original violations)
- mist token #6b6b6b -> #525252 (homepage and Family A)
- text-mist/60|80 -> text-mist (full strength)
- text-clay/60 -> text-clay/80, text-clay/50 -> text-clay/80
- inline body links forced to #1e293b with underline
(overrides Tailwind text-accent/text-accentDark via class-token
selectors keyed off [class~="text-accent"])
- pill spans bg-accent/* + text-accent: text-accent -> text-ink
- bold accent labels: text-accent -> text-accentDark
- Dark-context override: anything with bg-ink / bg-zinc-9*/bg-gray-9*
/ text-white ancestor gets light text-accent (#ddd6fe)
- text-red-600 in <code> -> text-red-700
- ai-writing-detox .banned-word #ef4444 -> #b91c1c
- ai-writing-detox text-green-600 -> text-green-700
- Homepage footer: text-canvas/20|30 -> text-canvas/70
- Venmo support button bg #008CFF -> #006bbf
- Hero gradient ::before { rgba(0,0,0,0.18) } overlay on every
skill page using .hero-gradient (Joe-approved approach)
- pdf-playground mockup: opacity-60 -> opacity-90 on dimmed
feature-card so dimmed text still passes AA; bg-ink/20 text-ink
badge restyled to dark-bg + white text
- pdf-playground 2nd h1 (UI mockup) demoted to div role=heading
aria-level=2 to keep one h1 per page
- about page: explicit dark link override (it lacks accentDark token)
The patcher is idempotent (marker comments) and tracks each change
per file. Re-running on already-patched pages is a no-op.
Verification: node scripts/verify_a11y.mjs prints
pages scanned: 45
TOTAL | CRIT | SERIOUS | MOD | MINOR
0 | 0 | 0 | 0 | 0
exit 0
* fix(a11y): surface per-page scan errors and python3 spawn failures (PR #40 review)
Addresses two silent-failure bugs flagged in Copilot's review:
1. spawn('python3', ...) had no 'error' listener. ENOENT (python3 missing
from PATH) would terminate node with an unhandled error before the
try/catch in main() could run, skipping cleanup. Now caught with a
clear diagnostic, controlled cleanup, exit 2.
2. Per-page goto/axe failures were caught, recorded with violationCount=0
and zeros across byImpact, then ignored by the exit-code check. A run
that silently skipped half the pages would have exited 0 and passed CI.
Fix: track errored pages, surface them in stderr with paths + error
messages, add an ERR column to the per-page table, include error count
in the totals row, and exit 2 if any page errored. A clean a11y report
on the surviving pages is not a pass when we don't know about the rest.
Exit code semantics:
0 = all pages scanned, zero critical/serious/moderate violations
1 = all pages scanned, but at least one severe violation (real result)
2 = scan itself failed (spawn error, page error, fatal exception)
Verified:
- clean run: exit 0, totals "0 | 0 | 0 | 0 | 0 | 0"
- PATH=<no python3>: exit 2 with "fatal: failed to spawn python3 (ENOENT)"
---------
Co-authored-by: Joe Amditis <amditisj@mail.montclair.edu>
2026-05-06 22:28:43 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""WCAG 2.2 AA remediation patcher for docs/ static HTML pages.
|
|
|
|
|
|
|
|
|
|
Idempotent. Re-runnable. Prints a summary of files changed and changes per file.
|
|
|
|
|
|
|
|
|
|
Marker tags used to detect prior application:
|
|
|
|
|
- <!-- a11y-patch-v1 --> in <head>
|
|
|
|
|
- /* a11y-patch-v1 */ inside <style>
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
DOCS = ROOT / "docs"
|
|
|
|
|
|
|
|
|
|
HEAD_MARKER = "<!-- a11y-patch-v1 -->"
|
|
|
|
|
STYLE_MARKER = "/* a11y-patch-v1 */"
|
|
|
|
|
|
|
|
|
|
UNIVERSAL_CSS = """
|
|
|
|
|
""" + STYLE_MARKER + """
|
|
|
|
|
.skip-link { position: absolute; left: -9999px; top: 0; z-index: 10000; }
|
|
|
|
|
.skip-link:focus { left: 1rem; top: 1rem; padding: 0.75rem 1.25rem; background: #121212; color: #ede6d4; font-weight: 700; text-decoration: none; }
|
|
|
|
|
:focus-visible { outline: 2px solid #3d4b40; outline-offset: 4px; }
|
|
|
|
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
|
|
|
p a:not(.btn-primary):not(.btn-outline):not(.skill-card):not(.deckle-card):not(.feature-card),
|
|
|
|
|
li a:not(.btn-primary):not(.btn-outline),
|
|
|
|
|
.prose a {
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
text-underline-offset: 0.15em;
|
|
|
|
|
text-decoration-thickness: 1px;
|
|
|
|
|
}
|
|
|
|
|
a[class~="text-accent"]:not(.btn-primary):not(.btn-outline),
|
|
|
|
|
a[class~="text-accentDark"]:not(.btn-primary):not(.btn-outline),
|
|
|
|
|
a[class~="text-accentDeep"]:not(.btn-primary):not(.btn-outline) { color: #1e293b !important; }
|
|
|
|
|
a[class~="text-accent"]:not(.btn-primary):not(.btn-outline):hover,
|
|
|
|
|
a[class~="text-accentDark"]:not(.btn-primary):not(.btn-outline):hover { color: #0f172a !important; }
|
|
|
|
|
strong[class~="text-accent"], em[class~="text-accent"], code[class~="text-accent"],
|
|
|
|
|
strong[class~="text-accentDark"], em[class~="text-accentDark"] { color: #1e293b !important; }
|
|
|
|
|
.bg-canvas [class~="text-mist"], main [class~="text-mist"], [class~="text-mist"] { color: #3a3a3a !important; }
|
|
|
|
|
.bg-ink [class~="text-accent"], [class~="bg-ink"] [class~="text-accent"],
|
|
|
|
|
.bg-zinc-900 [class~="text-accent"], .bg-gray-900 [class~="text-accent"],
|
|
|
|
|
[class*="bg-zinc-9"] [class~="text-accent"], [class*="bg-gray-9"] [class~="text-accent"],
|
|
|
|
|
[class*="text-white"] [class~="text-accent"],
|
|
|
|
|
[style*="background:#1"] [class~="text-accent"], [style*="background-color:#1"] [class~="text-accent"] {
|
|
|
|
|
color: #ddd6fe !important;
|
|
|
|
|
}
|
|
|
|
|
.bg-ink [class~="text-mist"], [class~="bg-ink"] [class~="text-mist"],
|
|
|
|
|
[class*="text-white"] [class~="text-mist"] {
|
|
|
|
|
color: #d4d4d4 !important;
|
|
|
|
|
}
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
*, *::before, *::after {
|
|
|
|
|
animation-duration: 0.01ms !important;
|
|
|
|
|
animation-iteration-count: 1 !important;
|
|
|
|
|
transition-duration: 0.01ms !important;
|
|
|
|
|
scroll-behavior: auto !important;
|
|
|
|
|
}
|
|
|
|
|
.reveal-section, .reveal { opacity: 1 !important; transform: none !important; }
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
HERO_OVERLAY_CSS = """
|
|
|
|
|
.hero-gradient { position: relative; }
|
|
|
|
|
.hero-gradient::before {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
background: rgba(0, 0, 0, 0.18);
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
z-index: 1;
|
|
|
|
|
}
|
|
|
|
|
.hero-gradient > * { position: relative; z-index: 2; }
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
REVEAL_NOSCRIPT_CSS = """
|
|
|
|
|
.reveal-section, .reveal { opacity: 1; transform: none; }
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
SKIP_LINK_HTML = '<a href="#main" class="skip-link">Skip to main content</a>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PageReport:
|
|
|
|
|
__slots__ = ("path", "changes")
|
|
|
|
|
|
|
|
|
|
def __init__(self, path: Path):
|
|
|
|
|
self.path = path
|
|
|
|
|
self.changes: list[str] = []
|
|
|
|
|
|
|
|
|
|
def add(self, change: str) -> None:
|
|
|
|
|
self.changes.append(change)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_pages() -> list[Path]:
|
|
|
|
|
return sorted(p for p in DOCS.rglob("index.html"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def has_marker(html: str) -> bool:
|
|
|
|
|
return HEAD_MARKER in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_universal_css(html: str, report: PageReport) -> str:
|
|
|
|
|
if STYLE_MARKER in html:
|
|
|
|
|
return html
|
|
|
|
|
if "</style>" not in html:
|
|
|
|
|
# No <style>, inject one before </head>
|
|
|
|
|
block = "<style>" + UNIVERSAL_CSS + "</style>"
|
|
|
|
|
new = html.replace("</head>", block + "\n</head>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add("inject <style> + universal a11y CSS")
|
|
|
|
|
return new
|
|
|
|
|
new = html.replace("</style>", UNIVERSAL_CSS + "</style>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add("inject universal a11y CSS into existing <style>")
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_hero_overlay(html: str, report: PageReport) -> str:
|
|
|
|
|
if "hero-gradient" not in html:
|
|
|
|
|
return html
|
|
|
|
|
if "hero-gradient::before" in html:
|
|
|
|
|
return html
|
|
|
|
|
if "</style>" not in html:
|
|
|
|
|
return html
|
|
|
|
|
new = html.replace("</style>", HERO_OVERLAY_CSS + "</style>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add("add hero-gradient ::before dark overlay")
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_reveal_noscript(html: str, report: PageReport) -> str:
|
Shorten skill and plugin descriptions, remove em-dashes (#277)
* Shorten skill and plugin descriptions, remove em-dashes
Trim every SKILL.md description to <=130 characters (longest is now 130,
aggregate 7,464 across all 62 skills) so the full set stays under Codex's
skills-metadata budget and stops the overflow warnings on load. Descriptions
stay third-person and front-load their trigger keywords so discovery is
unaffected. Also shorten the 12 plugin.json and matching marketplace.json
blurbs (journalism-core dropped from 1,036 to 378 chars).
Replace em-dashes with commas repo-wide (2,558 lines across 228 files),
following the repo's own ai-writing-detox guidance. The demonstrative
em-dashes that teach the pattern in ai-writing-detox and ai-slop-detector
are preserved; a quote attribution keeps an ASCII hyphen.
All 175 tests pass; validate:agent-skills reports 62 skills, 0 failed.
* Add changelog entry for description and em-dash cleanup
* Fix review: restore table markers, attributions, photo-metadata triggers
The blanket em-dash-to-comma pass corrupted three non-prose uses of the
em-dash that a comma cannot represent:
- Table "not applicable" cells (photo-metadata SKILL.md and reference.md,
the Brazil recurso-cgu template, and the photo-metadata and
accessibility-compliance docs pages) became a lone comma. Restored them
to a hyphen marker: 47 markdown cells and 9 HTML cells.
- Pull-quote and slide citation prefixes in the pdf-playground report and
slides templates became a leading comma. Restored the ASCII attribution
hyphen.
- The photo-metadata description dropped its distinctive safety triggers.
Reworded to keep GPS stripping, C2PA credentials, and AI-source labeling
alongside IPTC/EXIF/XMP, still within 130 characters.
npm test 175/175, validate:agent-skills 62/62, check:docs-css verified.
* Bump plugin versions, keep visual-explainer triggers, log superjawn divergence
Address the remaining review findings:
- Patch-bump all 12 plugins in plugin.json and marketplace.json (and the
okf-wiki and visual-explainer root skill version fields) so existing
installs are offered the shortened descriptions. Update the version-
alignment guard tests and the journalism-core install canary accordingly.
- Rework the visual-explainer description to keep its distinctive workflow
triggers (timelines, source maps, slide decks, comparison tables, recaps,
plan and diff reviews) within the 130-character cap.
- Record the superjawn upstream divergence: drop skill_md_parity to false
for the five skills whose SKILL.md changed, add supporting_file_overrides
for the touched supporting files, and document it in superjawn/CREDITS.md.
All 175 node tests pass; validate:agent-skills 62/62; check:docs-css verified;
longest description 130 chars, aggregate 7,472 across 62 skills.
* Rewrite CCM brand em-dash rule to name commas as the replacement
The repo-wide em-dash pass stripped the glyph from the CCM brand rule's
example, leaving 'Prefer em dashes ()' which was both malformed and
contradicted the new no-em-dash policy. Rewrite it to prefer commas.
* Fix parser regression, restore leading-comma sentences, keep more triggers
Fourth review round:
- The repo-wide em-dash sweep rewrote a functional delimiter in
autocontext/scripts/seed-from-claude-md.py: is_command_reference matched
"/cmd — description" via " — ", and changing it to ", " both stopped
recognizing external CLAUDE.md command docs (which still use em-dashes) and
misclassified any slash-prefixed rule containing a comma. Restored the
em-dash delimiter (a functional exception, like the ai-writing-detox
teaching examples) and added a regression-guard test for the case the
original suite never covered.
- Fixed the sentence-leading commas the sweep left on wrapped lines in
okf-wiki/SKILL.md, academic-writing, and the okf example photo-metadata
bundle by moving the comma to the end of the preceding line.
- Reworked the web-scraping and interview-prep descriptions to keep their
distinctive triggers (social media, yt-dlp, CAPTCHA/403 for web-scraping;
recording, consent, one-party/all-party law for interview-prep).
Verification: node 175/175, autocontext pytest 26/26 (incl. new guard),
validate:agent-skills 62/62. Longest description 130, aggregate 7,489.
* Fix exported-quote attribution and keep academic-writing compliance triggers
- The em-dash sweep turned the speaker attribution in interview-transcription's
export_for_story() into a leading comma, so every publication-ready quote
exported as ", Speaker Name". Restored the ASCII attribution hyphen.
- Reworked the academic-writing description to keep its distinctive compliance
triggers (CRediT, preregistration, Plan S, Nelson Memo, ORCID, LLM
disclosure) within the 130-character cap.
* Restore distinctive discovery triggers in four descriptions
Codex's review keeps surfacing skills whose shortened description dropped a
distinctive trigger that maps to dedicated, consequential guidance in the body
(which cannot load if the skill is not selected). Restore those triggers within
the 130-character cap:
- one-way-door: add event schemas and CI/CD (both are explicit one-way-door
categories with their own contract and deployment-risk checks).
- api-hardening: name Express, FastAPI, and serverless (framework-specific
hardening sections).
- web-archiving: name the Wayback Machine and Archive.today (how users
actually ask for this).
- secure-auth: name OWASP and NIST.
node 175/175, validate:agent-skills 62/62; longest description 130 chars.
* Fix CRediT taxonomy and doc-span corruption, restore four more triggers
The em-dash sweep corrupted two constructs where the dash carried meaning:
- The CRediT taxonomy in academic-writing listed "Writing — original draft"
and "Writing — review & editing" as two of its 14 roles; the comma swap made
them read as four separate roles. Restored the hyphen in both role names.
- Seven label/value rows in docs/superjawn/index.html (and one dir-tree
annotation in visual-explainer css-patterns) put the separator at the start
of the second span, rendering "Web , error string searches". Moved the
separator onto the label (colon) / used a hyphen annotation.
Also restored distinctive discovery triggers flagged by review:
- python-pipeline: Google Sheets/Drive integration
- page-monitoring: RSS feed generation
- zero-build-frontend: Manifest V3 browser extensions
- pdf-design: secure upload
node 175/175, pytest 304/304, validate:agent-skills 62/62, check:docs-css.
* Sync displayed versions, fix pitch contrast, restore three triggers
- Version bumps: update the displayed plugin versions that trailed the manifest
bumps: CLAUDE.md inventory (journalism-core, research-toolkit, video-toolkit,
pdf-playground, superjawn) and the docs badges in docs/index.html,
docs/superjawn, and docs/video-toolkit, plus the video-toolkit docs test guard.
- story-pitch: the em-dash sweep flattened "background checks - through a
spreadsheet" into a comma, losing the contrast in the model pitch. Restored
it with an explicit "but".
- Restore triggers: ai-writing-detox (writing/drafting, not just editing),
brainstorming (MUST + modifying behavior), okf-wiki (builds from existing
docs/notes/repo, the input flow, not "from concept files").
node 175/175, validate:agent-skills 62/62, check:docs-css.
* Restore four more discovery triggers
- context-engineering-fundamentals: lost-instructions / dropped-evidence
- vibe-coding: name Copilot, Codex, Aider, Windsurf (not just Claude Code/Cursor)
- video-transcribe: audio-only (WAV, podcasts), not just video
- zero-build-frontend: Google Sheets as a database
node 175/175, validate:agent-skills 62/62.
2026-08-18 07:49:38 -04:00
|
|
|
# Detect via JS pattern, not CSS selector, our own injected CSS contains .reveal-section.
|
fix(a11y): WCAG 2.2 AA pass across all 45 docs/ pages (86 violations -> 0) (#40)
* chore(a11y): add WCAG 2.2 AA verification tooling and patcher
verify_a11y.mjs runs Playwright + axe-core against every docs/**/index.html
served from a local Python http.server, scans against WCAG 2/2.1/2.2 AA
tags, writes scan-results.json, and exits non-zero when any page has a
critical/serious/moderate violation. Doubles as a CI gate.
patch_a11y.py applies idempotent structural fixes across all pages:
skip link, main#main, nav aria-label, aria-hidden on inline svgs,
aria-label on icon-only links, rel=noopener on target=_blank, tabindex
on scrollable code blocks, prefers-reduced-motion CSS, focus-visible,
sr-only utility, hero-gradient ::before overlay (where applicable),
noscript fallback for JS-gated reveal sections, contrast fixes for
text-mist/text-clay/text-accent and bespoke pdf-playground / venmo /
banned-word color tweaks. Marker comments make it safe to re-run.
baseline-violations.json captures the pre-fix scan (86 serious violations
across 45 pages) as a historical reference.
* fix(a11y): WCAG 2.2 AA pass across all docs/ pages
Result: axe-core reports 0 violations across all 45 pages
(45 critical/serious/moderate/minor totals all zero), down from
86 serious violations in the baseline.
Per-page changes are produced by scripts/patch_a11y.py and include:
Structural
- skip link to #main, id="main" on main, aria-label="Primary" on nav
- aria-hidden + focusable=false on every inline decorative svg
- aria-label on icon-only links (GitHub icon was the main offender)
- rel="noopener noreferrer" on every target="_blank"
- tabindex="0" + role="region" on overflow-x-auto code blocks and <pre>
- noscript fallback for JS-gated .reveal-section opacity:0
(homepage, autocontext, superjawn) so content remains readable
if JS fails to run
Motion + focus
- prefers-reduced-motion media query disables animations/transitions
and forces .reveal-section visible
- :focus-visible outline standardized across pages
- .sr-only utility class added
Contrast (the bulk of the original violations)
- mist token #6b6b6b -> #525252 (homepage and Family A)
- text-mist/60|80 -> text-mist (full strength)
- text-clay/60 -> text-clay/80, text-clay/50 -> text-clay/80
- inline body links forced to #1e293b with underline
(overrides Tailwind text-accent/text-accentDark via class-token
selectors keyed off [class~="text-accent"])
- pill spans bg-accent/* + text-accent: text-accent -> text-ink
- bold accent labels: text-accent -> text-accentDark
- Dark-context override: anything with bg-ink / bg-zinc-9*/bg-gray-9*
/ text-white ancestor gets light text-accent (#ddd6fe)
- text-red-600 in <code> -> text-red-700
- ai-writing-detox .banned-word #ef4444 -> #b91c1c
- ai-writing-detox text-green-600 -> text-green-700
- Homepage footer: text-canvas/20|30 -> text-canvas/70
- Venmo support button bg #008CFF -> #006bbf
- Hero gradient ::before { rgba(0,0,0,0.18) } overlay on every
skill page using .hero-gradient (Joe-approved approach)
- pdf-playground mockup: opacity-60 -> opacity-90 on dimmed
feature-card so dimmed text still passes AA; bg-ink/20 text-ink
badge restyled to dark-bg + white text
- pdf-playground 2nd h1 (UI mockup) demoted to div role=heading
aria-level=2 to keep one h1 per page
- about page: explicit dark link override (it lacks accentDark token)
The patcher is idempotent (marker comments) and tracks each change
per file. Re-running on already-patched pages is a no-op.
Verification: node scripts/verify_a11y.mjs prints
pages scanned: 45
TOTAL | CRIT | SERIOUS | MOD | MINOR
0 | 0 | 0 | 0 | 0
exit 0
* fix(a11y): surface per-page scan errors and python3 spawn failures (PR #40 review)
Addresses two silent-failure bugs flagged in Copilot's review:
1. spawn('python3', ...) had no 'error' listener. ENOENT (python3 missing
from PATH) would terminate node with an unhandled error before the
try/catch in main() could run, skipping cleanup. Now caught with a
clear diagnostic, controlled cleanup, exit 2.
2. Per-page goto/axe failures were caught, recorded with violationCount=0
and zeros across byImpact, then ignored by the exit-code check. A run
that silently skipped half the pages would have exited 0 and passed CI.
Fix: track errored pages, surface them in stderr with paths + error
messages, add an ERR column to the per-page table, include error count
in the totals row, and exit 2 if any page errored. A clean a11y report
on the surviving pages is not a pass when we don't know about the rest.
Exit code semantics:
0 = all pages scanned, zero critical/serious/moderate violations
1 = all pages scanned, but at least one severe violation (real result)
2 = scan itself failed (spawn error, page error, fatal exception)
Verified:
- clean run: exit 0, totals "0 | 0 | 0 | 0 | 0 | 0"
- PATH=<no python3>: exit 2 with "fatal: failed to spawn python3 (ENOENT)"
---------
Co-authored-by: Joe Amditis <amditisj@mail.montclair.edu>
2026-05-06 22:28:43 -04:00
|
|
|
if "IntersectionObserver" not in html and ".reveal-section.visible" not in html:
|
|
|
|
|
return html
|
|
|
|
|
if "noscript-reveal-fallback" in html:
|
|
|
|
|
return html
|
|
|
|
|
block = (
|
|
|
|
|
'<noscript id="noscript-reveal-fallback">'
|
|
|
|
|
'<style>' + REVEAL_NOSCRIPT_CSS + '</style>'
|
|
|
|
|
'</noscript>'
|
|
|
|
|
)
|
|
|
|
|
new = html.replace("</head>", block + "\n</head>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add("add <noscript> fallback for opacity:0 reveal sections")
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_skip_link(html: str, report: PageReport) -> str:
|
|
|
|
|
if 'class="skip-link"' in html or 'href="#main"' in html:
|
|
|
|
|
return html
|
|
|
|
|
new = re.sub(
|
|
|
|
|
r"(<body[^>]*>)",
|
|
|
|
|
lambda m: m.group(1) + "\n " + SKIP_LINK_HTML,
|
|
|
|
|
html,
|
|
|
|
|
count=1,
|
|
|
|
|
)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add("add skip link after <body>")
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_main_id(html: str, report: PageReport) -> str:
|
|
|
|
|
if re.search(r'<main[^>]*\bid\s*=', html):
|
|
|
|
|
return html
|
|
|
|
|
new = re.sub(
|
|
|
|
|
r"<main(\s|>)",
|
|
|
|
|
lambda m: '<main id="main"' + m.group(1),
|
|
|
|
|
html,
|
|
|
|
|
count=1,
|
|
|
|
|
)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('add id="main" to <main>')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_aria_hidden_to_svgs(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r"<svg(?![^>]*\baria-hidden=)(?![^>]*\brole\s*=\s*[\"']img[\"'])([^>]*)>")
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
count += 1
|
|
|
|
|
attrs = m.group(1)
|
|
|
|
|
if "focusable=" in attrs:
|
|
|
|
|
return f'<svg aria-hidden="true"{attrs}>'
|
|
|
|
|
return f'<svg aria-hidden="true" focusable="false"{attrs}>'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'add aria-hidden + focusable=false to {count} inline svg')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_rel_noopener(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r"<a\b([^>]*?\btarget\s*=\s*[\"']_blank[\"'][^>]*)>")
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
attrs = m.group(1)
|
|
|
|
|
rel_match = re.search(r'\brel\s*=\s*[\"\']([^\"\']*)[\"\']', attrs)
|
|
|
|
|
if rel_match:
|
|
|
|
|
rel_val = rel_match.group(1)
|
|
|
|
|
tokens = set(rel_val.split())
|
|
|
|
|
if "noopener" in tokens and "noreferrer" in tokens:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
tokens.update({"noopener", "noreferrer"})
|
|
|
|
|
new_rel = " ".join(sorted(tokens))
|
|
|
|
|
count += 1
|
|
|
|
|
new_attrs = attrs[: rel_match.start()] + f'rel="{new_rel}"' + attrs[rel_match.end():]
|
|
|
|
|
return f'<a{new_attrs}>'
|
|
|
|
|
count += 1
|
|
|
|
|
return f'<a{attrs} rel="noopener noreferrer">'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'add rel="noopener noreferrer" to {count} target=_blank link')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_aria_label_to_icon_links(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Find <a> elements containing only an <svg> (no visible text) and no aria-label.
|
|
|
|
|
|
|
|
|
|
Heuristic: anchors whose entire content is whitespace + one <svg>.
|
|
|
|
|
Add aria-label based on href hint (github -> GitHub, twitter -> Twitter, etc.).
|
|
|
|
|
"""
|
|
|
|
|
anchor_re = re.compile(
|
|
|
|
|
r'(<a\b([^>]*?)>)([\s\S]*?)(</a>)',
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
count = 0
|
|
|
|
|
label_map = [
|
|
|
|
|
("github.com", "GitHub"),
|
|
|
|
|
("twitter.com", "Twitter"),
|
|
|
|
|
("x.com", "X (Twitter)"),
|
|
|
|
|
("linkedin.com", "LinkedIn"),
|
|
|
|
|
("mastodon", "Mastodon"),
|
|
|
|
|
("bsky.app", "Bluesky"),
|
|
|
|
|
("youtube.com", "YouTube"),
|
|
|
|
|
("instagram.com", "Instagram"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
open_tag, attrs, body, close_tag = m.group(1), m.group(2), m.group(3), m.group(4)
|
|
|
|
|
if "aria-label=" in attrs:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
text_only = re.sub(r"<[^>]+>", "", body)
|
|
|
|
|
text_only = re.sub(r"&[#\w]+;", "", text_only).strip()
|
|
|
|
|
if text_only:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
if "<svg" not in body and "data-lucide" not in body:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
href_match = re.search(r'href\s*=\s*[\"\']([^\"\']+)[\"\']', attrs)
|
|
|
|
|
if not href_match:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
href = href_match.group(1).lower()
|
|
|
|
|
label = None
|
|
|
|
|
for needle, candidate in label_map:
|
|
|
|
|
if needle in href:
|
|
|
|
|
label = candidate
|
|
|
|
|
break
|
|
|
|
|
if not label:
|
|
|
|
|
label = "External link"
|
|
|
|
|
count += 1
|
|
|
|
|
new_attrs = f' aria-label="{label}"' + attrs
|
|
|
|
|
return f'<a{new_attrs}>{body}{close_tag}'
|
|
|
|
|
|
|
|
|
|
new = anchor_re.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'add aria-label to {count} icon-only link')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_tabindex_to_scrollable(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(
|
|
|
|
|
r'(<(?:div|pre)\b[^>]*\bclass\s*=\s*[\"\'][^\"\']*overflow-x-auto[^\"\']*[\"\'][^>]*)>'
|
|
|
|
|
)
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
attrs = m.group(1)
|
|
|
|
|
if "tabindex=" in attrs or "role=" in attrs:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
count += 1
|
|
|
|
|
return attrs + ' tabindex="0" role="region" aria-label="Scrollable content">'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'add tabindex=0 + role=region to {count} scrollable container')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_text_clay_60(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r'\btext-clay/60\b')
|
|
|
|
|
count = len(pattern.findall(html))
|
|
|
|
|
if not count:
|
|
|
|
|
return html
|
|
|
|
|
new = pattern.sub('text-clay/80', html)
|
|
|
|
|
report.add(f'bump text-clay/60 -> text-clay/80 ({count} occurrences)')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_text_clay_50(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r'\btext-clay/50\b')
|
|
|
|
|
count = len(pattern.findall(html))
|
|
|
|
|
if not count:
|
|
|
|
|
return html
|
|
|
|
|
new = pattern.sub('text-clay/80', html)
|
|
|
|
|
report.add(f'bump text-clay/50 -> text-clay/80 ({count} occurrences)')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_tabindex_to_pre(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r'<pre\b((?![^>]*\btabindex=)[^>]*)>')
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
attrs = m.group(1)
|
|
|
|
|
count += 1
|
|
|
|
|
return f'<pre tabindex="0"{attrs}>'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'add tabindex=0 to {count} <pre> block')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def swap_link_accent_to_dark(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Swap text-accent -> text-accentDark on <a> tags (only if accentDark exists in tailwind config)."""
|
|
|
|
|
if "accentDark" not in html:
|
|
|
|
|
return html
|
|
|
|
|
pattern = re.compile(
|
|
|
|
|
r'(<a\b[^>]*\bclass\s*=\s*[\"\'])([^\"\']*\btext-accent\b[^\"\']*)([\"\'])'
|
|
|
|
|
)
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
prefix, classes, suffix = m.group(1), m.group(2), m.group(3)
|
|
|
|
|
# Skip only if BASE text-accentDark exists (not hover:text-accentDark variant)
|
|
|
|
|
if re.search(r'(?<![\w:-])text-accentDark\b', classes):
|
|
|
|
|
return m.group(0)
|
|
|
|
|
new_classes = re.sub(r'(?<![\w:-])text-accent\b', 'text-accentDark', classes)
|
|
|
|
|
if new_classes == classes:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
count += 1
|
|
|
|
|
return prefix + new_classes + suffix
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'swap text-accent -> text-accentDark on {count} <a> tag')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_pill_text_accent(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Tag pills like <span class="bg-accent/10 text-accent ...">label</span> have
|
|
|
|
|
accent-on-tinted-accent (too low contrast). Swap text-accent -> text-ink so
|
|
|
|
|
pill text becomes dark on the lightly-tinted background.
|
|
|
|
|
"""
|
|
|
|
|
pattern = re.compile(
|
|
|
|
|
r'(<span\b[^>]*\bclass\s*=\s*[\"\'])([^\"\']*\bbg-accent\b[^\"\']*\btext-accent\b[^\"\']*|[^\"\']*\btext-accent\b[^\"\']*\bbg-accent\b[^\"\']*)([\"\'])'
|
|
|
|
|
)
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
prefix, classes, suffix = m.group(1), m.group(2), m.group(3)
|
|
|
|
|
new_classes = re.sub(r'(?<![\w:-])text-accent\b', 'text-ink', classes)
|
|
|
|
|
if new_classes == classes:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
count += 1
|
|
|
|
|
return prefix + new_classes + suffix
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'pill text-accent -> text-ink on {count} <span> with bg-accent tint')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_text_mist_60(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r'\btext-mist/(60|80)\b')
|
|
|
|
|
matches = pattern.findall(html)
|
|
|
|
|
if not matches:
|
|
|
|
|
return html
|
|
|
|
|
new = pattern.sub('text-mist', html)
|
|
|
|
|
report.add(f'bump text-mist/60|80 -> text-mist ({len(matches)} occurrences)')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_stat_accent_on_dark(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Stat cards like <p class="text-5xl ... text-accent ..."> on bg-ink fail.
|
|
|
|
|
|
|
|
|
|
Inject a CSS override that bumps text-accent to a lighter shade specifically
|
|
|
|
|
when its parent has bg-ink or similar dark background. Simpler: target the
|
|
|
|
|
pattern directly via class.
|
|
|
|
|
"""
|
|
|
|
|
if "text-5xl" not in html or "text-accent" not in html:
|
|
|
|
|
return html
|
|
|
|
|
if "/* stat-on-dark-fix */" in html:
|
|
|
|
|
return html
|
|
|
|
|
block = """
|
|
|
|
|
/* stat-on-dark-fix */
|
|
|
|
|
.bg-ink .text-accent, .bg-zinc-900 .text-accent, .bg-gray-900 .text-accent,
|
|
|
|
|
.bg-ink .text-accentDark, .bg-zinc-900 .text-accentDark, .bg-gray-900 .text-accentDark {
|
|
|
|
|
filter: brightness(1.6) saturate(0.85);
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
if "</style>" not in html:
|
|
|
|
|
return html
|
|
|
|
|
new = html.replace("</style>", block + "</style>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('add stat-on-dark accent brightness fix')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_green_600(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r'\btext-green-600\b')
|
|
|
|
|
count = len(pattern.findall(html))
|
|
|
|
|
if not count:
|
|
|
|
|
return html
|
|
|
|
|
new = pattern.sub('text-green-700', html)
|
|
|
|
|
report.add(f'bump text-green-600 -> text-green-700 ({count})')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_code_red_600(html: str, report: PageReport) -> str:
|
|
|
|
|
"""text-red-600 inside <code> on light bg fails contrast. Bump to text-red-700."""
|
|
|
|
|
pattern = re.compile(r'(<code\b[^>]*\bclass\s*=\s*[\"\'][^\"\']*)\btext-red-600\b')
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
count += 1
|
|
|
|
|
return m.group(1) + 'text-red-700'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'<code> text-red-600 -> text-red-700 ({count})')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_terminal_mockup_opacity(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Persistent-sessions has a dark terminal mockup. text-white/40 fails on bg-zinc-900-ish."""
|
|
|
|
|
if "text-white/40" not in html:
|
|
|
|
|
return html
|
|
|
|
|
new = html.replace("text-white/40", "text-white/70")
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('terminal mockup: text-white/40 -> text-white/70')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_banned_word_color(html: str, report: PageReport) -> str:
|
|
|
|
|
"""ai-writing-detox: .banned-word red #ef4444 on #fef2f2 fails contrast (3.44:1).
|
|
|
|
|
|
|
|
|
|
Swap to a darker red that passes 4.5:1 against the same pink background.
|
|
|
|
|
"""
|
|
|
|
|
if ".banned-word" not in html:
|
|
|
|
|
return html
|
|
|
|
|
if "/* banned-word-fix */" in html:
|
|
|
|
|
return html
|
|
|
|
|
pattern = re.compile(r'(\.banned-word\s*\{[^}]*color\s*:\s*)#ef4444', re.IGNORECASE)
|
|
|
|
|
if not pattern.search(html):
|
|
|
|
|
return html
|
|
|
|
|
new = pattern.sub(r'\g<1>#b91c1c /* banned-word-fix */', html)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('banned-word color: #ef4444 -> #b91c1c (passes AA)')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_venmo_button(html: str, report: PageReport) -> str:
|
|
|
|
|
if ".s-venmo{background:#008CFF}" not in html and ".s-venmo{background:#008cff}" not in html:
|
|
|
|
|
return html
|
|
|
|
|
new = html.replace(".s-venmo{background:#008CFF}", ".s-venmo{background:#006bbf}")
|
|
|
|
|
new = new.replace(".s-venmo{background:#008cff}", ".s-venmo{background:#006bbf}")
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('Venmo support button: bg #008CFF -> #006bbf (passes AA)')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_about_page_links(html: str, report: PageReport) -> str:
|
|
|
|
|
"""about/index.html has no accentDark token. Add CSS override for prose links to a darker accent."""
|
|
|
|
|
if 'accent' not in html or 'accentDark' in html:
|
|
|
|
|
return html
|
|
|
|
|
if '/* about-link-fix */' in html:
|
|
|
|
|
return html
|
|
|
|
|
block = """
|
|
|
|
|
/* about-link-fix */
|
|
|
|
|
main p a, main li a { color: #155e75; text-decoration: underline; text-underline-offset: 0.15em; }
|
|
|
|
|
"""
|
|
|
|
|
if "</style>" not in html:
|
|
|
|
|
return html
|
|
|
|
|
new = html.replace("</style>", block + "</style>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('about-page: add darker link color override')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_text_accent_bold(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Bold accent text on white cards fails AA (#0891b2 ~3.4:1).
|
|
|
|
|
|
|
|
|
|
Only target <span> elements with class containing both 'text-accent' and 'font-bold'.
|
|
|
|
|
Replace text-accent -> text-accentDark on those spans only.
|
|
|
|
|
"""
|
|
|
|
|
pattern = re.compile(
|
|
|
|
|
r'(<span\b[^>]*\bclass\s*=\s*[\"\'])([^\"\']*\btext-accent\b[^\"\']*\bfont-bold\b[^\"\']*|[^\"\']*\bfont-bold\b[^\"\']*\btext-accent\b[^\"\']*)([\"\'])'
|
|
|
|
|
)
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
prefix, classes, suffix = m.group(1), m.group(2), m.group(3)
|
|
|
|
|
if "text-accentDark" in classes:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
new_classes = re.sub(r'\btext-accent\b', 'text-accentDark', classes)
|
|
|
|
|
count += 1
|
|
|
|
|
return prefix + new_classes + suffix
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'swap text-accent -> text-accentDark on {count} bold label')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_text_mist_token(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Replace mist token color #6b6b6b with #525252 in tailwind config block.
|
|
|
|
|
|
|
|
|
|
Only affects pages that define a tailwind theme.colors.mist override.
|
|
|
|
|
"""
|
|
|
|
|
pattern = re.compile(r"(mist\s*:\s*['\"])#6b6b6b(['\"])")
|
|
|
|
|
if not pattern.search(html):
|
|
|
|
|
return html
|
|
|
|
|
new = pattern.sub(r"\g<1>#525252\g<2>", html)
|
|
|
|
|
report.add('update mist token #6b6b6b -> #525252')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_homepage_footer_canvas_opacity(html: str, report: PageReport) -> str:
|
|
|
|
|
"""Homepage footer uses text-canvas/20 and text-canvas/30 on bg-ink.
|
|
|
|
|
|
|
|
|
|
Composite contrast ~1.5:1. Bump to /60 (~3.6:1, passes AA large) and /70.
|
Shorten skill and plugin descriptions, remove em-dashes (#277)
* Shorten skill and plugin descriptions, remove em-dashes
Trim every SKILL.md description to <=130 characters (longest is now 130,
aggregate 7,464 across all 62 skills) so the full set stays under Codex's
skills-metadata budget and stops the overflow warnings on load. Descriptions
stay third-person and front-load their trigger keywords so discovery is
unaffected. Also shorten the 12 plugin.json and matching marketplace.json
blurbs (journalism-core dropped from 1,036 to 378 chars).
Replace em-dashes with commas repo-wide (2,558 lines across 228 files),
following the repo's own ai-writing-detox guidance. The demonstrative
em-dashes that teach the pattern in ai-writing-detox and ai-slop-detector
are preserved; a quote attribution keeps an ASCII hyphen.
All 175 tests pass; validate:agent-skills reports 62 skills, 0 failed.
* Add changelog entry for description and em-dash cleanup
* Fix review: restore table markers, attributions, photo-metadata triggers
The blanket em-dash-to-comma pass corrupted three non-prose uses of the
em-dash that a comma cannot represent:
- Table "not applicable" cells (photo-metadata SKILL.md and reference.md,
the Brazil recurso-cgu template, and the photo-metadata and
accessibility-compliance docs pages) became a lone comma. Restored them
to a hyphen marker: 47 markdown cells and 9 HTML cells.
- Pull-quote and slide citation prefixes in the pdf-playground report and
slides templates became a leading comma. Restored the ASCII attribution
hyphen.
- The photo-metadata description dropped its distinctive safety triggers.
Reworded to keep GPS stripping, C2PA credentials, and AI-source labeling
alongside IPTC/EXIF/XMP, still within 130 characters.
npm test 175/175, validate:agent-skills 62/62, check:docs-css verified.
* Bump plugin versions, keep visual-explainer triggers, log superjawn divergence
Address the remaining review findings:
- Patch-bump all 12 plugins in plugin.json and marketplace.json (and the
okf-wiki and visual-explainer root skill version fields) so existing
installs are offered the shortened descriptions. Update the version-
alignment guard tests and the journalism-core install canary accordingly.
- Rework the visual-explainer description to keep its distinctive workflow
triggers (timelines, source maps, slide decks, comparison tables, recaps,
plan and diff reviews) within the 130-character cap.
- Record the superjawn upstream divergence: drop skill_md_parity to false
for the five skills whose SKILL.md changed, add supporting_file_overrides
for the touched supporting files, and document it in superjawn/CREDITS.md.
All 175 node tests pass; validate:agent-skills 62/62; check:docs-css verified;
longest description 130 chars, aggregate 7,472 across 62 skills.
* Rewrite CCM brand em-dash rule to name commas as the replacement
The repo-wide em-dash pass stripped the glyph from the CCM brand rule's
example, leaving 'Prefer em dashes ()' which was both malformed and
contradicted the new no-em-dash policy. Rewrite it to prefer commas.
* Fix parser regression, restore leading-comma sentences, keep more triggers
Fourth review round:
- The repo-wide em-dash sweep rewrote a functional delimiter in
autocontext/scripts/seed-from-claude-md.py: is_command_reference matched
"/cmd — description" via " — ", and changing it to ", " both stopped
recognizing external CLAUDE.md command docs (which still use em-dashes) and
misclassified any slash-prefixed rule containing a comma. Restored the
em-dash delimiter (a functional exception, like the ai-writing-detox
teaching examples) and added a regression-guard test for the case the
original suite never covered.
- Fixed the sentence-leading commas the sweep left on wrapped lines in
okf-wiki/SKILL.md, academic-writing, and the okf example photo-metadata
bundle by moving the comma to the end of the preceding line.
- Reworked the web-scraping and interview-prep descriptions to keep their
distinctive triggers (social media, yt-dlp, CAPTCHA/403 for web-scraping;
recording, consent, one-party/all-party law for interview-prep).
Verification: node 175/175, autocontext pytest 26/26 (incl. new guard),
validate:agent-skills 62/62. Longest description 130, aggregate 7,489.
* Fix exported-quote attribution and keep academic-writing compliance triggers
- The em-dash sweep turned the speaker attribution in interview-transcription's
export_for_story() into a leading comma, so every publication-ready quote
exported as ", Speaker Name". Restored the ASCII attribution hyphen.
- Reworked the academic-writing description to keep its distinctive compliance
triggers (CRediT, preregistration, Plan S, Nelson Memo, ORCID, LLM
disclosure) within the 130-character cap.
* Restore distinctive discovery triggers in four descriptions
Codex's review keeps surfacing skills whose shortened description dropped a
distinctive trigger that maps to dedicated, consequential guidance in the body
(which cannot load if the skill is not selected). Restore those triggers within
the 130-character cap:
- one-way-door: add event schemas and CI/CD (both are explicit one-way-door
categories with their own contract and deployment-risk checks).
- api-hardening: name Express, FastAPI, and serverless (framework-specific
hardening sections).
- web-archiving: name the Wayback Machine and Archive.today (how users
actually ask for this).
- secure-auth: name OWASP and NIST.
node 175/175, validate:agent-skills 62/62; longest description 130 chars.
* Fix CRediT taxonomy and doc-span corruption, restore four more triggers
The em-dash sweep corrupted two constructs where the dash carried meaning:
- The CRediT taxonomy in academic-writing listed "Writing — original draft"
and "Writing — review & editing" as two of its 14 roles; the comma swap made
them read as four separate roles. Restored the hyphen in both role names.
- Seven label/value rows in docs/superjawn/index.html (and one dir-tree
annotation in visual-explainer css-patterns) put the separator at the start
of the second span, rendering "Web , error string searches". Moved the
separator onto the label (colon) / used a hyphen annotation.
Also restored distinctive discovery triggers flagged by review:
- python-pipeline: Google Sheets/Drive integration
- page-monitoring: RSS feed generation
- zero-build-frontend: Manifest V3 browser extensions
- pdf-design: secure upload
node 175/175, pytest 304/304, validate:agent-skills 62/62, check:docs-css.
* Sync displayed versions, fix pitch contrast, restore three triggers
- Version bumps: update the displayed plugin versions that trailed the manifest
bumps: CLAUDE.md inventory (journalism-core, research-toolkit, video-toolkit,
pdf-playground, superjawn) and the docs badges in docs/index.html,
docs/superjawn, and docs/video-toolkit, plus the video-toolkit docs test guard.
- story-pitch: the em-dash sweep flattened "background checks - through a
spreadsheet" into a comma, losing the contrast in the model pitch. Restored
it with an explicit "but".
- Restore triggers: ai-writing-detox (writing/drafting, not just editing),
brainstorming (MUST + modifying behavior), okf-wiki (builds from existing
docs/notes/repo, the input flow, not "from concept files").
node 175/175, validate:agent-skills 62/62, check:docs-css.
* Restore four more discovery triggers
- context-engineering-fundamentals: lost-instructions / dropped-evidence
- vibe-coding: name Copilot, Codex, Aider, Windsurf (not just Claude Code/Cursor)
- video-transcribe: audio-only (WAV, podcasts), not just video
- zero-build-frontend: Google Sheets as a database
node 175/175, validate:agent-skills 62/62.
2026-08-18 07:49:38 -04:00
|
|
|
The h6 labels are 10px tracked-letterspaced uppercase, visually small but
|
fix(a11y): WCAG 2.2 AA pass across all 45 docs/ pages (86 violations -> 0) (#40)
* chore(a11y): add WCAG 2.2 AA verification tooling and patcher
verify_a11y.mjs runs Playwright + axe-core against every docs/**/index.html
served from a local Python http.server, scans against WCAG 2/2.1/2.2 AA
tags, writes scan-results.json, and exits non-zero when any page has a
critical/serious/moderate violation. Doubles as a CI gate.
patch_a11y.py applies idempotent structural fixes across all pages:
skip link, main#main, nav aria-label, aria-hidden on inline svgs,
aria-label on icon-only links, rel=noopener on target=_blank, tabindex
on scrollable code blocks, prefers-reduced-motion CSS, focus-visible,
sr-only utility, hero-gradient ::before overlay (where applicable),
noscript fallback for JS-gated reveal sections, contrast fixes for
text-mist/text-clay/text-accent and bespoke pdf-playground / venmo /
banned-word color tweaks. Marker comments make it safe to re-run.
baseline-violations.json captures the pre-fix scan (86 serious violations
across 45 pages) as a historical reference.
* fix(a11y): WCAG 2.2 AA pass across all docs/ pages
Result: axe-core reports 0 violations across all 45 pages
(45 critical/serious/moderate/minor totals all zero), down from
86 serious violations in the baseline.
Per-page changes are produced by scripts/patch_a11y.py and include:
Structural
- skip link to #main, id="main" on main, aria-label="Primary" on nav
- aria-hidden + focusable=false on every inline decorative svg
- aria-label on icon-only links (GitHub icon was the main offender)
- rel="noopener noreferrer" on every target="_blank"
- tabindex="0" + role="region" on overflow-x-auto code blocks and <pre>
- noscript fallback for JS-gated .reveal-section opacity:0
(homepage, autocontext, superjawn) so content remains readable
if JS fails to run
Motion + focus
- prefers-reduced-motion media query disables animations/transitions
and forces .reveal-section visible
- :focus-visible outline standardized across pages
- .sr-only utility class added
Contrast (the bulk of the original violations)
- mist token #6b6b6b -> #525252 (homepage and Family A)
- text-mist/60|80 -> text-mist (full strength)
- text-clay/60 -> text-clay/80, text-clay/50 -> text-clay/80
- inline body links forced to #1e293b with underline
(overrides Tailwind text-accent/text-accentDark via class-token
selectors keyed off [class~="text-accent"])
- pill spans bg-accent/* + text-accent: text-accent -> text-ink
- bold accent labels: text-accent -> text-accentDark
- Dark-context override: anything with bg-ink / bg-zinc-9*/bg-gray-9*
/ text-white ancestor gets light text-accent (#ddd6fe)
- text-red-600 in <code> -> text-red-700
- ai-writing-detox .banned-word #ef4444 -> #b91c1c
- ai-writing-detox text-green-600 -> text-green-700
- Homepage footer: text-canvas/20|30 -> text-canvas/70
- Venmo support button bg #008CFF -> #006bbf
- Hero gradient ::before { rgba(0,0,0,0.18) } overlay on every
skill page using .hero-gradient (Joe-approved approach)
- pdf-playground mockup: opacity-60 -> opacity-90 on dimmed
feature-card so dimmed text still passes AA; bg-ink/20 text-ink
badge restyled to dark-bg + white text
- pdf-playground 2nd h1 (UI mockup) demoted to div role=heading
aria-level=2 to keep one h1 per page
- about page: explicit dark link override (it lacks accentDark token)
The patcher is idempotent (marker comments) and tracks each change
per file. Re-running on already-patched pages is a no-op.
Verification: node scripts/verify_a11y.mjs prints
pages scanned: 45
TOTAL | CRIT | SERIOUS | MOD | MINOR
0 | 0 | 0 | 0 | 0
exit 0
* fix(a11y): surface per-page scan errors and python3 spawn failures (PR #40 review)
Addresses two silent-failure bugs flagged in Copilot's review:
1. spawn('python3', ...) had no 'error' listener. ENOENT (python3 missing
from PATH) would terminate node with an unhandled error before the
try/catch in main() could run, skipping cleanup. Now caught with a
clear diagnostic, controlled cleanup, exit 2.
2. Per-page goto/axe failures were caught, recorded with violationCount=0
and zeros across byImpact, then ignored by the exit-code check. A run
that silently skipped half the pages would have exited 0 and passed CI.
Fix: track errored pages, surface them in stderr with paths + error
messages, add an ERR column to the per-page table, include error count
in the totals row, and exit 2 if any page errored. A clean a11y report
on the surviving pages is not a pass when we don't know about the rest.
Exit code semantics:
0 = all pages scanned, zero critical/serious/moderate violations
1 = all pages scanned, but at least one severe violation (real result)
2 = scan itself failed (spawn error, page error, fatal exception)
Verified:
- clean run: exit 0, totals "0 | 0 | 0 | 0 | 0 | 0"
- PATH=<no python3>: exit 2 with "fatal: failed to spawn python3 (ENOENT)"
---------
Co-authored-by: Joe Amditis <amditisj@mail.montclair.edu>
2026-05-06 22:28:43 -04:00
|
|
|
semantically headings. Bumping to /70 gives ~5:1 which passes AA normal.
|
|
|
|
|
"""
|
|
|
|
|
changed = False
|
|
|
|
|
new = html
|
|
|
|
|
if "text-canvas/20" in new:
|
|
|
|
|
new = new.replace('text-canvas/20', 'text-canvas/70')
|
|
|
|
|
changed = True
|
|
|
|
|
if "text-canvas/30" in new:
|
|
|
|
|
new = new.replace('text-canvas/30', 'text-canvas/70')
|
|
|
|
|
changed = True
|
|
|
|
|
if changed:
|
|
|
|
|
report.add('homepage footer: text-canvas/20|30 -> text-canvas/70')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_pdf_playground_opacity_60(html: str, report: PageReport) -> str:
|
|
|
|
|
"""opacity-60 on mockup feature cards dims everything below contrast threshold.
|
|
|
|
|
|
Shorten skill and plugin descriptions, remove em-dashes (#277)
* Shorten skill and plugin descriptions, remove em-dashes
Trim every SKILL.md description to <=130 characters (longest is now 130,
aggregate 7,464 across all 62 skills) so the full set stays under Codex's
skills-metadata budget and stops the overflow warnings on load. Descriptions
stay third-person and front-load their trigger keywords so discovery is
unaffected. Also shorten the 12 plugin.json and matching marketplace.json
blurbs (journalism-core dropped from 1,036 to 378 chars).
Replace em-dashes with commas repo-wide (2,558 lines across 228 files),
following the repo's own ai-writing-detox guidance. The demonstrative
em-dashes that teach the pattern in ai-writing-detox and ai-slop-detector
are preserved; a quote attribution keeps an ASCII hyphen.
All 175 tests pass; validate:agent-skills reports 62 skills, 0 failed.
* Add changelog entry for description and em-dash cleanup
* Fix review: restore table markers, attributions, photo-metadata triggers
The blanket em-dash-to-comma pass corrupted three non-prose uses of the
em-dash that a comma cannot represent:
- Table "not applicable" cells (photo-metadata SKILL.md and reference.md,
the Brazil recurso-cgu template, and the photo-metadata and
accessibility-compliance docs pages) became a lone comma. Restored them
to a hyphen marker: 47 markdown cells and 9 HTML cells.
- Pull-quote and slide citation prefixes in the pdf-playground report and
slides templates became a leading comma. Restored the ASCII attribution
hyphen.
- The photo-metadata description dropped its distinctive safety triggers.
Reworded to keep GPS stripping, C2PA credentials, and AI-source labeling
alongside IPTC/EXIF/XMP, still within 130 characters.
npm test 175/175, validate:agent-skills 62/62, check:docs-css verified.
* Bump plugin versions, keep visual-explainer triggers, log superjawn divergence
Address the remaining review findings:
- Patch-bump all 12 plugins in plugin.json and marketplace.json (and the
okf-wiki and visual-explainer root skill version fields) so existing
installs are offered the shortened descriptions. Update the version-
alignment guard tests and the journalism-core install canary accordingly.
- Rework the visual-explainer description to keep its distinctive workflow
triggers (timelines, source maps, slide decks, comparison tables, recaps,
plan and diff reviews) within the 130-character cap.
- Record the superjawn upstream divergence: drop skill_md_parity to false
for the five skills whose SKILL.md changed, add supporting_file_overrides
for the touched supporting files, and document it in superjawn/CREDITS.md.
All 175 node tests pass; validate:agent-skills 62/62; check:docs-css verified;
longest description 130 chars, aggregate 7,472 across 62 skills.
* Rewrite CCM brand em-dash rule to name commas as the replacement
The repo-wide em-dash pass stripped the glyph from the CCM brand rule's
example, leaving 'Prefer em dashes ()' which was both malformed and
contradicted the new no-em-dash policy. Rewrite it to prefer commas.
* Fix parser regression, restore leading-comma sentences, keep more triggers
Fourth review round:
- The repo-wide em-dash sweep rewrote a functional delimiter in
autocontext/scripts/seed-from-claude-md.py: is_command_reference matched
"/cmd — description" via " — ", and changing it to ", " both stopped
recognizing external CLAUDE.md command docs (which still use em-dashes) and
misclassified any slash-prefixed rule containing a comma. Restored the
em-dash delimiter (a functional exception, like the ai-writing-detox
teaching examples) and added a regression-guard test for the case the
original suite never covered.
- Fixed the sentence-leading commas the sweep left on wrapped lines in
okf-wiki/SKILL.md, academic-writing, and the okf example photo-metadata
bundle by moving the comma to the end of the preceding line.
- Reworked the web-scraping and interview-prep descriptions to keep their
distinctive triggers (social media, yt-dlp, CAPTCHA/403 for web-scraping;
recording, consent, one-party/all-party law for interview-prep).
Verification: node 175/175, autocontext pytest 26/26 (incl. new guard),
validate:agent-skills 62/62. Longest description 130, aggregate 7,489.
* Fix exported-quote attribution and keep academic-writing compliance triggers
- The em-dash sweep turned the speaker attribution in interview-transcription's
export_for_story() into a leading comma, so every publication-ready quote
exported as ", Speaker Name". Restored the ASCII attribution hyphen.
- Reworked the academic-writing description to keep its distinctive compliance
triggers (CRediT, preregistration, Plan S, Nelson Memo, ORCID, LLM
disclosure) within the 130-character cap.
* Restore distinctive discovery triggers in four descriptions
Codex's review keeps surfacing skills whose shortened description dropped a
distinctive trigger that maps to dedicated, consequential guidance in the body
(which cannot load if the skill is not selected). Restore those triggers within
the 130-character cap:
- one-way-door: add event schemas and CI/CD (both are explicit one-way-door
categories with their own contract and deployment-risk checks).
- api-hardening: name Express, FastAPI, and serverless (framework-specific
hardening sections).
- web-archiving: name the Wayback Machine and Archive.today (how users
actually ask for this).
- secure-auth: name OWASP and NIST.
node 175/175, validate:agent-skills 62/62; longest description 130 chars.
* Fix CRediT taxonomy and doc-span corruption, restore four more triggers
The em-dash sweep corrupted two constructs where the dash carried meaning:
- The CRediT taxonomy in academic-writing listed "Writing — original draft"
and "Writing — review & editing" as two of its 14 roles; the comma swap made
them read as four separate roles. Restored the hyphen in both role names.
- Seven label/value rows in docs/superjawn/index.html (and one dir-tree
annotation in visual-explainer css-patterns) put the separator at the start
of the second span, rendering "Web , error string searches". Moved the
separator onto the label (colon) / used a hyphen annotation.
Also restored distinctive discovery triggers flagged by review:
- python-pipeline: Google Sheets/Drive integration
- page-monitoring: RSS feed generation
- zero-build-frontend: Manifest V3 browser extensions
- pdf-design: secure upload
node 175/175, pytest 304/304, validate:agent-skills 62/62, check:docs-css.
* Sync displayed versions, fix pitch contrast, restore three triggers
- Version bumps: update the displayed plugin versions that trailed the manifest
bumps: CLAUDE.md inventory (journalism-core, research-toolkit, video-toolkit,
pdf-playground, superjawn) and the docs badges in docs/index.html,
docs/superjawn, and docs/video-toolkit, plus the video-toolkit docs test guard.
- story-pitch: the em-dash sweep flattened "background checks - through a
spreadsheet" into a comma, losing the contrast in the model pitch. Restored
it with an explicit "but".
- Restore triggers: ai-writing-detox (writing/drafting, not just editing),
brainstorming (MUST + modifying behavior), okf-wiki (builds from existing
docs/notes/repo, the input flow, not "from concept files").
node 175/175, validate:agent-skills 62/62, check:docs-css.
* Restore four more discovery triggers
- context-engineering-fundamentals: lost-instructions / dropped-evidence
- vibe-coding: name Copilot, Codex, Aider, Windsurf (not just Claude Code/Cursor)
- video-transcribe: audio-only (WAV, podcasts), not just video
- zero-build-frontend: Google Sheets as a database
node 175/175, validate:agent-skills 62/62.
2026-08-18 07:49:38 -04:00
|
|
|
Bump to opacity-90, preserves "older/dimmer" visual cue without breaking AA.
|
fix(a11y): WCAG 2.2 AA pass across all 45 docs/ pages (86 violations -> 0) (#40)
* chore(a11y): add WCAG 2.2 AA verification tooling and patcher
verify_a11y.mjs runs Playwright + axe-core against every docs/**/index.html
served from a local Python http.server, scans against WCAG 2/2.1/2.2 AA
tags, writes scan-results.json, and exits non-zero when any page has a
critical/serious/moderate violation. Doubles as a CI gate.
patch_a11y.py applies idempotent structural fixes across all pages:
skip link, main#main, nav aria-label, aria-hidden on inline svgs,
aria-label on icon-only links, rel=noopener on target=_blank, tabindex
on scrollable code blocks, prefers-reduced-motion CSS, focus-visible,
sr-only utility, hero-gradient ::before overlay (where applicable),
noscript fallback for JS-gated reveal sections, contrast fixes for
text-mist/text-clay/text-accent and bespoke pdf-playground / venmo /
banned-word color tweaks. Marker comments make it safe to re-run.
baseline-violations.json captures the pre-fix scan (86 serious violations
across 45 pages) as a historical reference.
* fix(a11y): WCAG 2.2 AA pass across all docs/ pages
Result: axe-core reports 0 violations across all 45 pages
(45 critical/serious/moderate/minor totals all zero), down from
86 serious violations in the baseline.
Per-page changes are produced by scripts/patch_a11y.py and include:
Structural
- skip link to #main, id="main" on main, aria-label="Primary" on nav
- aria-hidden + focusable=false on every inline decorative svg
- aria-label on icon-only links (GitHub icon was the main offender)
- rel="noopener noreferrer" on every target="_blank"
- tabindex="0" + role="region" on overflow-x-auto code blocks and <pre>
- noscript fallback for JS-gated .reveal-section opacity:0
(homepage, autocontext, superjawn) so content remains readable
if JS fails to run
Motion + focus
- prefers-reduced-motion media query disables animations/transitions
and forces .reveal-section visible
- :focus-visible outline standardized across pages
- .sr-only utility class added
Contrast (the bulk of the original violations)
- mist token #6b6b6b -> #525252 (homepage and Family A)
- text-mist/60|80 -> text-mist (full strength)
- text-clay/60 -> text-clay/80, text-clay/50 -> text-clay/80
- inline body links forced to #1e293b with underline
(overrides Tailwind text-accent/text-accentDark via class-token
selectors keyed off [class~="text-accent"])
- pill spans bg-accent/* + text-accent: text-accent -> text-ink
- bold accent labels: text-accent -> text-accentDark
- Dark-context override: anything with bg-ink / bg-zinc-9*/bg-gray-9*
/ text-white ancestor gets light text-accent (#ddd6fe)
- text-red-600 in <code> -> text-red-700
- ai-writing-detox .banned-word #ef4444 -> #b91c1c
- ai-writing-detox text-green-600 -> text-green-700
- Homepage footer: text-canvas/20|30 -> text-canvas/70
- Venmo support button bg #008CFF -> #006bbf
- Hero gradient ::before { rgba(0,0,0,0.18) } overlay on every
skill page using .hero-gradient (Joe-approved approach)
- pdf-playground mockup: opacity-60 -> opacity-90 on dimmed
feature-card so dimmed text still passes AA; bg-ink/20 text-ink
badge restyled to dark-bg + white text
- pdf-playground 2nd h1 (UI mockup) demoted to div role=heading
aria-level=2 to keep one h1 per page
- about page: explicit dark link override (it lacks accentDark token)
The patcher is idempotent (marker comments) and tracks each change
per file. Re-running on already-patched pages is a no-op.
Verification: node scripts/verify_a11y.mjs prints
pages scanned: 45
TOTAL | CRIT | SERIOUS | MOD | MINOR
0 | 0 | 0 | 0 | 0
exit 0
* fix(a11y): surface per-page scan errors and python3 spawn failures (PR #40 review)
Addresses two silent-failure bugs flagged in Copilot's review:
1. spawn('python3', ...) had no 'error' listener. ENOENT (python3 missing
from PATH) would terminate node with an unhandled error before the
try/catch in main() could run, skipping cleanup. Now caught with a
clear diagnostic, controlled cleanup, exit 2.
2. Per-page goto/axe failures were caught, recorded with violationCount=0
and zeros across byImpact, then ignored by the exit-code check. A run
that silently skipped half the pages would have exited 0 and passed CI.
Fix: track errored pages, surface them in stderr with paths + error
messages, add an ERR column to the per-page table, include error count
in the totals row, and exit 2 if any page errored. A clean a11y report
on the surviving pages is not a pass when we don't know about the rest.
Exit code semantics:
0 = all pages scanned, zero critical/serious/moderate violations
1 = all pages scanned, but at least one severe violation (real result)
2 = scan itself failed (spawn error, page error, fatal exception)
Verified:
- clean run: exit 0, totals "0 | 0 | 0 | 0 | 0 | 0"
- PATH=<no python3>: exit 2 with "fatal: failed to spawn python3 (ENOENT)"
---------
Co-authored-by: Joe Amditis <amditisj@mail.montclair.edu>
2026-05-06 22:28:43 -04:00
|
|
|
Only targets pdf-playground's mockup pattern: feature-card + opacity-60.
|
|
|
|
|
"""
|
|
|
|
|
pattern = re.compile(r'(class\s*=\s*[\"\'][^\"\']*\bfeature-card\b[^\"\']*?\b)opacity-60(\b[^\"\']*[\"\'])')
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m):
|
|
|
|
|
nonlocal count
|
|
|
|
|
count += 1
|
|
|
|
|
return m.group(1) + 'opacity-90' + m.group(2)
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'feature-card opacity-60 -> opacity-90 ({count})')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_pdf_playground_mockup_chrome(html: str, report: PageReport) -> str:
|
|
|
|
|
"""pdf-playground mockup uses bg-ink/20 text-ink badge + text-mist on cream.
|
|
|
|
|
Inject targeted CSS overrides for those mockup-specific patterns.
|
|
|
|
|
"""
|
|
|
|
|
if 'pdf-playground' not in html and 'bg-ink/20' not in html:
|
|
|
|
|
return html
|
|
|
|
|
if '/* pdf-mockup-fix */' in html:
|
|
|
|
|
return html
|
|
|
|
|
block = """
|
|
|
|
|
/* pdf-mockup-fix */
|
|
|
|
|
.bg-ink\\/20.text-ink, span.bg-ink\\/20.text-ink { background-color: rgba(18,18,18,0.85) !important; color: #ffffff !important; }
|
|
|
|
|
[class*="bg-canvas"] [class~="text-mist"], [class*="bg-cream"] [class~="text-mist"], [style*="background:#f8"] [class~="text-mist"], [style*="background-color:#f8"] [class~="text-mist"] { color: #2d2d2d !important; }
|
|
|
|
|
"""
|
|
|
|
|
if "</style>" not in html:
|
|
|
|
|
return html
|
|
|
|
|
new = html.replace("</style>", block + "</style>", 1)
|
|
|
|
|
if new != html:
|
|
|
|
|
report.add('add pdf-playground mockup chrome contrast fix')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fix_pdf_playground_mockup_h1(html: str, report: PageReport) -> str:
|
|
|
|
|
if 'id="doc-title"' not in html:
|
|
|
|
|
return html
|
|
|
|
|
pattern = re.compile(r'<h1(\s+id="doc-title"[^>]*)>([\s\S]*?)</h1>')
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
attrs = m.group(1)
|
|
|
|
|
body = m.group(2)
|
|
|
|
|
count += 1
|
|
|
|
|
return f'<div role="heading" aria-level="2"{attrs}>{body}</div>'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html)
|
|
|
|
|
if count:
|
|
|
|
|
report.add(f'demote {count} mockup h1 to role=heading aria-level=2')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_nav_aria_label(html: str, report: PageReport) -> str:
|
|
|
|
|
pattern = re.compile(r"<nav\b((?![^>]*\baria-label=)[^>]*)>", re.IGNORECASE)
|
|
|
|
|
count = 0
|
|
|
|
|
|
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
|
|
|
nonlocal count
|
|
|
|
|
count += 1
|
|
|
|
|
return f'<nav aria-label="Primary"{m.group(1)}>'
|
|
|
|
|
|
|
|
|
|
new = pattern.sub(repl, html, count=1)
|
|
|
|
|
if count:
|
|
|
|
|
report.add('add aria-label="Primary" to <nav>')
|
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stamp_marker(html: str) -> str:
|
|
|
|
|
if HEAD_MARKER in html:
|
|
|
|
|
return html
|
|
|
|
|
return html.replace("</head>", " " + HEAD_MARKER + "\n</head>", 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PASSES = [
|
|
|
|
|
inject_universal_css,
|
|
|
|
|
inject_hero_overlay,
|
|
|
|
|
inject_reveal_noscript,
|
|
|
|
|
add_skip_link,
|
|
|
|
|
add_main_id,
|
|
|
|
|
add_nav_aria_label,
|
|
|
|
|
add_aria_hidden_to_svgs,
|
|
|
|
|
add_rel_noopener,
|
|
|
|
|
add_aria_label_to_icon_links,
|
|
|
|
|
add_tabindex_to_scrollable,
|
|
|
|
|
fix_text_clay_60,
|
|
|
|
|
fix_text_clay_50,
|
|
|
|
|
fix_text_accent_bold,
|
|
|
|
|
swap_link_accent_to_dark,
|
|
|
|
|
fix_text_mist_token,
|
|
|
|
|
fix_homepage_footer_canvas_opacity,
|
|
|
|
|
fix_pdf_playground_mockup_h1,
|
|
|
|
|
fix_pdf_playground_opacity_60,
|
|
|
|
|
fix_pdf_playground_mockup_chrome,
|
|
|
|
|
fix_venmo_button,
|
|
|
|
|
fix_about_page_links,
|
|
|
|
|
fix_banned_word_color,
|
|
|
|
|
fix_pill_text_accent,
|
|
|
|
|
fix_text_mist_60,
|
|
|
|
|
fix_code_red_600,
|
|
|
|
|
fix_terminal_mockup_opacity,
|
|
|
|
|
fix_stat_accent_on_dark,
|
|
|
|
|
fix_green_600,
|
|
|
|
|
add_tabindex_to_pre,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def patch_file(path: Path) -> PageReport:
|
|
|
|
|
report = PageReport(path)
|
|
|
|
|
original = path.read_text(encoding="utf-8")
|
|
|
|
|
html = original
|
|
|
|
|
for fn in PASSES:
|
|
|
|
|
html = fn(html, report)
|
|
|
|
|
if report.changes:
|
|
|
|
|
html = stamp_marker(html)
|
|
|
|
|
if html != original:
|
|
|
|
|
path.write_text(html, encoding="utf-8")
|
|
|
|
|
return report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
pages = find_pages()
|
|
|
|
|
print(f"patching {len(pages)} pages under {DOCS}")
|
|
|
|
|
changed = 0
|
|
|
|
|
for p in pages:
|
|
|
|
|
report = patch_file(p)
|
|
|
|
|
if report.changes:
|
|
|
|
|
changed += 1
|
|
|
|
|
rel = p.relative_to(ROOT)
|
|
|
|
|
print(f"\n{rel}")
|
|
|
|
|
for c in report.changes:
|
|
|
|
|
print(f" - {c}")
|
|
|
|
|
print(f"\nchanged: {changed} of {len(pages)}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|