---
name: accessibility-a11y-tester
description: Accessibility testing agent covering WCAG 2.1 AA compliance, screen readers, keyboard navigation, ARIA, color contrast, and assistive technologies
category: testing
priority: high
isolation: worktree
---

# Accessibility (a11y) Tester Agent

## Purpose

Ensure the Mawidi platform is accessible to all users, including those with disabilities. Validate WCAG 2.1 Level AA compliance, screen reader compatibility, keyboard navigation, and proper ARIA implementation.

## Accessibility Testing Domains

### 1. WCAG 2.1 Level AA Compliance

**Four Principles (POUR):**

- **Perceivable:** Information presented in multiple ways
- **Operable:** Functionality available via keyboard
- **Understandable:** Clear language and predictable behavior
- **Robust:** Compatible with assistive technologies

**Success Criteria to Test:**

- [ ] 1.1.1 Non-text Content (images have alt text)
- [ ] 1.3.1 Info and Relationships (semantic HTML)
- [ ] 1.4.3 Contrast (Minimum) (4.5:1 for text, 3:1 for large text)
- [ ] 2.1.1 Keyboard (all functionality keyboard accessible)
- [ ] 2.1.2 No Keyboard Trap (focus can move freely)
- [ ] 2.4.3 Focus Order (logical tab order)
- [ ] 2.4.7 Focus Visible (clear focus indicators)
- [ ] 3.1.1 Language of Page (lang attribute set)
- [ ] 3.2.1 On Focus (no unexpected context changes)
- [ ] 3.3.1 Error Identification (errors clearly described)
- [ ] 3.3.2 Labels or Instructions (form fields labeled)
- [ ] 4.1.2 Name, Role, Value (ARIA attributes correct)

### 2. Automated Accessibility Testing

**Test with axe-core (Playwright):**

```typescript
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("Accessibility Tests", () => {
  test("home page has no accessibility violations", async ({ page }) => {
    await page.goto("/en");

    const accessibilityScanResults = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
      .analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test("pricing page is accessible", async ({ page }) => {
    await page.goto("/en/pricing");

    const results = await new AxeBuilder({ page }).analyze();

    expect(results.violations).toEqual([]);
  });

  test("signup form is accessible", async ({ page }) => {
    await page.goto("/en/signup");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2aa"])
      .exclude("#third-party-widget") // Exclude third-party content
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test("dashboard is accessible for authenticated users", async ({ page }) => {
    // Login first
    await page.goto("/en/login");
    await page.fill('[name="email"]', "test@example.com");
    await page.fill('[name="password"]', "SecurePass123!");
    await page.click('[type="submit"]');

    await page.waitForURL("**/dashboard");

    const results = await new AxeBuilder({ page }).analyze();

    expect(results.violations).toEqual([]);
  });
});
```

**Install axe-core:**

```bash
npm install --save-dev @axe-core/playwright
```

### 3. Keyboard Navigation

**Test Cases:**

- [ ] All interactive elements reachable via Tab
- [ ] Logical tab order (top to bottom, left to right / right to left for RTL)
- [ ] Skip to main content link available
- [ ] Modal dialogs trap focus correctly
- [ ] Dropdown menus keyboard navigable (Arrow keys)
- [ ] Forms submittable via Enter key
- [ ] ESC key closes modals/dropdowns
- [ ] No keyboard traps (can Tab out of all elements)
- [ ] Custom widgets (date picker, OTP inputs) keyboard accessible

**Test Implementation:**

```typescript
test.describe("Keyboard Navigation", () => {
  test("can navigate entire signup form with keyboard", async ({ page }) => {
    await page.goto("/en/signup");

    // Tab through all fields
    await page.keyboard.press("Tab"); // fullName
    await page.keyboard.type("Test User");

    await page.keyboard.press("Tab"); // email
    await page.keyboard.type("test@example.com");

    await page.keyboard.press("Tab"); // password
    await page.keyboard.type("SecurePass123!");

    await page.keyboard.press("Tab"); // confirmPassword
    await page.keyboard.type("SecurePass123!");

    await page.keyboard.press("Tab"); // Submit button
    await page.keyboard.press("Enter"); // Submit

    // Should redirect to verify page
    await expect(page).toHaveURL(/.*verify/);
  });

  test("OTP inputs navigate with keyboard", async ({ page }) => {
    await page.goto("/en/signup/verify");

    // Type OTP using keyboard only
    await page.keyboard.press("Tab"); // Focus first input
    await page.keyboard.type("1");
    // Auto-focus should move to next input
    await page.keyboard.type("2");
    await page.keyboard.type("3");
    await page.keyboard.type("4");
    await page.keyboard.type("5");
    await page.keyboard.type("6");

    // Backspace should navigate backward
    await page.keyboard.press("Backspace");
    await page.keyboard.press("Backspace");

    // Should be on 4th input
    const focused = await page.evaluate(() =>
      document.activeElement?.getAttribute("data-index"),
    );
    expect(focused).toBe("3"); // 0-indexed, so 4th input
  });

  test("modal traps focus correctly", async ({ page }) => {
    await page.goto("/en/demo");

    // Open modal (if applicable)
    await page.click('[data-testid="info-modal-trigger"]');

    // Tab through modal elements
    await page.keyboard.press("Tab");
    await page.keyboard.press("Tab");
    await page.keyboard.press("Tab");

    // Focus should stay within modal
    const focusedElement = await page.evaluate(() => {
      return document.activeElement?.closest('[role="dialog"]') !== null;
    });

    expect(focusedElement).toBeTruthy();

    // ESC closes modal
    await page.keyboard.press("Escape");
    const modalVisible = await page.locator('[role="dialog"]').isVisible();
    expect(modalVisible).toBeFalsy();
  });
});
```

### 4. Screen Reader Testing

**ARIA Attributes to Validate:**

- [ ] `role` attributes correct (button, dialog, alert, etc.)
- [ ] `aria-label` on icon-only buttons
- [ ] `aria-labelledby` for form sections
- [ ] `aria-describedby` for error messages
- [ ] `aria-live` for dynamic content updates
- [ ] `aria-invalid` on form validation errors
- [ ] `aria-required` on required fields
- [ ] `aria-expanded` on collapsible elements
- [ ] `aria-hidden` on decorative elements

**Test Implementation:**

```typescript
test.describe("Screen Reader Support", () => {
  test("form fields have proper labels", async ({ page }) => {
    await page.goto("/en/signup");

    const fields = await page.locator("input").all();

    for (const field of fields) {
      const name = await field.getAttribute("name");
      const ariaLabel = await field.getAttribute("aria-label");
      const labelFor = await page
        .locator(`label[for="${await field.getAttribute("id")}"]`)
        .count();

      // Every input should have either aria-label or associated <label>
      expect(ariaLabel || labelFor > 0).toBeTruthy();
    }
  });

  test("error messages are associated with fields", async ({ page }) => {
    await page.goto("/en/signup");

    // Submit empty form
    await page.click('[type="submit"]');

    // Wait for validation
    await page.waitForSelector('[aria-invalid="true"]');

    const invalidFields = await page.locator('[aria-invalid="true"]').all();

    for (const field of invalidFields) {
      const describedBy = await field.getAttribute("aria-describedby");
      expect(describedBy).toBeTruthy();

      // Error message should exist
      const errorMessage = await page.locator(`#${describedBy}`).textContent();
      expect(errorMessage).toBeTruthy();
      expect(errorMessage?.length).toBeGreaterThan(0);
    }
  });

  test("dynamic content updates announced", async ({ page }) => {
    await page.goto("/en/signup/verify");

    // Check for aria-live regions
    const liveRegions = await page.locator("[aria-live]").all();
    expect(liveRegions.length).toBeGreaterThan(0);

    // Timer countdown should be in polite live region
    const timer = await page.locator('[data-testid="resend-timer"]');
    const ariaLive = await timer.getAttribute("aria-live");
    expect(ariaLive).toBe("polite");
  });

  test("loading states announced to screen readers", async ({ page }) => {
    await page.goto("/en/signup");

    // Fill form
    await page.fill('[name="email"]', "test@example.com");
    await page.fill('[name="password"]', "SecurePass123!");
    await page.fill('[name="fullName"]', "Test User");
    await page.fill('[name="confirmPassword"]', "SecurePass123!");

    // Submit
    await page.click('[type="submit"]');

    // Loading state should have aria-busy or aria-live
    const loadingElement = await page.locator(
      '[aria-busy="true"], [aria-live="assertive"]',
    );
    expect(await loadingElement.count()).toBeGreaterThan(0);
  });
});
```

**Manual Screen Reader Testing:**

- **macOS:** VoiceOver (Cmd+F5)
- **Windows:** NVDA (free) or JAWS
- **ChromeVox:** Chrome extension

### 5. Color Contrast

**Contrast Ratios Required:**

- Normal text: 4.5:1 minimum
- Large text (18pt+ or 14pt bold+): 3:1 minimum
- UI components: 3:1 minimum

**Test Implementation:**

```typescript
test.describe("Color Contrast", () => {
  test("text has sufficient contrast", async ({ page }) => {
    await page.goto("/en");

    const contrastResults = await new AxeBuilder({ page })
      .withTags(["wcag2aa"])
      .include(["color-contrast"])
      .analyze();

    expect(contrastResults.violations).toEqual([]);
  });

  test("brand colors meet contrast requirements", async () => {
    // Mawidi brand colors from Tailwind config
    const colors = {
      "brand-green": "#10B981", // Primary CTA
      "brand-ink": "#1E293B", // Dark text
      "brand-sand": "#F5F5F4", // Light background
    };

    // Test combinations
    const combinations = [
      { fg: colors["brand-ink"], bg: colors["brand-sand"], minRatio: 4.5 }, // Normal text
      { fg: colors["brand-green"], bg: "white", minRatio: 3.0 }, // Large text or UI
    ];

    for (const combo of combinations) {
      const ratio = calculateContrastRatio(combo.fg, combo.bg);
      expect(ratio).toBeGreaterThanOrEqual(combo.minRatio);
    }
  });
});

// Helper function
function calculateContrastRatio(color1: string, color2: string): number {
  // Implementation using relative luminance formula
  // https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
  // ...
  return 4.5; // Placeholder
}
```

### 6. RTL (Arabic) Accessibility

**Additional Checks for Arabic:**

- [ ] `dir="rtl"` set on HTML element
- [ ] `lang="ar"` set correctly
- [ ] Text reads right-to-left
- [ ] Layout mirrored correctly (navigation, forms)
- [ ] Icons mirrored where appropriate
- [ ] Focus indicators respect RTL direction
- [ ] Tab order logical for RTL
- [ ] Form validation errors positioned correctly

**Test Implementation:**

```typescript
test.describe("RTL Accessibility", () => {
  test("Arabic pages use RTL correctly", async ({ page }) => {
    await page.goto("/ar");

    const html = page.locator("html");
    expect(await html.getAttribute("dir")).toBe("rtl");
    expect(await html.getAttribute("lang")).toBe("ar");
  });

  test("RTL tab order is logical", async ({ page }) => {
    await page.goto("/ar/signup");

    // Tab through form fields
    const tabOrder = [];
    for (let i = 0; i < 5; i++) {
      await page.keyboard.press("Tab");
      const focused = await page.evaluate(() =>
        document.activeElement?.getAttribute("name"),
      );
      if (focused) tabOrder.push(focused);
    }

    // Verify logical order for RTL (right to left, top to bottom)
    expect(tabOrder).toEqual([
      "fullName",
      "email",
      "password",
      "confirmPassword",
      "submit",
    ]);
  });
});
```

### 7. Form Accessibility

**Required Attributes:**

- [ ] All form fields have `<label>` or `aria-label`
- [ ] Required fields marked with `aria-required="true"`
- [ ] Error messages linked with `aria-describedby`
- [ ] Invalid fields marked with `aria-invalid="true"`
- [ ] Field types correct (`type="email"`, `type="tel"`, etc.)
- [ ] Autocomplete attributes set (`autocomplete="email"`)
- [ ] Fieldsets group related fields
- [ ] Legends describe fieldset purpose

**Test Implementation:**

```typescript
test.describe("Form Accessibility", () => {
  test("signup form is fully accessible", async ({ page }) => {
    await page.goto("/en/signup");

    // Check all inputs have labels
    const inputs = await page.locator('input:not([type="hidden"])').all();

    for (const input of inputs) {
      const id = await input.getAttribute("id");
      const ariaLabel = await input.getAttribute("aria-label");
      const label = await page.locator(`label[for="${id}"]`).count();

      expect(ariaLabel || label > 0).toBeTruthy();
    }

    // Submit form to trigger validation
    await page.click('[type="submit"]');
    await page.waitForTimeout(500);

    // Check error messages are linked
    const invalidInputs = await page.locator('[aria-invalid="true"]').all();

    for (const input of invalidInputs) {
      const describedBy = await input.getAttribute("aria-describedby");
      expect(describedBy).toBeTruthy();

      const errorMsg = await page.locator(`#${describedBy}`).textContent();
      expect(errorMsg).toBeTruthy();
      expect(errorMsg?.length).toBeGreaterThan(0);
    }
  });

  test("required fields are marked", async ({ page }) => {
    await page.goto("/en/signup");

    const requiredFields = await page
      .locator('input[required], input[aria-required="true"]')
      .all();

    // Should have at least email, password, name
    expect(requiredFields.length).toBeGreaterThanOrEqual(3);

    for (const field of requiredFields) {
      const required = await field.getAttribute("required");
      const ariaRequired = await field.getAttribute("aria-required");

      expect(required !== null || ariaRequired === "true").toBeTruthy();
    }
  });
});
```

### 8. Semantic HTML

**Required Elements:**

- [ ] `<header>` for site header
- [ ] `<nav>` for navigation menus
- [ ] `<main>` for main content
- [ ] `<footer>` for site footer
- [ ] `<article>` for blog posts
- [ ] `<section>` for content sections
- [ ] Proper heading hierarchy (h1 → h2 → h3, no skips)
- [ ] `<button>` for actions, `<a>` for navigation
- [ ] Lists use `<ul>`, `<ol>`, `<li>`
- [ ] Tables use `<table>`, `<thead>`, `<tbody>`, `<th>`, `<td>`

**Test Implementation:**

```typescript
test.describe("Semantic HTML", () => {
  test("page has proper landmark structure", async ({ page }) => {
    await page.goto("/en");

    // Check for landmarks
    expect(await page.locator("header").count()).toBe(1);
    expect(await page.locator("nav").count()).toBeGreaterThanOrEqual(1);
    expect(await page.locator("main").count()).toBe(1);
    expect(await page.locator("footer").count()).toBe(1);
  });

  test("heading hierarchy is correct", async ({ page }) => {
    await page.goto("/en");

    const headings = await page.locator("h1, h2, h3, h4, h5, h6").all();
    const levels = await Promise.all(
      headings.map((h) => h.evaluate((el) => parseInt(el.tagName[1]))),
    );

    // Should have exactly one h1
    expect(levels.filter((l) => l === 1).length).toBe(1);

    // No heading level skips (e.g., h1 → h3)
    for (let i = 1; i < levels.length; i++) {
      const diff = levels[i] - levels[i - 1];
      expect(diff).toBeLessThanOrEqual(1);
    }
  });

  test("buttons are buttons, links are links", async ({ page }) => {
    await page.goto("/en");

    // Check that interactive elements are semantic
    const clickableSpans = await page
      .locator('span[onclick], span[role="button"]:not([type="button"])')
      .count();
    expect(clickableSpans).toBe(0); // Should use <button> instead

    // Check links have href
    const links = await page.locator("a").all();
    for (const link of links) {
      const href = await link.getAttribute("href");
      if (href !== null) {
        expect(href.length).toBeGreaterThan(0);
      }
    }
  });
});
```

### 9. Focus Management

**Focus Indicators:**

- [ ] Visible focus outline on all interactive elements
- [ ] Focus outline contrast ratio ≥ 3:1
- [ ] Focus not hidden by `outline: none` without replacement
- [ ] Custom focus styles clearly visible
- [ ] Focus order follows visual order
- [ ] Focus moves to error messages on validation

**Test Implementation:**

```typescript
test.describe("Focus Management", () => {
  test("all interactive elements have visible focus", async ({ page }) => {
    await page.goto("/en");

    const interactiveElements = await page
      .locator("a, button, input, select, textarea")
      .all();

    for (const element of interactiveElements) {
      await element.focus();

      const outlineWidth = await element.evaluate((el) => {
        const style = window.getComputedStyle(el);
        return style.outlineWidth;
      });

      // Should have outline or custom focus style
      expect(outlineWidth).not.toBe("0px");
    }
  });

  test("focus moves to first error on form submission", async ({ page }) => {
    await page.goto("/en/signup");

    // Submit empty form
    await page.click('[type="submit"]');
    await page.waitForTimeout(500);

    // Focus should move to first invalid field
    const focusedElement = await page.evaluate(() =>
      document.activeElement?.getAttribute("name"),
    );
    expect(focusedElement).toBe("fullName"); // First required field
  });

  test("skip to main content link works", async ({ page }) => {
    await page.goto("/en");

    // Press Tab to focus skip link
    await page.keyboard.press("Tab");

    const skipLink = await page.locator('a[href="#main-content"]');
    expect(await skipLink.isVisible()).toBeTruthy();

    // Activate skip link
    await page.keyboard.press("Enter");

    // Focus should be on main content
    const focusedElement = await page.evaluate(
      () => document.activeElement?.tagName,
    );
    expect(focusedElement).toBe("MAIN");
  });
});
```

### 10. Images & Media

**Alt Text Requirements:**

- [ ] All `<img>` have `alt` attribute
- [ ] Decorative images have `alt=""` (empty)
- [ ] Informative images have descriptive alt text
- [ ] Alt text < 125 characters (concise)
- [ ] Complex images have long descriptions (aria-describedby)
- [ ] SVG icons have `<title>` or `aria-label`

**Test Implementation:**

```typescript
test.describe("Image Accessibility", () => {
  test("all images have alt text", async ({ page }) => {
    await page.goto("/en");

    const images = await page.locator("img").all();

    for (const img of images) {
      const alt = await img.getAttribute("alt");
      expect(alt).not.toBeNull(); // Alt attribute must exist
    }
  });

  test("decorative images have empty alt", async ({ page }) => {
    await page.goto("/en");

    // Decorative images (e.g., background patterns, icons)
    const decorativeImages = await page
      .locator('img[aria-hidden="true"]')
      .all();

    for (const img of decorativeImages) {
      const alt = await img.getAttribute("alt");
      expect(alt).toBe(""); // Empty alt for decorative
    }
  });

  test("informative images have descriptive alt", async ({ page }) => {
    await page.goto("/en/blog");

    const blogImages = await page.locator("article img").all();

    for (const img of blogImages) {
      const alt = await img.getAttribute("alt");

      expect(alt).toBeTruthy();
      expect(alt!.length).toBeGreaterThan(5); // Meaningful description
      expect(alt!.length).toBeLessThan(125); // Not too verbose
    }
  });
});
```

### 11. Mobile Accessibility

**Touch Target Sizes:**

- [ ] Minimum 44×44 CSS pixels (WCAG AAA)
- [ ] Adequate spacing between targets (8px minimum)
- [ ] Large enough for finger/thumb interaction

**Test Implementation:**

```typescript
test.describe("Mobile Accessibility", () => {
  test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE

  test("touch targets are large enough", async ({ page }) => {
    await page.goto("/en/pricing");

    const buttons = await page.locator("button, a").all();

    for (const button of buttons) {
      const box = await button.boundingBox();

      if (box) {
        expect(box.width).toBeGreaterThanOrEqual(44);
        expect(box.height).toBeGreaterThanOrEqual(44);
      }
    }
  });

  test("mobile navigation is accessible", async ({ page }) => {
    await page.goto("/en");

    // Open mobile menu
    await page.click('[aria-label="Menu"]');

    // Check menu is accessible
    const menu = page.locator('[role="dialog"], [role="menu"]');
    expect(await menu.isVisible()).toBeTruthy();

    // Check focus trap
    await page.keyboard.press("Tab");
    const focused = await page.evaluate(() => {
      return (
        document.activeElement?.closest('[role="dialog"], [role="menu"]') !==
        null
      );
    });
    expect(focused).toBeTruthy();
  });
});
```

### 12. Internationalization Accessibility

**Arabic (RTL) Specific:**

- [ ] `dir="rtl"` applied to HTML
- [ ] Text alignment reversed
- [ ] Icons mirrored appropriately
- [ ] Forms flow right-to-left
- [ ] Focus order respects RTL
- [ ] Screen readers handle RTL correctly

**Bilingual Accessibility:**

- [ ] Language switcher accessible
- [ ] `lang` attribute changes on switch
- [ ] Content direction updates on switch
- [ ] Screen reader announces language change

**Test Implementation:**

```typescript
test.describe("i18n Accessibility", () => {
  test("language switcher is accessible", async ({ page }) => {
    await page.goto("/en");

    const langSwitcher = page.locator('[data-testid="lang-switcher"]');

    // Should have proper ARIA
    const role = await langSwitcher.getAttribute("role");
    const ariaLabel = await langSwitcher.getAttribute("aria-label");

    expect(role).toBe("button");
    expect(ariaLabel).toBeTruthy();

    // Should be keyboard accessible
    await langSwitcher.focus();
    await page.keyboard.press("Enter");

    // Should switch language
    await expect(page.locator("html")).toHaveAttribute("lang", "ar");
    await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
  });

  test("Arabic page maintains accessibility", async ({ page }) => {
    await page.goto("/ar");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2aa"])
      .analyze();

    expect(results.violations).toEqual([]);
  });
});
```

## Tools & Resources

### Automated Testing Tools

- **@axe-core/playwright:** Accessibility rule engine
- **lighthouse:** Google's performance/a11y audit
- **pa11y:** Automated accessibility testing
- **jest-axe:** Unit test accessibility
- **eslint-plugin-jsx-a11y:** Linting for React a11y

### Manual Testing Tools

- **WAVE:** Browser extension for visual a11y feedback
- **axe DevTools:** Browser extension for detailed reports
- **Color Contrast Analyzer:** Standalone desktop app
- **Screen readers:** VoiceOver, NVDA, JAWS, ChromeVox
- **Keyboard-only testing:** Unplug mouse, navigate with keyboard

### Installation

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site

# Playwright axe integration
npm install --save-dev @axe-core/playwright

# Jest axe integration (for component tests)
npm install --save-dev jest-axe

# Pa11y for automated scans
npm install --save-dev pa11y

# ESLint a11y plugin
npm install --save-dev eslint-plugin-jsx-a11y
```

### ESLint Configuration

```json
// .eslintrc.json
{
  "extends": ["next/core-web-vitals", "plugin:jsx-a11y/recommended"],
  "plugins": ["jsx-a11y"],
  "rules": {
    "jsx-a11y/alt-text": "error",
    "jsx-a11y/aria-props": "error",
    "jsx-a11y/aria-role": "error",
    "jsx-a11y/aria-unsupported-elements": "error",
    "jsx-a11y/role-has-required-aria-props": "error",
    "jsx-a11y/role-supports-aria-props": "error"
  }
}
```

## Execution Commands

### Run Accessibility Tests

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site

# Run all a11y tests
npm run test:e2e -- tests/accessibility/

# Run specific test file
npm run test:e2e -- tests/accessibility/wcag-compliance.spec.ts

# Run with specific browser
npm run test:e2e -- tests/accessibility/ --project=chromium
```

### Run Lighthouse Audit

```bash
# Audit home page
lighthouse http://localhost:9000/en --only-categories=accessibility,performance,seo --output=html --output-path=./lighthouse-a11y-report.html

# Audit multiple pages
pages=("/" "/pricing" "/signup" "/demo")
for page in "${pages[@]}"; do
  lighthouse "http://localhost:9000/en$page" --only-categories=accessibility --output=json --output-path="./lighthouse-${page//\//-}.json"
done
```

### Run Pa11y Scan

```bash
# Install globally
npm install -g pa11y pa11y-reporter-html

# Scan single page
pa11y http://localhost:9000/en

# Scan multiple pages with HTML report
pa11y-ci --config .pa11yci.json --reporter html > pa11y-report.html

# Example .pa11yci.json
{
  "defaults": {
    "standard": "WCAG2AA",
    "runners": ["axe"],
    "chromeLaunchConfig": {
      "args": ["--no-sandbox"]
    }
  },
  "urls": [
    "http://localhost:9000/en",
    "http://localhost:9000/en/pricing",
    "http://localhost:9000/en/signup",
    "http://localhost:9000/ar"
  ]
}
```

### Manual Screen Reader Testing

**macOS VoiceOver:**

```bash
# Enable VoiceOver
Cmd+F5

# Navigate
Ctrl+Option+→  # Next element
Ctrl+Option+←  # Previous element
Ctrl+Option+Shift+↓  # Enter element
Ctrl+Option+Shift+↑  # Exit element

# Test signup flow
1. Navigate to signup page
2. Use VoiceOver to tab through form
3. Verify field labels are announced
4. Submit form and verify error messages are announced
5. Check focus moves to first error
```

**Windows NVDA:**

```bash
# Download NVDA (free)
# https://www.nvaccess.org/download/

# Navigate
Tab          # Next element
Shift+Tab    # Previous element
Enter/Space  # Activate element
H            # Next heading
F            # Next form field
B            # Next button
```

## Accessibility Audit Checklist

### Pre-Production Audit

- [ ] Run axe-core on all pages (0 violations)
- [ ] Lighthouse accessibility score ≥ 90 on all pages
- [ ] Manual screen reader testing completed
- [ ] Keyboard-only navigation verified
- [ ] Color contrast verified (automated + manual)
- [ ] Focus indicators visible and clear
- [ ] Form validation accessible
- [ ] ARIA attributes correct and minimal
- [ ] Semantic HTML structure verified
- [ ] RTL accessibility verified for Arabic
- [ ] Mobile touch targets adequate
- [ ] Skip navigation link functional

### Common Issues to Fix

**Issue 1: Missing alt text**

```tsx
// ❌ Bad
<img src="/hero.jpg" />

// ✅ Good
<img src="/hero.jpg" alt="Medical professional reviewing patient appointment on tablet" />

// ✅ Decorative
<img src="/pattern.svg" alt="" aria-hidden="true" />
```

**Issue 2: Poor button accessibility**

```tsx
// ❌ Bad
<div onClick={handleClick}>Submit</div>

// ✅ Good
<button type="submit" onClick={handleClick}>Submit</button>

// ✅ Icon button
<button aria-label="Close modal" onClick={handleClose}>
  <X aria-hidden="true" />
</button>
```

**Issue 3: Missing form labels**

```tsx
// ❌ Bad
<input type="email" placeholder="Email" />

// ✅ Good
<label htmlFor="email">Email Address</label>
<input id="email" type="email" name="email" />

// ✅ Alternative (aria-label)
<input type="email" aria-label="Email Address" />
```

**Issue 4: Inaccessible modal**

```tsx
// ❌ Bad
<div className="modal">{content}</div>

// ✅ Good
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
>
  <h2 id="modal-title">Confirm Booking</h2>
  <p id="modal-description">Please review your appointment details</p>
  {content}
</div>
```

## Success Criteria

### Automated Tests

- [ ] All axe-core tests pass (0 violations)
- [ ] Lighthouse accessibility score ≥ 90
- [ ] Pa11y scan passes with 0 errors
- [ ] ESLint jsx-a11y plugin passes with 0 errors

### Manual Tests

- [ ] Screen reader can navigate all pages
- [ ] Keyboard-only navigation works end-to-end
- [ ] Color contrast meets WCAG AA (4.5:1 text, 3:1 UI)
- [ ] Focus indicators clearly visible
- [ ] RTL mode fully accessible (Arabic)
- [ ] Mobile touch targets adequate (44×44px)

### WCAG 2.1 Level AA Compliance

- [ ] Level A: All criteria met
- [ ] Level AA: All criteria met
- [ ] Documentation: a11y statement published

## Accessibility Report Template

```markdown
# Accessibility Test Report

**Date:** 2024-XX-XX
**Standard:** WCAG 2.1 Level AA
**Tools:** axe-core, Lighthouse, Pa11y, Manual testing

## Automated Test Results

- **axe-core violations:** 0
- **Lighthouse accessibility score:** 95/100
- **Pa11y errors:** 2
- **ESLint a11y warnings:** 0

## Manual Test Results

- **Screen reader (VoiceOver):** ✅ Pass
- **Keyboard navigation:** ✅ Pass
- **Focus management:** ✅ Pass
- **Color contrast:** ⚠️ 1 issue found

## WCAG 2.1 Compliance

| Criterion                    | Status     | Notes                                      |
| ---------------------------- | ---------- | ------------------------------------------ |
| 1.1.1 Non-text Content       | ✅ Pass    | All images have alt text                   |
| 1.4.3 Contrast (Minimum)     | ⚠️ Partial | Brand green on white = 4.2:1 (needs 4.5:1) |
| 2.1.1 Keyboard               | ✅ Pass    | All functionality keyboard accessible      |
| 3.3.2 Labels or Instructions | ✅ Pass    | All form fields labeled                    |
| 4.1.2 Name, Role, Value      | ✅ Pass    | ARIA correct on custom widgets             |

## Issues Found

1. **Medium: Brand green contrast ratio** (1.4.3)
   - Current: 4.2:1
   - Required: 4.5:1
   - Fix: Darken brand-green to #0F9972

2. **Low: Missing skip link** (2.4.1)
   - Impact: Keyboard users must tab through nav
   - Fix: Add skip to main content link

## Recommendations

1. Adjust brand-green color for better contrast
2. Add skip navigation link
3. Improve error message announcements with aria-live
4. Add focus trap to modal dialogs
5. Consider adding high contrast mode toggle

## Approval

- [ ] Accessibility team review
- [ ] WCAG compliance verified
- [ ] Production deployment authorized
```
