SPA SEO & Performance Optimization
Trigger
User asks to SEO/PageSpeed optimize a React/Vite SPA, or a PageSpeed report reveals issues.
Class-level approach
1. Audit the current state
- Fetch the live site's response headers (
curl -sI) — checkx-robots-tag, CSP, caching - Check
_headers,_redirects,robots.txt,sitemap.xmlin the project - Identify font weight using
@font-faceanalysis in CSS - Check for Google/Baidu verification tags
2. Fix critical issues first (SEO scoring)
| Issue | Fix |
|---|---|
x-robots-tag: noindex |
Add X-Robots-Tag: index, follow to _headers for /*.html and / |
| Missing hreflang | Add for all language variants + x-default |
| No Baidu verification | Get code from ziyuan.baidu.com, add to HTML |
| Missing OG tags | Add og:site_name, og:image:alt, og:locale |
3. Font optimization (@fontsource projects)
Case A: Latin-only fonts (Inter, JetBrains Mono, Noto Sans/Serif Latin)
Before: Inter 400/500/600/700 + Noto Serif Latin 500/600 + Noto Serif CJK 500/600 + JetBrains Mono 400/500
After: Inter 400/500/600/700 + Noto Serif CJK 500/600 ONLY
Savings: ~78KB + 2-4 fewer font requests
Steps:
1. Remove Noto Serif SC Latin subsets — CJK range already covers Latin chars
2. Remove JetBrains Mono @font-face — replace with "SF Mono", "Consolas", monospace in CSS classes
3. Replace .font-mono { font-family: "JetBrains Mono"... } with system monospace
4. Replace .label-text font-family similarly
5. Change font-display: swap to font-display: optional for Latin fonts — prevents font-swap CLS (body-wide ~0.586) on all text.
Case B: CJK/Chinese fonts (⚠️ #1 perf killer for Chinese sites)
CJK font packages are ~1.5MB per font weight. Two weights = 3MB.
Fix: Remove entirely. System CJK serif fonts (Songti SC on Mac, SimSun on Windows) ship on every OS. Update font-family to use system fonts only, rebuild, verify no woff2 in dist.
Alternative (subset): Google Fonts API with text= parameter limits to needed characters (5-15KB instead of 1.5MB).
3b. Vendor chunk splitting (Vite build optimization)
If PageSpeed reports "Reduce unused JavaScript" for the vendor bundle, split React chunks:
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/react-dom')) return 'vendor-dom';
if (id.includes('node_modules/react')) return 'vendor-react';
if (id.includes('node_modules/')) return 'vendor-misc';
},
},
},
Before: one vendor.js (59KB, 25KB flagged as unused)
After: vendor-react (8KB) + vendor-dom (180KB) + vendor-misc (7KB)
Pitfall: react-dom's size is not unused code — it's the hydration runtime. The PSI warning for vendor chunks is a false positive for SPAs.
3c. React → Preact swap (nuclear JS reduction)
When "Reduce unused JavaScript" persists even after chunk splitting (react-dom still 180KB, 22-25KB unused), swap React for Preact via aliases. Zero code changes needed — Preact is API-compatible.
// vite.config.ts — add to resolve.alias
resolve: {
alias: {
'react': 'preact/compat',
'react-dom': 'preact/compat',
'react-dom/client': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
After the alias, update manualChunks since there's no separate react-dom chunk anymore:
manualChunks(id) {
if (id.includes('node_modules/@emailjs')) return 'vendor-email';
if (id.includes('node_modules/')) return 'vendor-misc';
},
Results from real project (55KB gzip total JS → 12.8KB gzip main + 7.8KB misc):
| Chunk | Before (React) | After (Preact) |
|-------|---------------|----------------|
| vendor-dom | 180KB (56KB gzip) | vanishes entirely 🗑️ |
| vendor-react | 8KB (3KB gzip) | vanishes entirely 🗑️ |
| Index (main) | 34KB (13KB gzip) | 33KB (12.8KB gzip) — includes Preact runtime |
Total gzip JS: 75KB → 22KB. Lighthouse "unused JS" warning goes from 22.5KB to ~0.
Installation: npm install preact
Pitfall: Preact does NOT include SyntheticEvent (React's cross-browser event wrapper). If code accesses e.nativeEvent or relies on event pooling, it may break. For standard form submissions and click handlers, Preact works identically.
3d. Eliminate third-party SDKs in favor of raw fetch()
When Lighthouse or PageSpeed flags an external vendor chunk (e.g., @emailjs/browser at 3.5KB), check if the library's REST API is simple enough to call directly.
EmailJS example — replace the SDK with raw fetch():
- import emailjs from "@emailjs/browser";
- await emailjs.send(SERVICE_ID, TEMPLATE_ID, params, { publicKey: KEY });
+ const res = await fetch("https://api.emailjs.com/api/v1.0/email/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ service_id: SERVICE_ID,
+ template_id: TEMPLATE_ID,
+ user_id: PUBLIC_KEY,
+ template_params: { name, email, ... },
+ }),
+ });
+ if (!res.ok) throw new Error(`EmailJS responded with ${res.status}`);
Savings: ~3.5KB removed (SDK + dependency tree), 31 modules vs 51 modules transformed.
⚠️ CSP update required: Add the API endpoint to connect-src in _headers:
- connect-src 'self'
+ connect-src 'self' https://api.emailjs.com
Rule of thumb: Before adding any npm SDK, check if the service exposes a simple REST API. If yes, raw fetch() + key exposed in browser contexts anyway — no benefit from the SDK wrapper.
3e. Switch minifier: esbuild → terser
esbuild is the Vite default (faster builds) but terser does better dead-code elimination. Switch when:
- "Reduce unused JavaScript" persists after chunk splitting and Preact swap
drop_consoleis desired (strips allconsole.logfrom production bundles)- Even marginal KB savings matter (vendor-dom shrank ~2KB gzip)
Config:
build: {
minify: 'terser', // replaces 'esbuild'
terserOptions: {
compress: {
drop_console: true, // strips console.log/debug/error
drop_debugger: true,
passes: 2, // multiple optimization passes
},
format: { // ⚠️ use 'format' NOT 'output' (deprecated in terser 6)
comments: false,
},
},
},
Installation: npm install -D terser
Results: vendor-dom 180.29KB (56.27KB gzip) → 178.55KB (55.73KB gzip). Modest but meaningful, and the console stripping prevents accidental debug log leaks.
4. HTML shell optimization (source index.html)
<!-- Resource Hints: preconnect to third-party origins -->
<link rel="dns-prefetch" href="//static.cloudflareinsights.com" />
<link rel="preconnect" href="https://static.cloudflareinsights.com" />
<!-- Preload hero image (LCP element) -->
<link rel="preload" href="./images/hero-showcase.webp" as="image" fetchpriority="high" />
Preconnect rules: 3-4 max. Always pair dns-prefetch + preconnect. Check CSP first — preconnecting to a blocked origin wastes the connection.
Common origins: static.cloudflareinsights.com, Google Fonts/Analytics (if CSP allows), CDN font origins.
5. Post-build plugin regex trap (expanded)
Vite post-build plugins (like html-transform) that inject/replace content via regex silently fail when the source HTML format changes (e.g., comment style <!-- X --> vs <!-- ===== X ===== -->).
Fix pattern: Match the ACTUAL comment text in source index.html, not a stylized delimiter:
// BROKEN: plugin regex looks for delimiters the source doesn't use
html.replace(/<!-- ===== Critical CSS ===== -->[\s\S]*?<\/style>/, replacement)
// FIXED: match the real comment text in source index.html
html.replace(/<!-- Inline critical CSS to prevent CLS -->[\s\S]*?<\/style>/, replacement)
Verification: Always inspect dist/index.html after build — grep for the injected classes:
grep -c 'font-display' dist/index.html # > 0 = critical CSS expanded
grep -c 'preconnect' dist/index.html # > 0 = hints survived
grep -c 'media="print"' dist/index.html # > 0 = async CSS loading
ls dist/assets/*.woff2 # font files exist
5a. Preload main JS entry in post-build plugin
In the same html-transform plugin, parse the built HTML to find the main JS entry (index-*.js) and add it as an explicit <link rel="modulepreload"> before the script tag. This lets the browser discover and download the main JS entry even sooner:
// Inside html-transform plugin's closeBundle handler
const mainJsMatch = html.match(/<script type="module" crossorigin src="(\.\/assets\/index-[^"]+\.js)">/);
if (mainJsMatch) {
const mainJsUrl = mainJsMatch[1];
if (!html.includes(`href="${mainJsUrl}"`)) {
html = html.replace(
mainJsMatch[0],
`<link rel="modulepreload" href="${mainJsUrl}" />\n ${mainJsMatch[0]}`,
);
}
}
This adds critical-path discovery without changing the execution order. Verify:
grep -c 'modulepreload.*index-' dist/index.html # > 0 = JS preload active
Workflow Preference for This User
Verify current state before applying optimizations. This user explicitly asks to re-read the latest report and compare with current source/dist state before making any changes. Do not jump to fixes — first audit what's actually different between report claims and local files. Use execute_code or parallel read_file + terminal calls to generate a side-by-side comparison and present findings before writing code.
Lighthouse Report → Source Code Optimization Pipeline
When PageSpeed/Lighthouse returns a list of suggestions, do NOT apply them blindly — the report may be stale or based on cached content.
Step 1: Verify report accuracy against actual files
Image dimensions: Lighthouse reports HTML attribute values, not actual file dimensions. Cross-check:
# macOS (sips)
sips -g pixelWidth -g pixelHeight public/images/*.webp | grep -E "(pixelWidth|pixelHeight|\.webp)"
# ImageMagick
identify public/images/*.webp
Common finding: Images were already resized to display size but HTML width/height attributes reference old larger values.
Fix: Update React component attributes to match actual file dimensions:
// Before: stale attributes (wrong aspect-ratio → CLS)
width="1344" height="768"
// After: matches actual file on disk
width="1200" height="686"
Step 2: Apply image-specific fixes
See references/image-resize-workflow.md. Key addition: After resizing images, ALWAYS sync the width/height attributes in React components (Hero.tsx, Industries.tsx, Cases.tsx are common sites).
Step 2a: Re-compress images for further savings (after resizing)
After verifying dimensions are correct (Step 1), re-encode ALL resized images at quality 75 for additional byte savings beyond dimension-only reduction:
from PIL import Image
import os, glob
images_dir = 'public/images'
for f in sorted(glob.glob(f'{images_dir}/*.webp')):
name = os.path.basename(f)
if 'qr' in name: continue # skip tiny files
before = os.path.getsize(f)
img = Image.open(f)
img.save(f, 'WEBP', quality=75, method=6)
after = os.path.getsize(f)
print(f'{name}: {before/1024:.1f} KiB -> {after/1024:.1f} KiB')
⚠️ Re-compression trap: Re-encoding an already-optimized WebP at the same or higher quality can INCREASE file size (double-compression artifacts). Always use quality ≤ original. Test 3-5 images first before batch-running. Skip images that grow (revert with git checkout).
Typical results from this session:
| Image type | Before resize | After resize+compress | Total savings |
|-----------|--------------|----------------------|--------------|
| hero-showcase (1344x768) | 28 KiB | 9.8 KiB (800×457, q75) | -65% |
| case images (1184x635) | 49-82 KiB | 5.5-16 KiB (480×350, q75) | -70-87% |
| industry images (1184×864) | 23-74 KiB | 7-21 KiB (600×438, originals) | -70-90% |
Step 3: Apply structural fixes
- Add preconnect hints (section 4 above)
- Split vendor chunks if "Reduce unused JavaScript" warning is significant (section 3b)
- Verify critical CSS expansion survived post-build plugin (section 5)
Step 4: Rebuild and verify
# CLEAN rebuild — ensures no stale images/files in dist/
rm -rf dist && npm run build
# Verify everything survived the post-build plugin
grep -c 'font-display' dist/index.html
grep -c 'preconnect' dist/index.html
grep -c 'modulepreload.*index-' dist/index.html # JS entry preload (if added)
# Verify fresh images were copied from public/
sips -g pixelWidth -g pixelHeight dist/images/hero-showcase.webp 2>/dev/null | grep pixel
ls -lh dist/images/*.webp | head -5
JSON-LD structured data
Always include these schema types when applicable:
- Organization — @id, logo, sameAs, knowsAbout, areaServed (array of Country objects, not array of strings)
- Service — @id, inLanguage, provider refs Organization @id
- WebSite — @id, publisher refs Organization @id
- BreadcrumbList — @id, first item should use real URL not
#hero - ItemList — for case studies/portfolio, include datePublished
- FAQPage — rich snippet potential, target 6-8 questions
Noscript fallback for crawlers
Place AFTER <body> tag (NOT in <head>):
<noscript>
<h1>Site Name — Description</h1>
<p>Summary paragraph...</p>
<h2>Services</h2>
<ul><li>Service 1</li></ul>
<p>Contact: email@example.com</p>
</noscript>
⚠️ Vite's HTML parser rejects block elements inside <noscript> in <head>. Always place in <body>.
Sitemap with image extensions
Add xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" to <urlset>. Each image needs <image:image> block with <image:loc> and <image:title>.
Cloudflare Pages specifics
_headersmust includeX-Robots-Tag: index, follow— Cloudflare may addnoindexby default on Pages- CSP
script-src 'self'blocks Google Analytics/GTM — update if needed - SPA fallback in
_redirects:/* /index.html 200
Browser Console Verification (when PSI API is unavailable)
// Get ALL resource transfer sizes
performance.getEntriesByType('resource').map(r => ({
name: r.name.split('/').pop(),
size: r.transferSize,
type: r.initiatorType,
duration: r.duration.toFixed(0) + 'ms'
}))
// Get total page transfer
performance.getEntriesByType('resource').reduce((s, r) => s + (r.transferSize || 0), 0)
// Filter for fonts specifically
performance.getEntriesByType('resource').filter(r => r.name.includes('woff2'))
.map(r => ({ name: r.name.split('/').pop(), size: r.transferSize }))
PSI API Quota Pitfall
PSI free tier has a daily query limit (~25K/day). On HTTP 429, use curl-based offline analysis:
curl -sI https://example.com # headers, caching
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}\n" url # TTFB
curl -s url | grep -E '(title|meta|script type="application/ld|link rel=")' # SEO meta
curl -sI url | grep -i cf-cache-status # CDN status
Rule: One PSI API call per session — for final verification only.
SPA-Specific CLS Prevention
React SPAs suffer CLS from three distinct root causes. Fix all three to get CLS below 0.1.
Root Cause 1: Hydration Gap (the app-shell trap)
The traditional fix is a <div id="app-shell"> that mirrors the React header. This is wrong. The app-shell's content (SVG icon with multiple <rect>, <path> elements, brand text, inline styles) never exactly matches the React-rendered <header> — any structural or dimensional difference causes a measurable CLS during hydration.
Fix: Remove the app-shell entirely. No shell = nothing to swap.
- <div id="root">
- <div id="app-shell" style="position:fixed;...">svg...</div>
- </div>
+ <div id="root" style="padding-top:72px;min-height:100vh;"></div>
How this eliminates CLS:
1. #root has padding-top: 72px — content area starts 72px from the top
2. No app-shell exists to be replaced — nothing swaps during hydration
3. React adds the fixed <header> on top of the padding area
4. Zero DOM swap = zero layout shift
Update critical CSS — remove the #app-shell rule:
#root { min-height: 100vh; padding-top: 72px; }
-#app-shell { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: 72px; }
Result: CLS drops from >1.0 to <0.05 on desktop Lighthouse. Bonus: index.html shrinks by ~1.7KB (no more embedded SVG).
Root Cause 2: Font-Swap CLS (Latin fonts)
font-display: swap on Latin fonts (Inter, 24KB each) causes body-wide CLS (~0.586).
Fix: Use font-display: optional instead of swap for preloaded Latin fonts. Preloaded fonts still arrive in time on fast connections; on slow connections the fallback stays. Zero CLS either way.
LCP Hero Text Font Fallback
Even with font-display: optional, the hero title (often the LCP element) may use a serif display font (.font-display class) that the browser hasn't loaded yet. Without a system-serif fallback in critical CSS, the title text renders invisibly — no CLS, but delayed LCP measurement.
Fix: Add a system-serif fallback to BOTH the source index.html's inline <style> AND the post-build plugin's criticalCSS string:
.font-display { font-family: Georgia, 'Times New Roman', serif; }
Why Georgia: 0KB (pre-installed on every OS), renders within the LCP measurement window (2.5s). Without it, the hero title stays invisible until the display font loads. Double-check this rule survived the post-build plugin by grepping dist/index.html after build.
Root Cause 3: Image CLS (aspect-ratio override)
Images with CSS overrides (w-full h-auto) ignore HTML dimensions.
Fix: Add to inline critical CSS:
img[width][height] { aspect-ratio: attr(width) / attr(height); }
html-transform Plugin Trap
If a Vite post-build plugin replaces the inline <style> block via regex, it silently drops styles in the original index.html that aren't in the plugin's criticalCSS template. Both index.html AND the plugin must contain the same critical CSS.
Tailwind fontFamily Config Duplicate
Tailwind's fontFamily.display in tailwind.config.js generates its own .font-display utility. If you ALSO define .font-display in @layer components, both get emitted and Tailwind's takes precedence.
Fix: Update BOTH tailwind.config.js fontFamily array AND manual class definitions. Verify:
grep -o "font-display{font-family[^}]*}" dist/assets/*.css
Accessibility Contrast Fix for Badges
Light-tint badges often fail WCAG AA (4.5:1 for 12px text). Don't darken the tint — introduce a separate CSS variable:
:root { --accent-badge-text: #7A3B10; } /* 5.7:1 on #EED5C0 */
.dark { --accent-badge-text: #E6A070; } /* light orange on 20% opaque bg */
Dark-Surface Text: rgba Opacity Fix (common PSI fail)
React sections on --surface-dark (#1A1A1A in light mode / #0A0A0A in dark) frequently use semi-transparent white text like rgba(245,240,235,0.5). These opacity values produce effective colors that FAIL WCAG AA (4.5:1 for normal text).
Rule of thumb for rgba(245,240,235,X) on #1A1A1A:
| Opacity | Effective color | WCAG AA (4.5:1) | Use for |
|---------|----------------|-----------------|---------|
| 0.4 | #716F6E (2.6:1) | ❌ FAIL | Never |
| 0.5 | #878582 (3.5:1) | ❌ FAIL | Never for text |
| 0.55 | #94918F (4.1:1) | ⚠️ Borderline | Strikethrough / decorative |
| 0.6 | #9D9A97 (4.6:1) | ✅ PASS | Secondary text (barely) |
| 0.65 | #A8A5A2 (5.1:1) | ✅ PASS | Body text / links |
| 0.7 | #B3AFAC (5.8:1) | ✅ PASS | Primary text |
| 0.75 | #C0BCB8 (6.5:1) | ✅ PASS | Label text (use this instead of accent-color on dark) |
| 0.85 | #DCD8D4 (8.5:1) | ✅ PASS | Section headings |
| 1.0 | #F5F0EB (12.2:1) | ✅ PASS | Pure white |
Fix pattern for text-white/XX on accent backgrounds (#B85C28):
- text-white/90 on #B85C28 → 4.57:1 (barely passes AA)
- text-white (full opacity) → 5.37:1 ✅
- Prefer pure white on accent-color backgrounds
Fix checklist when PSI reports contrast failures on dark-themed sections:
1. Identify all rgba(245,240,235,X) values in the section's TSX files
2. Bump each to the next row up in the table (e.g., 0.5 → 0.65, 0.6 → 0.7, 0.4 → 0.55)
3. For text-white/90 on accent bg, change to text-white
4. For label-text on dark surfaces, change from var(--accent-text) (#8A4A1F, 2.3:1) to var(--accent-color) (#B85C28, 6.5:1)
5. Rebuild and re-scan — PSI may report different failures on second pass
See references/dark-surface-contrast-calc.md for the per-component opacity values.
Deployment
wrangler CLI
# Deploy to production (use the configured production branch — typically "main")
npx wrangler pages deploy dist --project-name NAME --branch main
⚠️ CRITICAL: --branch production creates a PREVIEW, not production. Cloudflare Pages's production branch is typically "main" (or "master"), NOT "production". Using --branch production when the production branch is "main" creates a Preview deployment only accessible at <hash>.project.pages.dev — the custom domain is NOT updated.
How to check:
npx wrangler pages deployment list --project-name NAME
# Look for "Environment" column — "Production" is the one serving the custom domain
Common pattern that silently fails:
# ❌ Creates a PREVIEW on branch "production" — custom domain NOT updated
npx wrangler pages deploy dist --project-name NAME --branch production
# ✅ Deploys to PRODUCTION (branch "main" — the configured production branch)
npx wrangler pages deploy dist --project-name NAME --branch main
Verification after deploy: Compare JS chunk hashes between local dist/ and the live site:
grep -o 'index-[^.]*' dist/index.html
curl -s https://yoursite.com/ | grep -o 'index-[^.]*'
# If they match, your deployment is live. If they don't, you deployed to the wrong branch.
Post-deploy cache purge
Cloudflare CDN caches images with max-age=31536000 (1 year) + immutable — meaning once cached, browsers and edge nodes never re-request for a full year. After resizing, the new content is invisible to users.
Verification script available: scripts/verify-deployment.py under this skill. Call it with python3 scripts/verify-deployment.py https://yourdomain.com path/to/dist to auto-compare JS chunk hashes between local and deployed — catching stale CDN cache or incomplete deployment without manual curl/grep.
Method A: Cache purge (admin access required)
- Dashboard: Cloudflare → Caching → Purge Everything
- API: POST /zones/{zone_id}/purge_cache --data '{"purge_everything":true}'
Method B: Image rename (no admin access) ⭐
When cache purge is unavailable (no API token, can't access dashboard), rename all image files to new filenames and update all source references. This creates entirely new URLs that bypass the CDN cache:
# In public/images/, create new versions with a suffix
cd public/images
for f in *.webp; do
base="${f%.webp}"
cp "$f" "${base}-opt.webp" # or use a version number
done
Then update ALL source references (TSX files + index.html preload):
- src="./images/hero-showcase.webp"
+ src="./images/hero-showcase-opt.webp"
- <link rel="preload" href="./images/hero-showcase.webp" ... />
+ <link rel="preload" href="./images/hero-showcase-opt.webp" ... />
Impact: Old files remain on the server (unreferenced until TTL expiry), new files serve immediately with correct cache headers. Verify with curl -sI https://domain.com/images/hero-showcase-opt.webp | grep content-length.
Pitfall: Every image URL change means a new hash in JS bundles → larger upload each deployment. Batch all image renames in one commit, not incrementally.
Method C: _headers race-deploy (moderate)
When cache purge is unavailable and image rename is too heavy, temporarily change the image cache policy to max-age=0, must-revalidate, deploy, then revert:
# Step 1: In public/_headers, change image cache to immediate revalidation
# /images/* → Cache-Control: public, max-age=0, must-revalidate
# /og-image.webp → Cache-Control: public, max-age=0, must-revalidate
# Step 2: Rebuild + deploy
rm -rf dist && npm run build && wrangler pages deploy dist --project-name NAME --branch production
# Step 3: Revert _headers back to immutable once cache is refreshed
# /images/* → Cache-Control: public, max-age=31536000, immutable
# Step 4: Deploy again
wrangler pages deploy dist --project-name NAME --branch production
Why this works: Cloudflare Pages respects the _headers file. Each deployment triggers a fresh origin fetch from the CDN edge. With max-age=0, the CDN revalidates on every request, immediately serving the new content. After cache refresh, revert to long TTL.
Pitfall: Requires two deployments. The window of no-cache lasts only between deploys (typically 30-120 seconds). Old cached copies on user browsers are NOT invalidated — only the CDN edge cache.
Which method to choose?
| Scenario | Method |
|----------|--------|
| Admin access (API key / Dashboard) | A: Purge cache |
| Can't access admin, OK with one large commit | B: Image rename |
| Can't access admin, want minimal changes | C: _headers race-deploy |
| Need to invalidate user browser caches | Only Method B works (new URL = new cache key) |
Deployment Verification Script
A helper script scripts/verify-deployment.py under this skill automates hash comparison between local dist/ and the deployed site. It:
- Fetches deployed HTML and extracts the main JS hash
- Compares it against local
dist/index.html - Fetches the deployed main JS to extract lazy chunk references
- Compares all lazy chunk hashes against local
- Fetches one chunk to confirm its content is accessible
python3 scripts/verify-deployment.py https://yourdomain.com /path/to/dist
# Example output:
# Deployed main JS: index-D-5wANjw.js
# ✅ Main JS hash MATCHES local — deployment is live
# ✅ All 7 lazy chunk imports match local
# ✅ Sample chunk WhyUs-CimLE5wr.js: deployed=2341B, local=2341B
These items push a Lighthouse Accessibility audit from 96→100. Each corresponds to a specific automated check.
Contrast (largest scoring category)
| Check | Rule | Fix |
|---|---|---|
| Body text | ≥ 4.5:1 vs background | Use contrast table in main SKILL.md |
| Large text (≥18px / ≥14px bold) | ≥ 3:1 | Same table, lower threshold |
| Text on dark surfaces | Opacity ≥ 0.6 body, ≥ 0.65 secondary | See references/dark-surface-contrast-calc.md |
| Text on accent bg | Use pure #fff, not text-white/90 |
text-white/90 on #B85C28 = 4.57:1 (barely passes); pure white = 5.37:1 ✅ |
label-text class on dark bg |
var(--accent-text) (#8A4A1F) = 2.3:1 ❌ |
rgba(245,240,235,0.75) = 6.5:1 ✅. Pitfall: var(--accent-color) (#B85C28) on #1A1A1A = only 3.81:1 — STILL fails WCAG AA for small text. Do NOT use accent color for label text on dark surfaces. |
Form Labels
- Every
<input>,<select>,<textarea>needs a<label>(visible or.sr-only) - Fallback:
aria-labelmatching the visible placeholder
Common miss: Newsletter email inputs with only placeholder:
+ <label htmlFor="newsletter-email" className="sr-only">{t("footer.emailPlaceholder")}</label>
- <input type="email" placeholder={t("footer.emailPlaceholder")} ... />
+ <input id="newsletter-email" type="email" placeholder={t("footer.emailPlaceholder")}
+ aria-label={t("footer.emailPlaceholder")} ... />
- Required fields: add
aria-required="true"alongsiderequired - Error/success messages: add
role="alert"
Document and Landmarks
- [ ]
<html>haslangattribute - [ ]
<main>exists withid="main-content" - [ ]
<nav>hasaria-label="Main navigation" - [ ] Page has skip-to-content link at top of
<body> - [ ]
<header>,<main>,<footer>landmark elements used
Skip link boilerplate:
<!-- In index.html, first child of <body> -->
<a href="#main-content" class="skip-link"
style="position:absolute;top:-9999px;left:-9999px;z-index:9999;
padding:12px 24px;background:var(--accent-color,#B85C28);color:#fff;
font-size:14px;font-weight:500;border-radius:0 0 8px 0;text-decoration:none;">
Skip to main content
</a>
.skip-link:focus { top: 0 !important; left: 0 !important; position: fixed !important; }
Keyboard and Focus
- [ ] Global
:focus-visiblewith 2px accent-color ring + 2px offset - [ ]
:focus:not(:focus-visible)removes outline for mouse users only - [ ] All interactive elements are keyboard-focusable (Tab order matches DOM order)
- [ ] Touch targets ≥ 44×44px (WCAG 2.2)
*:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; border-radius: 4px; }
*:focus:not(:focus-visible) { outline: none; }
Images
- [ ] Every
<img>has meaningfulalt(evenalt=""for decorative) - [ ] SVGs have
role="img"+<title>oraria-label - [ ] Lazy-loaded images still have alt text
Headings
- [ ] Exactly one
<h1>per page - [ ] Hierarchy is logical: h1 → h2 → h3, no gaps
- [ ] Lists use
<ul>/<ol>, not plain<div>or<span>