---
name: scroll-capture-audit
description: 'Run a full audit of a web app for invisible or blank content by scroll-driving each page to the bottom before screenshotting. Use when asked to check for invisible content, verify below the fold, run a screenshot audit, or investigate blank section bugs. Fixes the core phantom-finding trap: fullPage screenshots photograph whileInView / opacity:0 reveal-on-scroll states as blank before they have fired, producing false "empty section" reports — this skill settles all animations by scrolling first, then re-measures visibility, so only real blank sections are flagged.'
---

## When to invoke

- Any full-page screenshot audit of a web app where scroll-reveal animations may be present
- Investigating "blank below the fold" or "invisible content" complaints
- Pre-ship visual QA on pages that use `whileInView`, `IntersectionObserver`, opacity/transform-on-scroll, or AOS-style libraries
- Verifying that a prior "blank section" finding was a phantom (scroll-triggered animation) vs. a real bug

## Steps

1. **Identify routes to audit.**
   Collect the list of URL paths to check. For Next.js, scan `src/app/**/page.tsx` (App Router) or `pages/**/*.tsx` (Pages Router) to enumerate routes. For other frameworks, use your routing config. Build a `ROUTES` array in the test file.

2. **Write the Playwright scroll-settle routine.**
   Create a test file (e.g., `tests/e2e/scroll-capture-audit.spec.ts`):

   ```ts
   import { test, expect } from '@playwright/test';

   const BASE_URL = process.env.AUDIT_BASE_URL ?? 'http://localhost:3000';

   // Populate with routes discovered in Step 1
   const ROUTES = [
     '/',
     // add more routes here
   ];

   async function scrollSettleAndCapture(page: any, route: string) {
     await page.goto(`${BASE_URL}${route}`, { waitUntil: 'networkidle' });

     // Scroll incrementally so IntersectionObserver / whileInView fires at each viewport
     const scrollHeight: number = await page.evaluate(() => document.body.scrollHeight);
     const viewportHeight: number = page.viewportSize()?.height ?? 900;
     let scrollY = 0;
     while (scrollY < scrollHeight) {
       scrollY = Math.min(scrollY + viewportHeight, scrollHeight);
       await page.evaluate((y: number) => window.scrollTo({ top: y, behavior: 'instant' }), scrollY);
       await page.waitForTimeout(350); // allow reveal transitions to complete
     }
     // Scroll back to top so screenshot starts at the top, but animations are settled
     await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
     await page.waitForTimeout(200);

     return page.screenshot({ fullPage: true, path: `test-results/audit/${route.replace(/\//g, '_') || 'root'}.png` });
   }

   for (const route of ROUTES) {
     test(`scroll-audit: ${route}`, async ({ page }) => {
       await scrollSettleAndCapture(page, route);

       // Measure opacity of every element that carries a data-reveal or Framer Motion attribute
       // after the scroll-settle pass — this is the gate that classifies real vs phantom blanks
       const invisibleSections = await page.evaluate(() => {
         const candidates = Array.from(
           document.querySelectorAll('[data-section], section, [class*="section"], [class*="reveal"]')
         );
         return candidates
           .filter(el => {
             const style = window.getComputedStyle(el);
             const rect = el.getBoundingClientRect();
             const opacity = parseFloat(style.opacity);
             const visibility = style.visibility;
             const display = style.display;
             const hasSize = rect.width > 0 && rect.height > 0;
             return hasSize && (opacity < 0.05 || visibility === 'hidden' || display === 'none');
           })
           .map(el => ({
             tag: el.tagName,
             id: el.id,
             classes: el.className,
             opacity: window.getComputedStyle(el).opacity,
             visibility: window.getComputedStyle(el).visibility,
           }));
       });

       // Log for human review — fail the test only if genuinely invisible post-scroll
       if (invisibleSections.length > 0) {
         console.warn(`[scroll-audit] ${route} — ${invisibleSections.length} invisible section(s) after scroll-settle:`, JSON.stringify(invisibleSections, null, 2));
       }

       // Assertion: zero sized-elements with opacity < 0.05 after scroll-settle = no real blanks
       expect(invisibleSections).toHaveLength(0);
     });
   }
   ```

3. **Create the output directory and run.**
   ```bash
   mkdir -p test-results/audit
   npx playwright test tests/e2e/scroll-capture-audit.spec.ts --project=desktop
   # For mobile:
   npx playwright test tests/e2e/scroll-capture-audit.spec.ts --project=iphone
   ```

4. **Inspect screenshots.**
   Screenshots land in `test-results/audit/`. Open each PNG. A real blank section is blank in the screenshot AND flagged by the opacity measurement. A phantom finding is one that was blank in a naïve fullPage shot but is visible here (animation fired).

5. **Triage findings.**
   - Element present in screenshot + opacity ≥ 0.05 → visible, not a bug.
   - Element absent in screenshot + opacity < 0.05 post-scroll → real blank bug. File it.
   - Element absent in naïve fullPage shot but present here → confirmed phantom / scroll-reveal; close the finding.

6. **Iterate on routes list** if new pages are added. Keep the `ROUTES` array in sync with the app's route tree.

## Verification

Each check below proves a specific property of the audit's correctness:

| Check | Command / Measurement | What it proves |
|---|---|---|
| Screenshots exist for every route | `ls test-results/audit/*.png \| wc -l` equals `ROUTES.length` | Every route was captured; no route silently skipped |
| Scroll-settled state reached | Playwright `waitForTimeout(350)` per scroll step + final `waitForTimeout(200)` | Reveal transitions (typically 200–600 ms) completed before measurement |
| Opacity measurement runs post-scroll | `invisibleSections` query runs AFTER `window.scrollTo({ top: 0 })` settle | Measurements reflect post-animation DOM state, not mid-animation |
| No phantom findings | Element flagged invisible must be invisible in the screenshot AND report opacity < 0.05 | Both channels agree — screenshot + DOM measurement corroborate each other |
| Real blank confirmed | Take a naïve `fullPage` shot WITHOUT scrolling (`page.goto` only) and compare to scroll-settled shot | If naïve shot is blank but settled shot is populated, it was a phantom — scroll-reveal worked correctly |
| Mobile parity | Re-run with `--project=iphone`; compare mobile screenshots against desktop ones | Ensures scroll-triggered reveals also fire on the mobile viewport, not just desktop |
