Detailed changes
@@ -33,6 +33,10 @@ agent capabilities through a standardised format.
- [resuming-work-through-lunatask](skills/resuming-work-through-lunatask/SKILL.md):
Resumes previously deferred work from Lunatask. Use when user pastes a
task note, shares a deep link, or references continuing earlier work.
+- [frontend-accessibility](skills/frontend-accessibility/SKILL.md):
+ Generates accessible HTML, React, and frontend code following WCAG 2.2
+ AA guidelines. Focuses on semantic HTML, keyboard accessibility, and
+ screen reader compatibility.
## Installation
@@ -0,0 +1,157 @@
+---
+name: frontend-accessibility
+description: Generates accessible HTML, React, and frontend code following WCAG 2.2 AA guidelines. Use when creating UI components, forms, navigation, or any user-facing frontend code. Focuses on semantic HTML, keyboard accessibility, and screen reader compatibility.
+license: AGPL-3.0-or-later
+metadata:
+ author: Amolith <amolith@secluded.site>
+ version: "1.0"
+---
+
+Write accessible frontend code by default. Semantic HTML covers most needs; ARIA is for gaps HTML can't fill.
+
+## Core principles
+
+1. **Semantic HTML first**: Use `<button>`, `<nav>`, `<main>`, `<form>` - not divs with roles
+2. **Keyboard accessible**: Everything clickable must be focusable and operable with Enter/Space
+3. **Screen reader context**: Labels, alt text, and heading hierarchy convey structure without vision
+4. **No ARIA is better than bad ARIA**: ARIA overrides native semantics and can make things *worse*
+
+## Quick reference
+
+### Images
+```html
+<!-- Informative: describe content -->
+<img src="chart.png" alt="Sales increased 25% from Q1 to Q2">
+
+<!-- Decorative: empty alt -->
+<img src="divider.png" alt="">
+
+<!-- Complex: summarize + link to details -->
+<img src="flowchart.png" alt="User registration flow. Details in following section.">
+```
+
+### Interactive elements
+```html
+<!-- Correct: semantic button -->
+<button onclick="save()">Save</button>
+
+<!-- Wrong: div cosplaying as button -->
+<div onclick="save()" role="button" tabindex="0">Save</div>
+```
+The div requires `role`, `tabindex`, `onkeydown` for Enter/Space, focus styles, and still won't work with voice control. Use `<button>`.
+
+### Forms
+```html
+<label for="email">Email address</label>
+<input type="email" id="email" name="email" required aria-describedby="email-hint">
+<span id="email-hint">We'll never share your email</span>
+```
+Every input needs a visible `<label>` with matching `for`/`id`. Use `aria-describedby` for hints, `aria-invalid` for errors.
+
+### Headings
+```html
+<h1>Page title</h1> <!-- One per page -->
+ <h2>Section</h2> <!-- Don't skip levels -->
+ <h3>Subsection</h3>
+ <h2>Another section</h2>
+```
+Headings create navigable structure. Screen reader users jump by heading level.
+
+### Links
+```html
+<!-- Good: describes destination -->
+<a href="/pricing">View pricing plans</a>
+
+<!-- Bad: meaningless out of context -->
+<a href="/pricing">Click here</a>
+```
+
+## When to use ARIA
+
+Use ARIA only when HTML has no semantic equivalent:
+
+| Need | Solution |
+|------|----------|
+| Button | `<button>` not `role="button"` |
+| Navigation | `<nav>` not `role="navigation"` |
+| Live updates | `aria-live="polite"` (no HTML equivalent) |
+| Current page | `aria-current="page"` (no HTML equivalent) |
+| Expanded state | `aria-expanded="true"` (no HTML equivalent) |
+| Custom widget | ARIA + keyboard handling (last resort) |
+
+## Common violations to avoid
+
+See [antipatterns.md](references/antipatterns.md) for detailed examples.
+
+1. **Missing form labels** - Every input needs `<label for="...">`
+2. **Empty alt on informative images** - `alt=""` marks image as decorative
+3. **Div/span as buttons** - Use `<button>`, not `<div onclick>`
+4. **Missing skip link** - Add "Skip to main content" for keyboard users
+5. **Low color contrast** - 4.5:1 for normal text, 3:1 for large text
+6. **Keyboard traps** - Focus must be able to leave modals/menus
+7. **Missing focus indicators** - Never `outline: none` without replacement
+
+## Motion and animation
+
+Respect user preferences for reduced motion:
+
+```css
+/* Default: full animation */
+.animated {
+ transition: transform 0.3s ease;
+}
+
+/* Reduce or remove for users who prefer it */
+@media (prefers-reduced-motion: reduce) {
+ .animated {
+ transition: none;
+ }
+}
+```
+
+Or start safe and enhance:
+```css
+/* Safe default */
+.animated {
+ transition: none;
+}
+
+/* Only animate if user hasn't requested reduced motion */
+@media (prefers-reduced-motion: no-preference) {
+ .animated {
+ transition: transform 0.3s ease;
+ }
+}
+```
+
+Applies to: transitions, transforms, parallax, auto-playing video, animated illustrations. Essential for users with vestibular disorders.
+
+## Framework-specific notes
+
+### React/JSX
+- `htmlFor` instead of `for`
+- `className` instead of `class`
+- Fragments (`<>`) don't affect accessibility tree
+- Manage focus with refs when content changes dynamically
+
+### Component libraries
+If using Radix, Headless UI, or similar - they handle ARIA. Don't add redundant attributes.
+
+## Validation
+
+Generated code should pass:
+- **axe-core**: Catches ~57% of WCAG issues automatically
+- **Manual keyboard test**: Tab through everything, operate with Enter/Space
+- **Screen reader spot check**: VoiceOver (Mac), NVDA (Windows), Orca (Linux)
+
+Automated tools miss context-dependent issues (alt text quality, heading hierarchy logic, reading order). Human review still required.
+
+## Detailed patterns
+
+See [patterns.md](references/patterns.md) for:
+- Modal dialogs and focus management
+- Custom select/combobox
+- Tabs and tab panels
+- Accordions
+- Toast notifications
+- Data tables
@@ -0,0 +1,255 @@
+# Antipatterns
+
+Common accessibility mistakes and how to fix them.
+
+## Contents
+- [Div soup](#div-soup)
+- [ARIA overuse](#aria-overuse)
+- [Placeholder as label](#placeholder-as-label)
+- [Empty or meaningless alt text](#empty-or-meaningless-alt-text)
+- [Removing focus indicators](#removing-focus-indicators)
+- [Mouse-only interactions](#mouse-only-interactions)
+- [Auto-playing media](#auto-playing-media)
+- [Ignoring motion preferences](#ignoring-motion-preferences)
+- [Color as only indicator](#color-as-only-indicator)
+
+## Div soup
+
+**Wrong:**
+```html
+<div class="nav">
+ <div class="nav-item" onclick="goto('/')">Home</div>
+ <div class="nav-item" onclick="goto('/about')">About</div>
+</div>
+```
+
+**Right:**
+```html
+<nav>
+ <a href="/">Home</a>
+ <a href="/about">About</a>
+</nav>
+```
+
+The div version requires `role="navigation"`, `role="link"`, `tabindex="0"`, keyboard handlers, and focus styles. The semantic version works out of the box.
+
+## ARIA overuse
+
+**Wrong:**
+```html
+<button role="button" aria-label="Submit form" tabindex="0">
+ Submit form
+</button>
+```
+
+**Right:**
+```html
+<button type="submit">Submit form</button>
+```
+
+Problems with the wrong version:
+- `role="button"` is redundant on `<button>`
+- `aria-label` duplicates visible text (use only when no visible text)
+- `tabindex="0"` is redundant on `<button>`
+
+**Rule:** If a native HTML element does what you need, use it without ARIA.
+
+## Placeholder as label
+
+**Wrong:**
+```html
+<input type="email" placeholder="Email address">
+```
+
+**Right:**
+```html
+<label for="email">Email address</label>
+<input type="email" id="email" placeholder="e.g. user@example.com">
+```
+
+Problems with placeholder-only:
+- Disappears when typing (users forget what field is for)
+- Low contrast by default (hard to read)
+- Not announced as field name by all screen readers
+- Placeholder is hint text, not label
+
+## Empty or meaningless alt text
+
+**Wrong:**
+```html
+<img src="graph.png" alt="">
+<img src="graph.png" alt="image">
+<img src="graph.png" alt="graph.png">
+```
+
+**Right:**
+```html
+<img src="graph.png" alt="Line graph showing revenue growth from $1M to $3M over 2024">
+```
+
+- `alt=""` marks image as decorative - only use for truly decorative images
+- "image", "photo", "graph" are meaningless - describe the content
+- Filenames are not descriptions
+
+**For complex images:** Provide brief alt + detailed description nearby:
+```html
+<figure>
+ <img src="flowchart.png" alt="Application deployment workflow">
+ <figcaption>
+ <details>
+ <summary>Flowchart description</summary>
+ <p>1. Developer pushes to main branch. 2. CI runs tests...</p>
+ </details>
+ </figcaption>
+</figure>
+```
+
+## Removing focus indicators
+
+**Wrong:**
+```css
+*:focus {
+ outline: none;
+}
+
+button:focus {
+ outline: 0;
+}
+```
+
+**Right:**
+```css
+button:focus {
+ outline: 2px solid #005fcc;
+ outline-offset: 2px;
+}
+
+/* If you must customize, provide visible alternative */
+button:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
+}
+```
+
+Focus indicators tell keyboard users where they are. Removing them makes the site unusable for keyboard navigation.
+
+## Mouse-only interactions
+
+**Wrong:**
+```jsx
+<div
+ onMouseEnter={() => setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+>
+ <span>Hover for menu</span>
+ {open && <Menu />}
+</div>
+```
+
+**Right:**
+```jsx
+<button
+ onClick={() => setOpen(!open)}
+ onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}
+ aria-expanded={open}
+ aria-haspopup="menu"
+>
+ Menu
+</button>
+{open && <Menu onClose={() => setOpen(false)} />}
+```
+
+- Hover doesn't exist on touch devices
+- Keyboard users can't trigger hover
+- Click/keyboard activation works universally
+
+## Auto-playing media
+
+**Wrong:**
+```html
+<video autoplay>
+ <source src="intro.mp4" type="video/mp4">
+</video>
+```
+
+**Right:**
+```html
+<video controls>
+ <source src="intro.mp4" type="video/mp4">
+ <track kind="captions" src="intro.vtt" srclang="en" label="English">
+</video>
+```
+
+- Auto-play disrupts screen readers
+- Users with vestibular disorders need motion control
+- Background audio conflicts with screen reader audio
+- Always provide captions for video with speech
+
+If auto-play is required:
+```html
+<video autoplay muted>
+```
+And provide visible pause control.
+
+## Ignoring motion preferences
+
+**Wrong:**
+```css
+.hero {
+ animation: parallax 2s infinite;
+}
+
+.button {
+ transition: all 0.5s ease;
+}
+```
+
+**Right:**
+```css
+.hero {
+ animation: parallax 2s infinite;
+}
+
+.button {
+ transition: all 0.5s ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hero {
+ animation: none;
+ }
+
+ .button {
+ transition: none;
+ }
+}
+```
+
+- Animations can trigger vestibular disorders (vertigo, nausea)
+- ~35% of adults over 40 have vestibular dysfunction
+- Check `prefers-reduced-motion` for: parallax, transitions, auto-playing animations, smooth scrolling
+- "Reduce" doesn't mean "remove all" - subtle opacity fades are usually fine
+
+## Color as only indicator
+
+**Wrong:**
+```html
+<span class="status-good">Available</span> <!-- green -->
+<span class="status-bad">Unavailable</span> <!-- red -->
+```
+
+**Right:**
+```html
+<span class="status-good">✓ Available</span>
+<span class="status-bad">✗ Unavailable</span>
+
+<!-- Or with icons -->
+<span class="status-good">
+ <svg aria-hidden="true"><!-- checkmark --></svg>
+ Available
+</span>
+```
+
+- ~8% of men have color vision deficiency
+- Color alone doesn't convey meaning to screen readers
+- Use text, icons, or patterns in addition to color
@@ -0,0 +1,356 @@
+# Accessible Patterns
+
+Complex UI patterns with accessibility built in. For simple elements, prefer semantic HTML without these patterns.
+
+## Contents
+- [Modal dialogs](#modal-dialogs)
+- [Dropdown menus](#dropdown-menus)
+- [Tabs](#tabs)
+- [Accordions](#accordions)
+- [Toast notifications](#toast-notifications)
+- [Data tables](#data-tables)
+- [Skip links](#skip-links)
+- [Forms with validation](#forms-with-validation)
+
+## Modal dialogs
+
+```html
+<button onclick="openModal()" aria-haspopup="dialog">
+ Open settings
+</button>
+
+<div
+ id="modal"
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="modal-title"
+ hidden
+>
+ <h2 id="modal-title">Settings</h2>
+ <div><!-- modal content --></div>
+ <button onclick="closeModal()">Close</button>
+</div>
+```
+
+**Requirements:**
+1. `role="dialog"` and `aria-modal="true"`
+2. Label via `aria-labelledby` pointing to heading
+3. Focus moves into modal on open (to first focusable element or the dialog itself)
+4. Focus trapped inside modal while open
+5. Escape key closes modal
+6. Focus returns to trigger button on close
+7. Background content hidden from screen readers (via `aria-modal` or `inert`)
+
+**Focus trap pattern (JS):**
+```js
+function trapFocus(modal) {
+ const focusable = modal.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ modal.addEventListener('keydown', (e) => {
+ if (e.key !== 'Tab') return;
+
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ });
+}
+```
+
+## Dropdown menus
+
+```html
+<div class="menu-container">
+ <button
+ id="menu-button"
+ aria-expanded="false"
+ aria-haspopup="menu"
+ aria-controls="menu"
+ >
+ Options
+ </button>
+
+ <ul id="menu" role="menu" aria-labelledby="menu-button" hidden>
+ <li role="menuitem" tabindex="-1">Edit</li>
+ <li role="menuitem" tabindex="-1">Duplicate</li>
+ <li role="menuitem" tabindex="-1">Delete</li>
+ </ul>
+</div>
+```
+
+**Requirements:**
+1. `aria-expanded` toggles on button
+2. `aria-haspopup="menu"` on trigger
+3. `role="menu"` on container, `role="menuitem"` on items
+4. Arrow keys navigate items (not Tab)
+5. Enter/Space activates item
+6. Escape closes and returns focus to button
+7. Home/End jump to first/last item
+
+**Keyboard handling:**
+```js
+menu.addEventListener('keydown', (e) => {
+ const items = [...menu.querySelectorAll('[role="menuitem"]')];
+ const current = items.indexOf(document.activeElement);
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ items[(current + 1) % items.length].focus();
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ items[(current - 1 + items.length) % items.length].focus();
+ break;
+ case 'Home':
+ e.preventDefault();
+ items[0].focus();
+ break;
+ case 'End':
+ e.preventDefault();
+ items[items.length - 1].focus();
+ break;
+ case 'Escape':
+ closeMenu();
+ button.focus();
+ break;
+ }
+});
+```
+
+## Tabs
+
+```html
+<div class="tabs">
+ <div role="tablist" aria-label="Account settings">
+ <button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
+ Profile
+ </button>
+ <button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">
+ Security
+ </button>
+ <button role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">
+ Notifications
+ </button>
+ </div>
+
+ <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
+ <!-- Profile content -->
+ </div>
+ <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
+ <!-- Security content -->
+ </div>
+ <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
+ <!-- Notifications content -->
+ </div>
+</div>
+```
+
+**Requirements:**
+1. `role="tablist"` container with `role="tab"` buttons
+2. `aria-selected="true"` on active tab
+3. `tabindex="-1"` on inactive tabs (arrow key navigation, not Tab)
+4. `role="tabpanel"` for content, linked via `aria-controls`/`aria-labelledby`
+5. Left/Right arrows move between tabs
+6. Home/End jump to first/last tab
+
+## Accordions
+
+```html
+<div class="accordion">
+ <h3>
+ <button
+ aria-expanded="false"
+ aria-controls="section1"
+ id="accordion1"
+ >
+ Section 1
+ </button>
+ </h3>
+ <div id="section1" role="region" aria-labelledby="accordion1" hidden>
+ <p>Content for section 1</p>
+ </div>
+
+ <h3>
+ <button
+ aria-expanded="false"
+ aria-controls="section2"
+ id="accordion2"
+ >
+ Section 2
+ </button>
+ </h3>
+ <div id="section2" role="region" aria-labelledby="accordion2" hidden>
+ <p>Content for section 2</p>
+ </div>
+</div>
+```
+
+**Requirements:**
+1. Headings wrap buttons (preserves heading hierarchy for screen readers)
+2. `aria-expanded` indicates open/closed state
+3. `aria-controls` links button to panel
+4. Panel has `role="region"` and `aria-labelledby`
+5. Space/Enter toggles the panel
+
+## Toast notifications
+
+```html
+<!-- Container for all toasts -->
+<div
+ role="status"
+ aria-live="polite"
+ aria-atomic="true"
+ class="toast-container"
+>
+ <!-- Toasts injected here -->
+</div>
+
+<!-- For urgent/error messages -->
+<div
+ role="alert"
+ aria-live="assertive"
+ class="alert-container"
+>
+ <!-- Alerts injected here -->
+</div>
+```
+
+**Usage:**
+- `aria-live="polite"`: Announced after current speech (success messages, updates)
+- `aria-live="assertive"`: Interrupts immediately (errors, critical alerts)
+- `role="status"`: Implicit `aria-live="polite"`
+- `role="alert"`: Implicit `aria-live="assertive"`
+
+**Requirements:**
+1. Live region must exist in DOM before content is added
+2. Don't use for navigation or frequent updates (too noisy)
+3. Error toasts should be `assertive`, success should be `polite`
+4. Provide dismiss button if toast persists
+5. Don't auto-dismiss error messages
+
+## Data tables
+
+```html
+<table>
+ <caption>Quarterly sales by region</caption>
+ <thead>
+ <tr>
+ <th scope="col">Region</th>
+ <th scope="col">Q1</th>
+ <th scope="col">Q2</th>
+ <th scope="col">Q3</th>
+ <th scope="col">Q4</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th scope="row">North</th>
+ <td>$1.2M</td>
+ <td>$1.4M</td>
+ <td>$1.1M</td>
+ <td>$1.6M</td>
+ </tr>
+ <tr>
+ <th scope="row">South</th>
+ <td>$0.9M</td>
+ <td>$1.0M</td>
+ <td>$1.2M</td>
+ <td>$1.3M</td>
+ </tr>
+ </tbody>
+</table>
+```
+
+**Requirements:**
+1. `<caption>` describes the table's purpose
+2. `<th>` for headers with `scope="col"` or `scope="row"`
+3. Don't use tables for layout
+4. For complex tables with multi-level headers, use `id`/`headers` attributes
+5. Sortable columns need `aria-sort="ascending|descending|none"`
+
+## Skip links
+
+```html
+<body>
+ <a href="#main-content" class="skip-link">Skip to main content</a>
+
+ <header><!-- navigation --></header>
+
+ <main id="main-content" tabindex="-1">
+ <!-- page content -->
+ </main>
+</body>
+```
+
+```css
+.skip-link {
+ position: absolute;
+ left: -9999px;
+ z-index: 999;
+ padding: 1em;
+ background: #000;
+ color: #fff;
+}
+
+.skip-link:focus {
+ left: 50%;
+ transform: translateX(-50%);
+ top: 0;
+}
+```
+
+**Requirements:**
+1. First focusable element on page
+2. Links to `<main>` or primary content area
+3. Visible on focus (hidden until needed)
+4. Target element needs `tabindex="-1"` for programmatic focus
+
+## Forms with validation
+
+```html
+<form novalidate>
+ <div class="field">
+ <label for="email">Email address <span aria-hidden="true">*</span></label>
+ <input
+ type="email"
+ id="email"
+ name="email"
+ required
+ aria-required="true"
+ aria-invalid="false"
+ aria-describedby="email-error"
+ >
+ <span id="email-error" class="error" hidden></span>
+ </div>
+
+ <button type="submit">Submit</button>
+</form>
+```
+
+**On validation error:**
+```js
+function showError(input, message) {
+ const errorEl = document.getElementById(input.id + '-error');
+ input.setAttribute('aria-invalid', 'true');
+ errorEl.textContent = message;
+ errorEl.hidden = false;
+ input.focus();
+}
+```
+
+**Requirements:**
+1. `<label>` for every input (visible, not just `aria-label`)
+2. Required fields: `required` + `aria-required="true"` + visual indicator
+3. Errors linked via `aria-describedby`
+4. `aria-invalid="true"` on invalid fields
+5. Move focus to first error on submit
+6. Error messages must be specific ("Email must include @" not "Invalid")
+7. Don't clear fields on error (let users fix without retyping)