# PageSpeed Enhancer Skill - Detailed Guide > This file contains the detailed procedure and reference material extracted from `SKILL.md` for focused loading. The root skill defines activation, examples, safety constraints, and limitations. ## High-Level Workflow ``` PHASE 1 → Ingest Report & Parse Scores PHASE 2 → Batch Scan (4 sections, parallel analysis) PHASE 3 → Consolidated Risk Report (changes ranked by impact vs risk) PHASE 4 → Fix Batches (applied in safe order: low-risk → high-risk) PHASE 5 → Verification Checklist ``` --- ## PHASE 1 — Ingest & Classify When the user provides a PageSpeed Insights report (pasted text, screenshot, or URL): 1. Extract the four pillar scores: Performance, Accessibility, Best Practices, SEO. 2. Extract each flagged metric with its value and Lighthouse weight. 3. Identify the **critical path bottleneck** (the single issue most responsible for the lowest pillar score). 4. Output a **Score Summary Table**: ``` | Pillar | Score | Status | Critical Issue | |-----------------|-------|---------|-------------------------------------| | Performance | 80 | ⚠️ Warn | LCP 4.0s — element render delay | | Accessibility | 100 | ✅ Pass | — | | Best Practices | 100 | ✅ Pass | CSP missing (unscored) | | SEO | 100 | ✅ Pass | — | ``` Then proceed immediately to Phase 2 without waiting for user input unless the report is ambiguous. --- ## PHASE 2 — Batch Scan (4 Sections) Run all four section scans. Present as collapsible sections in output. ### Batch A — Performance Scan Audit these in order (highest Lighthouse weight first): | Audit | Metric Impact | Key Questions | |-------|--------------|---------------| | LCP breakdown | LCP | Is the LCP element lazily loaded? Is TTFB > 600ms? Is element render delay > 1s? | | Render-blocking resources | FCP, LCP | Which CSS/JS files block the critical path? Can they be deferred or inlined? | | CSS `@import` rules | FCP, LCP | Are external stylesheets loaded via `@import url()` in CSS? This can add a sequential discovery dependency — browser must fetch CSS, parse it, then fetch imported CSS. Use `` instead. | | Unused JavaScript | FCP, LCP, TBT | What % of the main bundle is unused? Is code-splitting possible? | | Network dependency tree | LCP | What is the critical path chain? Max latency? | | Forced reflows | TBT | Which JS functions query geometry after DOM mutation? | | Image delivery | FCP, LCP | Are images in WebP/AVIF? Are above-fold images lazy-loaded? | | Speed Index | SI | Is page visually progressive or does it paint all at once? | | CLS culprits | CLS | Any images without width/height? Any late-injected content? | | JavaScript execution time | TBT | Total parse + compile + evaluate time? | | Long main-thread tasks | TBT | Tasks > 50ms? Starting when? | | Bundled asset sizes | FCP, LCP, TBT | Check `dist/` output: any single JS chunk > 500KB gzipped? CSS > 100KB? Code-splitting creating proper vendor chunks? | For each audit item, output: - **Finding**: What the report says - **Root Cause**: Why it's happening - **Fix Category**: Quick Win / Medium Effort / Refactor Required ### Batch B — Accessibility Scan Focus on any failed audits. For a 100-score page, still check: | Check | What to Verify | |-------|---------------| | ARIA attribute correctness | All `aria-*` attributes match element roles | | Colour contrast | Applicable text meets WCAG AA (4.5:1 normal, 3:1 large) | | Image alt text quality | Alt text is descriptive, not filename-style | | Keyboard navigation | All interactive elements reachable by Tab | | Skip links | Present and focusable | | Heading hierarchy | No skipped levels (h1 → h2 → h3) | | Touch target size | WCAG 2.2 AA 24×24 CSS px or applicable exception; 44×44 is the enhanced target | | Form labels | Every input has an associated label | | `lang` attribute | `` present and valid BCP 47 | | `font-display` | Set to `swap` or `optional` to prevent FOIT | ### Batch C — Best Practices Scan Security headers are often unflagged by Lighthouse score but are critical. Check ALL deployment targets: | Check | Header/Setting | Where to Configure | Severity | |-------|---------------|-------------------|----------| | Content Security Policy | `Content-Security-Policy` | `netlify.toml` `[[headers]]` / `vercel.json` `"headers"` | 🔴 High | | Cross-Origin-Opener-Policy | `COOP` header | Same as above | 🔴 High | | Clickjacking protection | `X-Frame-Options` or CSP `frame-ancestors` | Same as above | 🔴 High | | HSTS configuration | `Strict-Transport-Security` with `includeSubDomains` + `preload` | Same as above | 🟡 Medium | | Trusted Types (DOM XSS) | CSP `require-trusted-types-for 'script'` | Same as above | 🟡 Medium | | X-Content-Type-Options | `nosniff` header | Same as above | 🟡 Medium | | Referrer-Policy | `strict-origin-when-cross-origin` | Same as above | 🟡 Medium | | Permissions-Policy | Restrict camera/mic/geolocation | Same as above | 🟡 Medium | | Third-party cookies | Any `SameSite=None` cookies without `Secure`? | — | 🟡 Medium | | Deprecated APIs | Any browser-deprecated JS APIs in use? | — | 🟢 Low | | Source maps | Are source maps deployed for debugging? | — | 🟢 Low | When both `netlify.toml` and `vercel.json` exist, check BOTH. Each has a different syntax (TOML vs JSON). ### Batch D — SEO Scan | Check | What to Verify | |-------|---------------| | `` tag | Present, 50–60 chars, includes primary keyword | | Meta description | Present, 150–160 chars, compelling | | Canonical tag | `<link rel="canonical">` points to correct URL | | hreflang | Present if multilingual; correct language codes | | robots.txt | Valid, not blocking key resources | | Structured data | JSON-LD present; run Schema validator | | Image alt attributes | Informative images have useful alt; decorative images have empty alt | | Link descriptiveness | No "click here" / "read more" link text | | Crawlability | No `noindex` on important pages | | HTTP status | 200 on main page and critical resources | | SPA meta injection | If using react-helmet-async / Next.js Head: verify via "View Page Source", not DevTools Elements — meta tags may be JS-injected | --- ## PHASE 3 — Risk Report After completing all four batch scans, output a consolidated **Risk vs Impact Matrix**: ``` | Fix | Impact Score | Risk Level | Effort | Priority | |----------------------------------|-------------|------------|----------|----------| | Add defer/async to non-critical JS | High (LCP -0.8s est) | 🟢 Low | 1h | P1 | | Convert images to WebP/AVIF | Medium (LCP -0.3s) | 🟢 Low | 2h | P1 | | Add CSP header | Security | 🟡 Medium | 3h | P2 | | Code-split main JS bundle | High (TBT -20ms) | 🟡 Medium | 1 day | P2 | | Fix forced reflows | Medium (TBT -15ms) | 🔴 High | 2 days | P3 | | Add HSTS preload | Security | 🟡 Medium | 30min | P2 | ``` **Risk Level Definitions:** - 🟢 Low: Assess the actual blast radius; headers can break auth, embedding or subdomains and may not roll back quickly. - 🟡 Medium: Build config or asset pipeline change. Test in staging first. - 🔴 High: JavaScript refactor, architectural change. Requires full QA cycle. Always recommend: fix P1 (Low Risk, High Impact) items first, then P2, then P3. --- ## PHASE 4 — Fix Batches Apply fixes in risk order. For each fix, provide: 1. **What to change** — file, line, specific change 2. **Before** (code snippet) 3. **After** (code snippet) 4. **Expected effect** — hypothesis unless measured 5. **How to verify** — what to check after deploying ### Fix Batch 1 — Candidate quick wins (verify before authorized deployment) Examples from common audits: **F1.1 — Move CSS `@import` to `<link>` tag** CSS @import can delay stylesheet discovery; the effect is not a fixed multiplier. Move to `<link>` in `<head>`: ```css /* Before: in index.css */ @import url('https://fonts.googleapis.com/css2?family=Inter&display=swap'); ``` ```html <!-- After: in index.html <head> --> <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter&display=swap" media="print" onload="this.media='all'" /> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter&display=swap" /></noscript> ``` **F1.2 — Defer render-blocking CSS (if not above-fold critical)** ```html <!-- Before --> <link rel="stylesheet" href="/assets/index.css"> <!-- After: load async, apply on load --> <link rel="preload" href="/assets/index.css" as="style" onload="this.onload=null;this.rel='stylesheet'"> <noscript><link rel="stylesheet" href="/assets/index.css"></noscript> ``` **F1.3 — Fix broken preconnect (crossorigin mismatch)** ```html <!-- Before (broken — no crossorigin on font CDN) --> <link rel="preconnect" href="https://api.rss2json.com"> <!-- After --> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <!-- Only preconnect origins used in critical path, max 4 --> ``` **F1.4 — Convert images to WebP** ```bash # Using cwebp cwebp -q 80 input.jpeg -o output.webp # Using sharp (Node.js) sharp('image.jpeg').webp({ quality: 80 }).toFile('image.webp') # macOS fallback (sips built-in) # Check sips --help for supported encoders on this macOS version; do not assume WebP output exists. # Python Pillow fallback python3 -c " from PIL import Image Image.open('input.jpg').save('output.webp', 'WebP', quality=80) " ``` **F1.5 — Add explicit image dimensions (CLS fix)** ```html <!-- Before --> <img src="hero.webp" alt="..."> <!-- After --> <img src="hero.webp" alt="..." width="800" height="400"> ``` **F1.6 — Add security headers (netlify.toml)** ```toml [[headers]] for = "/*" [headers.values] X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" Strict-Transport-Security = "max-age=300" Cross-Origin-Opener-Policy = "same-origin" Permissions-Policy = "camera=(), microphone=(), geolocation=()" Content-Security-Policy-Report-Only = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://api.rss2json.com" ``` **F1.7 — Add security headers (vercel.json)** ```json { "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Frame-Options", "value": "DENY" }, { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "Strict-Transport-Security", "value": "max-age=300" }, { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }, { "key": "Content-Security-Policy-Report-Only", "value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://api.rss2json.com" } ] } ] } ``` **F1.8 — Self-host Google Fonts (eliminate external CSS request)** Download woff2 files and serve them locally to remove the Google Fonts CSS round-trip entirely: ```bash # 1. Download woff2 files from Google Fonts CSS URL # Open https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap # in a browser, then download each woff2 URL listed in the @font-face blocks. # 2. Place files in public/fonts/ or src/assets/fonts/ public/fonts/ inter-v12-latin-400.woff2 inter-v12-latin-700.woff2 # 3. Add @font-face CSS (load once, no external request) ``` ```css /* src/styles/fonts.css */ @font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/inter-v12-latin-400.woff2') format('woff2'); } @font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/inter-v12-latin-700.woff2') format('woff2'); } ``` ```css /* Remove the old Google Fonts <link> from index.html */ /* Before: */ <link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"> /* After: just use the font-family normally */ body { font-family: 'Inter', sans-serif; } ``` **Expected effect to measure:** removes the external font CSS request. Font availability, fallback behavior, layout shift and offline caching still require validation. **F1.9 — Resize oversized icons** Choose budgets per asset role; an OG social image is not a small favicon and has different dimensions/quality requirements. Check and resize: ```bash python3 -c " from PIL import Image img = Image.open('favicon.png') img.resize((192, 192)).save('favicon.png', 'PNG', optimize=True) img.resize((32, 32)).save('favicon-32x32.png', 'PNG', optimize=True) img.resize((16, 16)).save('favicon-16x16.png', 'PNG', optimize=True) " ``` ### Fix Batch 2 — Medium Effort (staging test recommended) **F2.1 — Remove LCP element lazy loading** The LCP element must NEVER be lazy-loaded: ```html <!-- Before: wrong — LCP image is lazy --> <img src="hero.webp" loading="lazy" ...> <!-- After: eager load the above-fold LCP element --> <img src="hero.webp" loading="eager" fetchpriority="high" ...> ``` **F2.2 — Preload LCP image** ⚠️ Preload must reference the exact emitted URL and responsive candidate. Bundlers can inject hashed asset URLs into preload links; picture/fetchPriority does not replace correct discovery in every case: ```html <!-- For stable URLs (public/ directory): --> <link rel="preload" as="image" href="/hero.webp" fetchpriority="high"> <!-- For hashed filenames (Vite/Rollup): use component-level approach --> <picture> <source srcSet={webpImage} type="image/webp" /> <img src={jpgImage} fetchPriority="high" loading="eager" width="1920" height="1080" /> </picture> ``` **F2.3 — Reduce unused JS (Vite/Rollup config)** ```js // vite.config.js — enable manual chunking build: { rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom'], rss: ['rss-parser'], } } } } ``` **F2.4 — Eliminate forced reflows** ```js // Before: reads layout property inside animation loop element.addEventListener('scroll', () => { const h = element.offsetHeight; // triggers reflow doSomething(h); }); // After: cache geometry reads outside event handlers let h = element.offsetHeight; const resizeObserver = new ResizeObserver(() => { h = element.offsetHeight; }); resizeObserver.observe(element); // disconnect during teardown element.addEventListener('scroll', () => { doSomething(h); }); ``` **F2.5 — Optimise DOM size** If DOM > 1,500 elements: - Use virtual scrolling for long lists (react-virtual, TanStack Virtual) - Lazy-render off-screen sections - Remove hidden/display:none nodes that never become visible ### Fix Batch 3 — Refactor Required (full QA cycle) **F3.1 — External API in critical path (e.g. api.rss2json.com)** Current: HTML → JS bundle → external API (adds 1,574ms to critical path) Solution: Move external API calls to build time or server-side: ```js // Option A: Fetch at build time (Astro/Next.js SSG) export async function getStaticProps() { const res = await fetch('https://api.rss2json.com/v1/api.json?rss_url=...'); const data = await res.json(); return { props: { posts: data.items }, revalidate: 3600 }; } // Option B: Edge function / serverless proxy // Cache RSS response at CDN edge, return stale-while-revalidate ``` **F3.2 — Content Security Policy (full CSP)** Build the CSP iteratively: 1. Deploy in report-only mode first: `Content-Security-Policy-Report-Only` 2. Check browser console for violations for 48h 3. Whitelist required origins 4. Promote to enforcement mode --- ## PRE-DEPLOY GATE Before deploying any fix batch, run these checks: ``` Build: □ npm run build (or equivalent) — exits 0 □ npm run lint / typecheck — no new errors vs baseline □ Inspect dist/ output: - No single JS chunk > 500KB (gzipped) - CSS < 100KB - Code-splitting created separate vendor chunks Asset verification: □ Confirm preload URLs match emitted assets and responsive selection; use the bundler's supported asset pipeline. □ Favicons and icons are < 50KB each (not multi-MB source images used as icons) □ WebP/AVIF versions exist alongside originals Deploy target: □ If dual-deployed (Netlify + Vercel), verify headers on BOTH □ If using SPA framework: verify meta tags via "View Page Source", not DevTools Elements (react-helmet-async injects at runtime — check prerendered/SSR output) ``` --- ## PHASE 5 — Verification Checklist After deploying each fix batch, verify: ``` Performance: □ Re-run PageSpeed Insights on mobile AND desktop □ LCP < 2.5s (Good) □ FCP < 1.8s (Good) □ TBT < 200ms (Good) □ CLS < 0.1 (Good) □ SI < 3.4s (Good) Accessibility: □ Run axe DevTools browser extension □ Navigate page with keyboard only (Tab, Shift+Tab, Enter, Space) □ Test with screen reader (NVDA/VoiceOver) □ Check contrast with browser DevTools accessibility panel Best Practices: □ Verify security headers at https://securityheaders.com □ Check HTTPS: no mixed content warnings in DevTools □ Run Lighthouse Best Practices audit again SEO: □ Validate structured data at https://search.google.com/test/rich-results □ Check robots.txt at /robots.txt □ Verify canonical tag in page source (View Source, not DevTools) □ Submit updated sitemap to Google Search Console ``` --- ## Output Format Conventions - Always label outputs: **[SCAN]**, **[RISK]**, **[FIX]**, **[VERIFY]** - Use emoji severity indicators: 🔴 Critical / 🟡 Warning / 🟢 Pass / ℹ️ Info - Always show "Before" and "After" code for every fix - Report metric deltas only from measured evidence; otherwise state improvement not yet measured - Never suggest fixes that conflict with each other — sequence matters --- ## Quick Reference: Metric Thresholds | Metric | Good | Needs Work | Poor | |--------|------|-----------|------| | FCP | < 1.8s | 1.8–3.0s | > 3.0s | | LCP | < 2.5s | 2.5–4.0s | > 4.0s | | TBT | < 200ms | 200–600ms | > 600ms | | CLS | < 0.1 | 0.1–0.25 | > 0.25 | | SI | < 3.4s | 3.4–5.8s | > 5.8s | --- ## Change Log & Revert Checklist After each fix batch, log what changed and whether it caused build failures: | Fix | File(s) Modified | Build Pass? | Errors | Revert Steps | |-----|-----------------|-------------|--------|-------------| | F1.1 — CSS @import → `<link>` | `index.html`, `src/styles/*.css` | □ Yes □ No | | Restore original `<link>` tags | | F1.2 — Defer render-blocking CSS | `index.html` | □ Yes □ No | | Remove `media="print"` + `onload` | | F1.4 — WebP conversion | `public/images/*.webp` | □ Yes □ No | | Delete .webp files, restore originals | | F1.5 — Image dimensions | `src/components/*.tsx` | □ Yes □ No | | Remove `width`/`height`/`loading` attrs | | F1.6 — Security headers (Netlify) | `netlify.toml` | □ Yes □ No | | Delete the `[[headers]]` block | | F1.7 — Security headers (Vercel) | `vercel.json` | □ Yes □ No | | Remove the `"headers"` array entry | | F1.8 — Self-host fonts | `public/fonts/*.woff2`, `src/styles/fonts.css`, `index.html` | □ Yes □ No | | Delete font files, remove `@font-face`, restore Google Fonts `<link>` | | F1.9 — Resize icons | `public/favicon*`, `public/apple-touch-icon*`, `public/og-image*` | □ Yes □ No | | Restore original icon files | | F2.1 — LCP eager loading | `src/components/*.tsx` | □ Yes □ No | | Change `loading="eager"` back to `loading="lazy"` | | F2.2 — Preload LCP image | `index.html` or `src/components/*.tsx` | □ Yes □ No | | Remove `<link rel="preload">` or revert `<picture>` | | F2.3 — Code-split JS | `vite.config.ts` | □ Yes □ No | | Remove `manualChunks` config | | F2.4 — Fix forced reflows | `src/**/*.ts` | □ Yes □ No | | Revert geometry caching changes | | F2.5 — Optimise DOM | `src/components/*.tsx` | □ Yes □ No | | Restore removed hidden nodes | | F3.1 — External API to build time | `src/**/*.ts`, config files | □ Yes □ No | | Restore client-side fetch | | F3.2 — CSP headers | `netlify.toml` / `vercel.json` | □ Yes □ No | | Remove or relax CSP directives | If **Build Pass?** is **No**, run `npm run build` to see the exact error, revert the failed fix immediately, and re-test before applying the next batch. --- ## References The following upstream deep-dives are not bundled in this tree; do not claim they were read: - `references/performance-deep-dive.md` — LCP, CLS, TBT root cause trees - `references/security-headers.md` — Complete CSP/HSTS/COOP reference - `references/image-optimization.md` — WebP/AVIF conversion pipelines