# Color Contrast Template

Tests contrast ratios, color-blind safe palettes, and focus indicator visibility.

## Prerequisites
- App running at `{{baseUrl}}`
- axe-playwright installed: `npm i -D @axe-core/playwright`
- Page under test: `{{baseUrl}}/{{pagePath}}`

---

## TypeScript

```typescript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Color Contrast', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('{{baseUrl}}/{{pagePath}}');
  });

  // Happy path: no color contrast violations (axe)
  test('has no color contrast violations', async ({ page }) => {
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .withRules(['color-contrast'])
      .analyze();
    expect(results.violations).toEqual([]);
  });

  // Happy path: body text contrast ratio ≥ 4.5:1
  test('body text meets WCAG AA contrast ratio', async ({ page }) => {
    const ratio = await page.evaluate(() => {
      const el = document.querySelector('p, main, [class*="body"]') as HTMLElement;
      if (!el) return null;
      const style = getComputedStyle(el);
      // Simplified check — use axe for full verification
      return style.color !== 'rgba(0, 0, 0, 0)' ? style.color : null;
    });
    expect(ratio).toBeTruthy();
  });

  // Happy path: large text contrast ratio ≥ 3:1
  test('headings have sufficient contrast', async ({ page }) => {
    const results = await new AxeBuilder({ page })
      .withRules(['color-contrast'])
      .include('h1, h2, h3, h4, h5, h6')
      .analyze();
    expect(results.violations).toEqual([]);
  });

  // Happy path: focus indicator meets contrast requirement
  test('focus indicator is visible and meets contrast', async ({ page }) => {
    await page.getByRole('button').first().focus();
    const outline = await page.getByRole('button').first().evaluate(el => {
      const s = getComputedStyle(el, ':focus');
      return {
        outlineWidth: parseFloat(s.outlineWidth),
        outlineColor: s.outlineColor,
        outlineStyle: s.outlineStyle,
      };
    });
    expect(outline.outlineWidth).toBeGreaterThanOrEqual(2);
    expect(outline.outlineColor).not.toBe('rgba(0, 0, 0, 0)');
  });

  // Happy path: error text contrast
  test('error messages have sufficient contrast', async ({ page }) => {
    await page.goto('{{baseUrl}}/{{formPath}}');
    await page.getByRole('button', { name: /submit/i }).click();
    const results = await new AxeBuilder({ page })
      .withRules(['color-contrast'])
      .include('[class*="error"], [role="alert"]')
      .analyze();
    expect(results.violations).toEqual([]);
  });

  // Happy path: no information conveyed by color alone
  test('status badges use text or icon in addition to color', async ({ page }) => {
    const badges = page.getByRole('status');
    const count = await badges.count();
    for (let i = 0; i < count; i++) {
      const text = await badges.nth(i).textContent();
      const ariaLabel = await badges.nth(i).getAttribute('aria-label');
      expect(text?.trim() || ariaLabel).toBeTruthy();
    }
  });

  // Edge case: full page axe scan for all WCAG 2.1 AA issues
  test('full page passes WCAG 2.1 AA axe scan', async ({ page }) => {
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .exclude('{{knownExcludedSelector}}')
      .analyze();
    if (results.violations.length > 0) {
      const messages = results.violations.map(v =>
        `${v.id}: ${v.description} — ${v.nodes.map(n => n.target).join(', ')}`
      ).join('\n');
      throw new Error(`Axe violations:\n${messages}`);
    }
  });
});
```

---

## JavaScript

```javascript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;

test.describe('Color Contrast', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('{{baseUrl}}/{{pagePath}}');
  });

  test('no color contrast violations', async ({ page }) => {
    const results = await new AxeBuilder({ page })
      .withRules(['color-contrast'])
      .analyze();
    expect(results.violations).toEqual([]);
  });

  test('focus indicator is visible', async ({ page }) => {
    await page.getByRole('button').first().focus();
    const outlineWidth = await page.getByRole('button').first().evaluate(
      el => parseFloat(getComputedStyle(el).outlineWidth)
    );
    expect(outlineWidth).toBeGreaterThanOrEqual(2);
  });

  test('status badges use text not just color', async ({ page }) => {
    const badges = page.getByRole('status');
    const count = await badges.count();
    for (let i = 0; i < count; i++) {
      const text = await badges.nth(i).textContent();
      const label = await badges.nth(i).getAttribute('aria-label');
      expect((text?.trim()) || label).toBeTruthy();
    }
  });

  test('full page passes WCAG 2.1 AA', async ({ page }) => {
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();
    expect(results.violations).toEqual([]);
  });
});
```

## Variants
| Variant | Description |
|---------|-------------|
| Contrast violations | axe color-contrast rule → no violations |
| Body text contrast | Text color non-transparent |
| Heading contrast | axe include h1-h6 → no violations |
| Focus indicator | outline-width ≥ 2px and non-transparent |
| Error text contrast | Error messages pass axe |
| Color-only info | Badges have text or aria-label |
| Full axe scan | WCAG 2.1 AA complete scan |
