---
name: mobile-a11y-tap-targets
description: >
  Run a mobile accessibility pass on a web app: fix tap targets to meet the 44x44px minimum,
  perform a heading hierarchy check to catch orphaned H3s and duplicated/concatenated H1 names,
  and report a per-element fix table mapped to component file and line. Auto-fires on: fix tap targets,
  a11y audit, 44px tap targets, accessibility pass, mobile tap target check, heading hierarchy check.
---

## When to invoke

- Any time an accessibility audit is requested for a web app on mobile/iOS
- When tap targets are reported as too small, or a heading-hierarchy skip is suspected
- Before shipping a new page or component that has interactive elements or headings
- After responsive layout changes that might have shrunk buttons or links below 44px
- As part of a pre-deploy QA pass on the iPhone/WebKit Playwright profile

---

## Steps

### 0. Locate the Playwright config and dev server

```bash
# Confirm Playwright is installed and find the config
ls playwright.config.ts playwright.config.js 2>/dev/null || echo "no playwright config"
# Find the baseURL / start command from the config
grep -E 'baseURL|webServer|command' playwright.config.ts 2>/dev/null | head -20
```

If there is no Playwright config, create a minimal one (see Step 1 fallback note).

Start the dev server in the background if it is not already running:

```bash
# e.g. npm run dev / next dev / vite — adapt to the project
npm run dev &
sleep 4   # give it time to bind
```

### 1. Write the audit script

Create `/tmp/a11y-tap-audit.spec.ts` (adapts to any project — edit `PAGES_TO_AUDIT` for your routes):

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

// Edit this list to cover the routes you want to audit.
// Use '/' for single-page apps, or list every route that matters.
const PAGES_TO_AUDIT: string[] = ['/'];

const MIN_TAP_PX = 44;

interface TapFinding {
  tag: string;
  text: string;
  href: string | null;
  width: number;
  height: number;
  selector: string;
}

interface HeadingFinding {
  level: number;
  text: string;
  issue: string;
}

test.describe('Mobile a11y: tap targets + heading hierarchy', () => {
  for (const route of PAGES_TO_AUDIT) {
    test(`[${route}] tap targets ≥ 44×44px`, async ({ page }) => {
      await page.goto(route);
      await page.waitForLoadState('networkidle');

      const findings: TapFinding[] = await page.evaluate((min) => {
        const els = Array.from(document.querySelectorAll('a, button, [role="button"], [role="link"], input, select, textarea'));
        const bad: TapFinding[] = [];
        for (const el of els) {
          const r = el.getBoundingClientRect();
          // Skip hidden / zero-area elements
          if (r.width === 0 && r.height === 0) continue;
          if (r.width < min || r.height < min) {
            bad.push({
              tag: el.tagName.toLowerCase(),
              text: (el as HTMLElement).innerText?.trim().slice(0, 60) || el.getAttribute('aria-label') || '',
              href: el.getAttribute('href'),
              width: Math.round(r.width),
              height: Math.round(r.height),
              selector: el.className
                ? `${el.tagName.toLowerCase()}.${String(el.className).split(' ').filter(Boolean).join('.')}`
                : el.tagName.toLowerCase(),
            });
          }
        }
        return bad;
      }, MIN_TAP_PX);

      if (findings.length > 0) {
        console.log(`\n=== TAP TARGET FAILURES on ${route} ===`);
        console.table(findings.map(f => ({
          element: `<${f.tag}>`,
          text: f.text,
          size: `${f.width}×${f.height}px`,
          selector: f.selector,
        })));
      }

      expect(findings, `${findings.length} element(s) below 44×44px on ${route}`).toHaveLength(0);
    });

    test(`[${route}] heading hierarchy — no skips, no duplicate H1`, async ({ page }) => {
      await page.goto(route);
      await page.waitForLoadState('networkidle');

      const findings: HeadingFinding[] = await page.evaluate(() => {
        const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
        const issues: HeadingFinding[] = [];
        let prevLevel = 0;

        const h1s = headings.filter(h => h.tagName === 'H1');
        if (h1s.length > 1) {
          // Check for concatenated/duplicated accessible names
          const names = h1s.map(h => (h as HTMLElement).innerText.trim());
          const unique = new Set(names);
          if (unique.size < h1s.length) {
            issues.push({ level: 1, text: names.join(' | '), issue: `Duplicate H1 accessible name (${h1s.length} H1s found)` });
          } else {
            issues.push({ level: 1, text: names.join(' | '), issue: `Multiple H1s (${h1s.length}) — only one H1 per page` });
          }
        }

        for (const h of headings) {
          const level = parseInt(h.tagName[1], 10);
          const text = (h as HTMLElement).innerText.trim().slice(0, 80);
          if (prevLevel > 0 && level > prevLevel + 1) {
            issues.push({
              level,
              text,
              issue: `Skipped from H${prevLevel} → H${level} (expected H${prevLevel + 1})`,
            });
          }
          prevLevel = level;
        }
        return issues;
      });

      if (findings.length > 0) {
        console.log(`\n=== HEADING HIERARCHY FAILURES on ${route} ===`);
        console.table(findings);
      }

      expect(findings, `${findings.length} heading issue(s) on ${route}`).toHaveLength(0);
    });
  }
});
```

### 2. Run the audit against the iPhone profile

```bash
# Run on the iPhone/WebKit profile (adjust profile name to match your playwright.config)
npx playwright test /tmp/a11y-tap-audit.spec.ts --project=iphone 2>&1 | tee /tmp/a11y-tap-results.txt

# If no iphone profile exists, use --project=webkit or run without --project to use the default
npx playwright test /tmp/a11y-tap-audit.spec.ts --project=webkit 2>&1 | tee /tmp/a11y-tap-results.txt
```

### 3. Parse the console output into a fix table

The test stdout includes a `console.table` for each failure category. Parse it:

```bash
grep -A 50 'TAP TARGET FAILURES\|HEADING HIERARCHY FAILURES' /tmp/a11y-tap-results.txt
```

For each failing element, map it to a source file:

```bash
# Search by class name or tag — adapt the selector from the table output
# Example: find where a small <a> with class "nav-link" is rendered
grep -rn 'nav-link\|<a\b' src/ --include='*.tsx' --include='*.jsx' --include='*.html' | head -20
```

### 4. Apply fixes

**Tap target too small:** The reliable fix is to add `min-height: 44px; min-width: 44px` (or the equivalent token) to the element or its wrapper. For icon-only links, use padding to grow the hit area while keeping the visual size:

```css
/* Token-first approach — use your project's spacing tokens */
.icon-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  min-width: 44px;
  padding: var(--space-2, 8px);
}
```

**Heading skip (H1 → H3):** Insert the missing H2, or reclassify the H3 as H2. If the visual style must stay the same, use a semantic heading with a visual-only class:

```html
<!-- Before: -->
<h1>Page Title</h1>
<h3>Subsection</h3>   <!-- skips H2 -->

<!-- After: -->
<h1>Page Title</h1>
<h2 class="visually-h3-style">Subsection</h2>
```

**Multiple H1s:** Keep one `<h1>` as the primary landmark; convert the rest to `<h2>` or use `aria-label` to disambiguate if the content genuinely requires it.

### 5. Re-run to confirm all failures are resolved

```bash
npx playwright test /tmp/a11y-tap-audit.spec.ts --project=iphone 2>&1 | tail -20
```

All tests must report `passed` before claiming the audit is done.

---

## Verification

Each of these checks proves a specific property — run them all, report actual vs. expected.

| Check | Command / action | What it proves |
|---|---|---|
| Tap target assertions pass | `npx playwright test /tmp/a11y-tap-audit.spec.ts --project=iphone` exits 0 | Every `<a>`, `<button>`, and role=button/link element is ≥ 44×44px at iPhone viewport on WebKit |
| Heading assertions pass | Same run — heading test suite green | No H-level skips, no duplicate/concatenated H1 accessible names |
| Fix locations are real | `grep -rn '<selector>' src/` returns the file + line for each fix | The fix table points to actual source, not a phantom |
| Visual regression | Screenshot the repaired page via Playwright: `page.screenshot({ path: '/tmp/after-a11y.png', fullPage: true })` then `Read` the image | Tap target growth (padding/min-height) did not break surrounding layout |
| Headings in DOM order | `page.evaluate(() => [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => h.tagName + ': ' + h.innerText.trim()))` returns a monotonically non-decreasing level sequence | Heading DOM order matches visual order and level progression |

A "done" claim is only valid when all five checks are green and the test run exits 0.
