Files
Joe Amditis 3e0e3a8dff 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

707 lines
24 KiB
Python

#!/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:
# Detect via JS pattern, not CSS selector, our own injected CSS contains .reveal-section.
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.
The h6 labels are 10px tracked-letterspaced uppercase, visually small but
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.
Bump to opacity-90, preserves "older/dimmer" visual cue without breaking AA.
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())