* fix: add missing plugin.json files and restore trailing newlines - Add plugin.json for review-fix-a11y skill - Add plugin.json for free-llm-api skill - Restore POSIX-compliant trailing newlines in JSON index files * feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375) Adds review-fix-a11y (WCAG 2.2 a11y audit + fix) and free-llm-api skills. Includes: - review-fix-a11y: WCAG 2.2 audit workflow, a11y_audit.py scanner, contrast_checker.py - free-llm-api: ChatAnywhere, Groq, Cerebras, OpenRouter, llm-mux, One API setup - secret_scanner.py upgrade with secrets-patterns-db integration (1,600+ patterns) Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223-alt@users.noreply.github.com> * chore: sync codex skills symlinks [automated] * Revert "feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)" This reverts commit49c9f2109f. * chore: sync codex skills symlinks [automated] * Revert "feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)" This reverts commit49c9f2109f. * feat(engineering-team): add a11y-audit skill — WCAG 2.2 accessibility audit & fix (#376) Built from scratch (replaces reverted PR #375 contribution). Skill package: - SKILL.md: 1132 lines, 3-phase workflow (scan → fix → verify), per-framework fix patterns (React, Next.js, Vue, Angular, Svelte, HTML), CI/CD integration guide, 20+ issue type coverage - scripts/a11y_scanner.py: static scanner detecting 20+ violation types across HTML/JSX/TSX/Vue/Svelte/CSS — severity-ranked, CI-friendly exit codes - scripts/contrast_checker.py: WCAG contrast calculator with AA/AAA checks, --suggest mode, --batch CSS scanning, named color support - references/wcag-quick-ref.md: WCAG 2.2 Level A/AA criteria table - references/aria-patterns.md: ARIA roles, live regions, keyboard interaction - references/framework-a11y-patterns.md: React, Vue, Angular, Svelte fix patterns - assets/sample-component.tsx: sample file with intentional violations - expected_outputs/: scan report, contrast output, JSON output samples - /a11y-audit slash command, settings.json, plugin.json, README.md Validation: 97.6/100 (EXCELLENT), quality 73.9/100 (B-), scripts 2/2 PASS Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: sync codex skills symlinks [automated] --------- Co-authored-by: Leo <leo@openclaw.ai> Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223@gmail.com> Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223-alt@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
324 lines
7.1 KiB
Markdown
324 lines
7.1 KiB
Markdown
# Framework-Specific Accessibility Patterns
|
|
|
|
## React / Next.js
|
|
|
|
### Common Issues and Fixes
|
|
|
|
**Image alt text:**
|
|
```jsx
|
|
// ❌ Bad
|
|
<img src="/hero.jpg" />
|
|
<Image src="/hero.jpg" width={800} height={400} />
|
|
|
|
// ✅ Good
|
|
<img src="/hero.jpg" alt="Team collaborating in office" />
|
|
<Image src="/hero.jpg" width={800} height={400} alt="Team collaborating in office" />
|
|
|
|
// ✅ Decorative image
|
|
<img src="/divider.svg" alt="" role="presentation" />
|
|
```
|
|
|
|
**Form labels:**
|
|
```jsx
|
|
// ❌ Bad — placeholder as label
|
|
<input placeholder="Email" type="email" />
|
|
|
|
// ✅ Good — explicit label
|
|
<label htmlFor="email">Email</label>
|
|
<input id="email" type="email" placeholder="user@example.com" />
|
|
|
|
// ✅ Good — aria-label for icon-only inputs
|
|
<input type="search" aria-label="Search products" />
|
|
```
|
|
|
|
**Click handlers on divs:**
|
|
```jsx
|
|
// ❌ Bad — not keyboard accessible
|
|
<div onClick={handleClick}>Click me</div>
|
|
|
|
// ✅ Good — use button
|
|
<button onClick={handleClick}>Click me</button>
|
|
|
|
// ✅ If div is required — add keyboard support
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={handleClick}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
|
|
>
|
|
Click me
|
|
</div>
|
|
```
|
|
|
|
**SPA route announcements (Next.js App Router):**
|
|
```jsx
|
|
// Layout component — announce page changes
|
|
'use client';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
export function RouteAnnouncer() {
|
|
const pathname = usePathname();
|
|
const [announcement, setAnnouncement] = useState('');
|
|
|
|
useEffect(() => {
|
|
const title = document.title;
|
|
setAnnouncement(`Navigated to ${title}`);
|
|
}, [pathname]);
|
|
|
|
return (
|
|
<div aria-live="assertive" role="status" className="sr-only">
|
|
{announcement}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Focus management after dynamic content:**
|
|
```jsx
|
|
// After adding item to list, announce it
|
|
const [items, setItems] = useState([]);
|
|
const statusRef = useRef(null);
|
|
|
|
const addItem = (item) => {
|
|
setItems([...items, item]);
|
|
// Announce to screen readers
|
|
statusRef.current.textContent = `${item.name} added to list`;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div ref={statusRef} aria-live="polite" className="sr-only" />
|
|
{/* list content */}
|
|
</>
|
|
);
|
|
```
|
|
|
|
### React-Specific Libraries
|
|
- `@radix-ui/*` — accessible primitives (Dialog, Tabs, Select, etc.)
|
|
- `@headlessui/react` — unstyled accessible components
|
|
- `react-aria` — Adobe's accessibility hooks
|
|
- `eslint-plugin-jsx-a11y` — lint rules for JSX accessibility
|
|
|
|
## Vue 3
|
|
|
|
### Common Issues and Fixes
|
|
|
|
**Dynamic content announcements:**
|
|
```vue
|
|
<template>
|
|
<div aria-live="polite" class="sr-only">
|
|
{{ announcement }}
|
|
</div>
|
|
<button @click="search">Search</button>
|
|
<ul v-if="results.length">
|
|
<li v-for="r in results" :key="r.id">{{ r.name }}</li>
|
|
</ul>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref } from 'vue';
|
|
const results = ref([]);
|
|
const announcement = ref('');
|
|
|
|
async function search() {
|
|
results.value = await fetchResults();
|
|
announcement.value = `${results.value.length} results found`;
|
|
}
|
|
</script>
|
|
```
|
|
|
|
**Conditional rendering with focus:**
|
|
```vue
|
|
<template>
|
|
<button @click="showForm = true">Add Item</button>
|
|
<form v-if="showForm" ref="formRef">
|
|
<label for="name">Name</label>
|
|
<input id="name" ref="nameInput" />
|
|
</form>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, nextTick } from 'vue';
|
|
const showForm = ref(false);
|
|
const nameInput = ref(null);
|
|
|
|
watch(showForm, async (val) => {
|
|
if (val) {
|
|
await nextTick();
|
|
nameInput.value?.focus();
|
|
}
|
|
});
|
|
</script>
|
|
```
|
|
|
|
### Vue-Specific Libraries
|
|
- `vue-announcer` — route change announcements
|
|
- `@headlessui/vue` — accessible components
|
|
- `eslint-plugin-vuejs-accessibility` — lint rules
|
|
|
|
## Angular
|
|
|
|
### Common Issues and Fixes
|
|
|
|
**CDK accessibility utilities:**
|
|
```typescript
|
|
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
|
import { FocusTrapFactory } from '@angular/cdk/a11y';
|
|
|
|
@Component({...})
|
|
export class MyComponent {
|
|
constructor(
|
|
private liveAnnouncer: LiveAnnouncer,
|
|
private focusTrapFactory: FocusTrapFactory
|
|
) {}
|
|
|
|
addItem(item: Item) {
|
|
this.items.push(item);
|
|
this.liveAnnouncer.announce(`${item.name} added`);
|
|
}
|
|
|
|
openDialog(element: HTMLElement) {
|
|
const focusTrap = this.focusTrapFactory.create(element);
|
|
focusTrap.focusInitialElement();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Template-driven forms:**
|
|
```html
|
|
<!-- ❌ Bad -->
|
|
<input [formControl]="email" placeholder="Email" />
|
|
|
|
<!-- ✅ Good -->
|
|
<label for="email">Email address</label>
|
|
<input id="email" [formControl]="email"
|
|
[attr.aria-invalid]="email.invalid && email.touched"
|
|
[attr.aria-describedby]="email.invalid ? 'email-error' : null" />
|
|
<div id="email-error" *ngIf="email.invalid && email.touched" role="alert">
|
|
Please enter a valid email address.
|
|
</div>
|
|
```
|
|
|
|
### Angular-Specific Tools
|
|
- `@angular/cdk/a11y` — `FocusTrap`, `LiveAnnouncer`, `FocusMonitor`
|
|
- `codelyzer` — a11y lint rules for Angular templates
|
|
|
|
## Svelte / SvelteKit
|
|
|
|
### Common Issues and Fixes
|
|
|
|
```svelte
|
|
<!-- ❌ Bad — on:click without keyboard -->
|
|
<div on:click={handleClick}>Action</div>
|
|
|
|
<!-- ✅ Good — Svelte a11y warning built-in -->
|
|
<button on:click={handleClick}>Action</button>
|
|
|
|
<!-- ✅ Accessible toggle -->
|
|
<button
|
|
on:click={() => isOpen = !isOpen}
|
|
aria-expanded={isOpen}
|
|
aria-controls="panel"
|
|
>
|
|
{isOpen ? 'Close' : 'Open'} Details
|
|
</button>
|
|
|
|
{#if isOpen}
|
|
<div id="panel" role="region" aria-labelledby="toggle-btn">
|
|
Panel content
|
|
</div>
|
|
{/if}
|
|
```
|
|
|
|
**Note:** Svelte has built-in a11y warnings in the compiler — it flags missing alt text, click-without-keyboard, and other common issues at build time.
|
|
|
|
## Plain HTML
|
|
|
|
### Checklist for Static Sites
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Descriptive Page Title</title>
|
|
</head>
|
|
<body>
|
|
<!-- Skip link -->
|
|
<a href="#main" class="skip-link">Skip to main content</a>
|
|
|
|
<header>
|
|
<nav aria-label="Main navigation">
|
|
<ul>
|
|
<li><a href="/">Home</a></li>
|
|
<li><a href="/about" aria-current="page">About</a></li>
|
|
</ul>
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="main" tabindex="-1">
|
|
<h1>Page Heading</h1>
|
|
<!-- Only one h1 per page -->
|
|
<!-- Heading levels don't skip (h1 → h2 → h3, never h1 → h3) -->
|
|
</main>
|
|
|
|
<footer>
|
|
<p>© 2026 Company Name</p>
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
## CSS Accessibility Patterns
|
|
|
|
### Focus Indicators
|
|
|
|
```css
|
|
/* ❌ Bad — removes focus indicator entirely */
|
|
:focus { outline: none; }
|
|
|
|
/* ✅ Good — custom focus indicator */
|
|
:focus-visible {
|
|
outline: 2px solid #005fcc;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* ✅ Good — enhanced for high contrast mode */
|
|
@media (forced-colors: active) {
|
|
:focus-visible {
|
|
outline: 2px solid ButtonText;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Reduced Motion
|
|
|
|
```css
|
|
/* ✅ Respect prefers-reduced-motion */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Screen Reader Only
|
|
|
|
```css
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border-width: 0;
|
|
}
|
|
```
|