# Accessibility Guide

## Overview

This design system targets **WCAG 2.2 Level AA** compliance for all static frontend output. This document explains the accessibility decisions made and how to maintain them when extending the system.

---

## Semantic Structure

Every page uses proper HTML5 landmark elements:

```html
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>  <!-- Skip nav -->
  <header role="banner">...</header>                                    <!-- Site header -->
  <nav aria-label="Main navigation">...</nav>                          <!-- Primary nav -->
  <main id="main-content" tabindex="-1">                               <!-- Main content -->
    <section aria-labelledby="section-heading">...</section>
    <aside aria-label="Related content">...</aside>
  </main>
  <footer role="contentinfo">...</footer>                              <!-- Site footer -->
</body>
```

**Rules:**
- One `<h1>` per page — always the page title
- Headings must not skip levels (h1 → h2 → h3, never h1 → h3)
- `<section>` elements should have an accessible name via `aria-labelledby` or `aria-label`
- Use `<article>` for self-contained content (blog posts, cards, team members)
- Use `<aside>` for supplementary content (sidebars, related links)

---

## Focus Management

### Focus Styles

All interactive elements have a visible focus indicator:

```css
:focus-visible {
  outline: 3px solid var(--color-brand);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}
/* Mouse users don't see the ring (cleaner UX) */
:focus:not(:focus-visible) { outline: none; }
```

**Never remove focus styles.** If the default ring doesn't match your design, customize it — don't remove it.

### Modal Focus Trap

When a modal opens:
1. Focus moves to the first focusable element inside the modal
2. Tab/Shift+Tab cycles only within the modal
3. Escape closes the modal and returns focus to the trigger

### Mobile Menu Focus Trap

Same pattern as modals — focus is trapped inside the mobile nav overlay when open.

---

## Color & Contrast

### Minimum Ratios (WCAG 2.2 AA)

| Text type | Minimum ratio |
|-----------|--------------|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 |
| Large text (≥ 18pt / ≥ 14pt bold) | 3:1 |
| UI components & focus indicators | 3:1 |
| Decorative elements | No requirement |

### Automatic Contrast Text

The theme generator (`config/theme.js`) automatically computes whether white or dark text should be used on each generated color:

```js
function getContrastText(hex) {
  // Uses WCAG relative luminance formula
  // Returns '#ffffff' or '#0f172a'
}
```

### Never Use Color Alone

Always pair color with another indicator:
- ✅ Error: red border + error icon + error message text
- ❌ Error: red border only

---

## Interactive Components

### Buttons

- Minimum touch target: **44×44px** (enforced via `min-height: 44px`)
- Icon-only buttons **must** have `aria-label`
- Disabled buttons use `aria-disabled="true"` (not just `disabled`) when they should remain focusable

```html
<!-- Good: descriptive label -->
<button class="btn btn-icon" aria-label="Close dialog">✕</button>

<!-- Good: disabled but focusable with explanation -->
<button class="btn btn-primary" aria-disabled="true" aria-describedby="submit-hint">
  Submit
</button>
<div id="submit-hint" class="sr-only">Complete all required fields to submit.</div>
```

### Links vs Buttons

- Use `<a href="...">` for navigation (changes the URL)
- Use `<button>` for actions (toggles, submits, opens modals)
- Never use `<div>` or `<span>` as interactive elements

### Forms

Every form input must have an associated label:

```html
<!-- Method 1: for/id (preferred) -->
<label for="email">Email address</label>
<input type="email" id="email" />

<!-- Method 2: aria-label (when no visible label) -->
<input type="search" aria-label="Search products" />

<!-- Method 3: aria-labelledby -->
<h2 id="form-title">Contact us</h2>
<form aria-labelledby="form-title">...</form>
```

**Required fields:**
```html
<label for="name">
  Name <span class="required" aria-hidden="true">*</span>
</label>
<input type="text" id="name" required aria-required="true" />
<!-- Note: aria-required is redundant with required, but improves compatibility -->
```

**Error states:**
```html
<input type="email" id="email"
       aria-invalid="true"
       aria-describedby="email-error" />
<div id="email-error" class="form-error" role="alert">
  Please enter a valid email address.
</div>
```

### Accordion

```html
<button class="accordion-trigger"
        aria-expanded="false"          <!-- true when open -->
        aria-controls="panel-id"       <!-- points to panel -->
        id="trigger-id">
  Question text
</button>
<div id="panel-id"
     role="region"
     aria-labelledby="trigger-id">    <!-- labelled by trigger -->
  Answer text
</div>
```

Keyboard support:
- `Enter`/`Space`: Toggle panel
- `Arrow Down`: Move to next trigger
- `Arrow Up`: Move to previous trigger
- `Home`: Move to first trigger
- `End`: Move to last trigger

### Tabs

```html
<div role="tablist" aria-label="Settings sections">
  <button role="tab" aria-selected="true"
          aria-controls="panel-1" id="tab-1">General</button>
  <button role="tab" aria-selected="false"
          aria-controls="panel-2" id="tab-2" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
  General settings content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabindex="0" hidden>
  Security settings content
</div>
```

Keyboard support:
- `Arrow Left`/`Right`: Move between tabs (auto-activates)
- `Home`: First tab
- `End`: Last tab

### Carousel

```html
<div class="carousel"
     role="region"
     aria-roledescription="carousel"
     aria-label="Product screenshots">
  <!-- Live region announces slide changes to screen readers -->
  <div class="sr-only" aria-live="polite" aria-atomic="true"></div>
  ...
</div>
```

- Auto-play pauses on hover and focus
- Each slide has `aria-label="Slide X of Y"`
- Prev/Next buttons have descriptive `aria-label`
- Dot navigation buttons have `aria-label="Go to slide X"`

### Modal / Dialog

```html
<div class="modal-overlay" id="dialog-id" hidden>
  <div class="modal"
       role="dialog"
       aria-modal="true"
       aria-labelledby="dialog-title">
    <h2 id="dialog-title">Dialog Title</h2>
    ...
    <button class="modal-close" aria-label="Close dialog">✕</button>
  </div>
</div>
```

- `aria-modal="true"` tells screen readers to ignore content behind the modal
- `aria-labelledby` points to the dialog's heading
- Escape key closes the modal
- Focus returns to the trigger element on close

---

## Images

```html
<!-- Informative image: describe what it shows -->
<img src="dashboard.png"
     alt="Acme dashboard showing 24 deploys today with a 96% success rate" />

<!-- Decorative image: empty alt, no role -->
<img src="background-pattern.svg" alt="" />

<!-- Functional image (link/button): describe the action -->
<a href="/home">
  <img src="logo.svg" alt="Acme Corp — go to homepage" />
</a>

<!-- Complex image: use aria-describedby for detailed description -->
<img src="chart.png"
     alt="Bar chart showing monthly revenue"
     aria-describedby="chart-desc" />
<div id="chart-desc" class="sr-only">
  January: $120K, February: $145K, March: $132K...
</div>
```

---

## Motion & Animation

All animations respect `prefers-reduced-motion`:

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
```

The carousel also pauses auto-play when `prefers-reduced-motion` is active.

---

## Screen Reader Testing

### VoiceOver (macOS/iOS)
- `Cmd + F5`: Toggle VoiceOver
- `VO + Right Arrow`: Read next element
- `VO + U`: Open rotor (navigate by headings, links, landmarks)

### NVDA (Windows, free)
- `Insert + F7`: List all links/headings
- `H`: Jump to next heading
- `B`: Jump to next button

### Common Issues to Check
1. All images have meaningful alt text
2. Form errors are announced (use `role="alert"` or `aria-live`)
3. Dynamic content changes are announced (use `aria-live`)
4. Modal focus trap works correctly
5. Skip link is the first focusable element
6. Page title changes on navigation

---

## Automated Testing Tools

```bash
# axe-core CLI
npm install -g @axe-core/cli
axe http://localhost:8080/pages/index.html --tags wcag2a,wcag2aa,wcag22aa

# Lighthouse (built into Chrome DevTools)
# Open DevTools → Lighthouse → Accessibility

# WAVE browser extension
# https://wave.webaim.org/extension/
```

Target score: **100/100 on Lighthouse Accessibility** for all pages.