Load PROACTIVELY when task involves accessibility compliance or inclusive design. Use when user says "check accessibility", "audit for WCAG", "fix screen reader issues", "add keyboard navigation", or "check color contrast". Covers WCAG 2.1 AA compliance across semantic HTML, ARIA patterns, keyboard navigation, screen reader support, color contrast ratios, form accessibility, media alternatives, and focus management. Produces structured audit reports with severity ratings.
Conducts comprehensive WCAG 2.1 AA audits for semantic HTML, ARIA, keyboard navigation, and screen reader support.
/plugin marketplace add mgd34msu/goodvibes-plugin/plugin install goodvibes@goodvibes-marketThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/accessibility-patterns.mdscripts/validate-accessibility-audit.shscripts/
validate-accessibility-audit.sh
references/
accessibility-patterns.md
This skill guides you through performing comprehensive accessibility audits to ensure WCAG 2.1 AA compliance. Use this when validating applications for inclusive design, preparing for accessibility reviews, or remediating accessibility issues.
A systematic accessibility audit follows these phases:
Objective: Map components and identify accessibility-critical areas.
Use discover to map the UI surface:
discover:
queries:
- id: interactive_components
type: grep
pattern: "(button|input|select|textarea|a href)"
glob: "**/*.{tsx,jsx}"
- id: form_components
type: grep
pattern: "<form|onSubmit|FormProvider"
glob: "**/*.{tsx,jsx}"
- id: aria_usage
type: grep
pattern: "aria-(label|labelledby|describedby|live|role)"
glob: "**/*.{tsx,jsx}"
- id: image_components
type: grep
pattern: "(<img|<Image|next/image)"
glob: "**/*.{tsx,jsx}"
verbosity: files_only
Identify critical areas:
Objective: Verify proper use of HTML5 semantic elements.
Search for heading usage:
precision_grep:
queries:
- id: heading_elements
pattern: "<h[1-6]|heading.*level"
glob: "**/*.{tsx,jsx}"
output:
format: standard
Common violations:
Correct heading structure:
import { ReactNode } from 'react';
interface PageLayoutProps {
title: string;
children: ReactNode;
}
export function PageLayout({ title, children }: PageLayoutProps) {
return (
<div className="page-layout">
{/* Single h1 per page */}
<h1 className="text-3xl font-bold">{title}</h1>
<main>
{children}
</main>
</div>
);
}
interface SectionProps {
title: string;
children: ReactNode;
}
export function Section({ title, children }: SectionProps) {
return (
<section>
{/* h2 for main sections */}
<h2 className="text-2xl font-semibold">{title}</h2>
{children}
</section>
);
}
interface SubsectionProps {
title: string;
children: ReactNode;
}
export function Subsection({ title, children }: SubsectionProps) {
return (
<div>
{/* h3 for subsections */}
<h3 className="text-xl font-medium">{title}</h3>
{children}
</div>
);
}
Search for landmark usage:
precision_grep:
queries:
- id: landmarks
pattern: "(<header|<main|<nav|<aside|<footer|role=\"(banner|navigation|main|complementary|contentinfo)\")"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
Required landmarks:
<header> or role="banner" for site header<nav> or role="navigation" for navigation<main> or role="main" for primary content (exactly one per page)<aside> or role="complementary" for sidebars<footer> or role="contentinfo" for site footerProper landmark structure:
import { ReactNode } from 'react';
interface AppLayoutProps {
navigation: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
}
export function AppLayout({ navigation, sidebar, children }: AppLayoutProps) {
return (
<div className="app-layout">
<header className="site-header">
<div className="logo">MyApp</div>
{navigation}
</header>
<div className="content-container">
{sidebar && (
<aside className="sidebar" aria-label="Filters">
{sidebar}
</aside>
)}
{/* Exactly one main per page */}
<main className="main-content">
{children}
</main>
</div>
<footer className="site-footer">
<p>© 2026 MyApp. All rights reserved.</p>
</footer>
</div>
);
}
Search for list patterns:
precision_grep:
queries:
- id: list_elements
pattern: "<(ul|ol|li|dl|dt|dd)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
Common violations:
<div> for lists instead of <ul> or <ol><li> outside of <ul> or <ol>Semantic list usage:
interface User {
id: string;
name: string;
email: string;
}
interface UserListProps {
users: User[];
}
export function UserList({ users }: UserListProps) {
return (
<ul className="user-list" aria-label="Team members">
{users.map((user) => (
<li key={user.id}>
<span className="user-name">{user.name}</span>
<span className="user-email">{user.email}</span>
</li>
))}
</ul>
);
}
Objective: Validate correct ARIA usage and patterns.
Search for ARIA role usage:
precision_grep:
queries:
- id: aria_roles
pattern: 'role="(button|link|dialog|alertdialog|menu|menuitem|tab|tabpanel|listbox|option)"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
Common violations:
<button role="button">)role="button" without keyboard handlersrole="widget")Correct ARIA button pattern:
import { MouseEvent, KeyboardEvent } from 'react';
interface CustomButtonProps {
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}
export function CustomButton({ onClick, disabled, children }: CustomButtonProps) {
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
if (!disabled) {
onClick();
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className="custom-button"
>
{children}
</div>
);
}
Note: Prefer native <button> element when possible. Only use role="button" on non-button elements when absolutely necessary.
Search for labeling patterns:
precision_grep:
queries:
- id: aria_labels
pattern: "aria-(label|labelledby|describedby)"
glob: "**/*.{tsx,jsx}"
output:
format: context
Common violations:
aria-labelledby referencing non-existent IDsaria-label and aria-labelledby)aria-label on non-interactive elementsCorrect labeling patterns:
import { useId } from 'react';
interface SearchFormProps {
onSearch: (query: string) => void;
}
export function SearchForm({ onSearch }: SearchFormProps) {
const searchId = useId();
const hintId = useId();
return (
<form role="search" onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSearch(formData.get('query') as string);
}}>
<label htmlFor={searchId} className="sr-only">
Search articles
</label>
<input
id={searchId}
type="search"
name="query"
placeholder="Search..."
aria-describedby={hintId}
/>
<p id={hintId} className="text-sm text-gray-600">
Search by title, author, or keyword
</p>
<button type="submit" aria-label="Submit search">
<SearchIcon aria-hidden="true" />
</button>
</form>
);
}
Search for live region usage:
precision_grep:
queries:
- id: live_regions
pattern: 'aria-live="(polite|assertive|off)"|role="(status|alert)"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
Live region best practices:
aria-live="polite" for non-critical updatesaria-live="assertive" or role="alert" for urgent messagesrole="status" for status updates (implicitly aria-live="polite")Accessible notification pattern:
import { ReactNode, useEffect, useState } from 'react';
interface NotificationProps {
message: string;
type: 'success' | 'error' | 'info';
onDismiss: () => void;
}
export function Notification({ message, type, onDismiss }: NotificationProps) {
useEffect(() => {
const timer = setTimeout(onDismiss, 5000);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
className={`notification notification-${type}`}
>
<p>{message}</p>
<button onClick={onDismiss} aria-label="Dismiss notification">
<CloseIcon aria-hidden="true" />
</button>
</div>
);
}
Objective: Ensure full keyboard accessibility.
Search for focus-related code:
precision_grep:
queries:
- id: focus_management
pattern: "(focus\\(\\)|autoFocus|tabIndex|useRef.*focus)"
glob: "**/*.{tsx,jsx,ts}"
output:
format: standard
Common violations:
:focus styles)tabIndex="-1" that should be reachableAccessible modal with focus trap:
import { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Save previously focused element
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element in modal
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements && focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
// Trap focus within modal
if (e.key === 'Tab' && focusableElements) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<div className="modal-body">{children}</div>
<button onClick={onClose} className="modal-close">
Close
</button>
</div>
</div>,
document.body
);
}
Search for skip link implementation:
precision_grep:
queries:
- id: skip_links
pattern: "(skip.*main|skip.*content|skip.*navigation)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
Skip link implementation:
export function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
>
Skip to main content
</a>
);
}
// CSS (in global styles)
// .skip-link {
// position: absolute;
// top: -40px;
// left: 0;
// background: #000;
// color: #fff;
// padding: 8px;
// text-decoration: none;
// z-index: 100;
// }
//
// .skip-link:focus {
// top: 0;
// }
Search for onClick without keyboard support:
precision_grep:
queries:
- id: onclick_handlers
pattern: "onClick=\\{"
glob: "**/*.{tsx,jsx}"
output:
format: locations
Manual review: For each onClick handler on a non-button/non-link element, verify:
role="button" or appropriate roletabIndex={0} for keyboard focusonKeyDown handler responds to Enter and Space keysAccessible click handler pattern:
import { MouseEvent, KeyboardEvent } from 'react';
interface ClickableCardProps {
title: string;
onClick: () => void;
}
export function ClickableCard({ title, onClick }: ClickableCardProps) {
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className="clickable-card"
>
<h3>{title}</h3>
</div>
);
}
Objective: Verify screen reader compatibility.
Search for sr-only patterns:
precision_grep:
queries:
- id: sr_only
pattern: "(sr-only|visually-hidden|screen-reader)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: files_only
Visually hidden text for icons:
interface IconButtonProps {
onClick: () => void;
ariaLabel: string;
icon: React.ReactNode;
}
export function IconButton({ onClick, ariaLabel, icon }: IconButtonProps) {
return (
<button onClick={onClick} aria-label={ariaLabel}>
{icon}
<span className="sr-only">{ariaLabel}</span>
</button>
);
}
// CSS for sr-only class:
// .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;
// }
Search for aria-hidden:
precision_grep:
queries:
- id: aria_hidden
pattern: 'aria-hidden="true"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
Common violations:
aria-hidden="true" on interactive elements (buttons, links)aria-hidden="true" on content that should be accessiblearia-hidden="true"Correct aria-hidden usage:
interface ButtonWithIconProps {
onClick: () => void;
label: string;
}
export function ButtonWithIcon({ onClick, label }: ButtonWithIconProps) {
return (
<button onClick={onClick}>
{/* Icon is decorative, hide from screen readers */}
<CheckIcon aria-hidden="true" />
{/* Label provides accessible text */}
<span>{label}</span>
</button>
);
}
Objective: Ensure sufficient color contrast and no color-only information.
Use browser DevTools or external tools:
WCAG 2.1 AA requirements:
Search for color definitions:
precision_grep:
queries:
- id: color_definitions
pattern: "(bg-|text-|color:|background:)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: count_only
Accessible color palette (Tailwind example):
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
theme: {
extend: {
colors: {
// Accessible color palette with documented contrast ratios
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
600: '#0284c7', // 4.54:1 on white (WCAG AA)
700: '#0369a1', // 7.09:1 on white (WCAG AAA)
900: '#0c4a6e', // 13.14:1 on white
},
// Error color with good contrast
error: {
600: '#dc2626', // 4.51:1 on white
700: '#b91c1c', // 6.70:1 on white
},
// Success color
success: {
600: '#16a34a', // 4.51:1 on white
700: '#15803d', // 6.68:1 on white
},
},
},
},
};
export default config;
Search for status indicators:
precision_grep:
queries:
- id: status_colors
pattern: "(bg-red|bg-green|bg-yellow|text-red|text-green|text-yellow)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
Manual review: Ensure status is conveyed through multiple means (color + icon + text).
Accessible status indicator:
type Status = 'success' | 'warning' | 'error' | 'info';
interface StatusBadgeProps {
status: Status;
message: string;
}
const statusConfig: Record<Status, { icon: React.ReactNode; className: string }> = {
success: { icon: <CheckCircleIcon />, className: 'bg-green-100 text-green-800' },
warning: { icon: <AlertTriangleIcon />, className: 'bg-yellow-100 text-yellow-800' },
error: { icon: <XCircleIcon />, className: 'bg-red-100 text-red-800' },
info: { icon: <InfoIcon />, className: 'bg-blue-100 text-blue-800' },
};
export function StatusBadge({ status, message }: StatusBadgeProps) {
const { icon, className } = statusConfig[status];
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded ${className}`}>
{/* Icon provides visual indicator beyond color */}
<span aria-hidden="true">{icon}</span>
{/* Text provides clear status information */}
<span>{message}</span>
</div>
);
}
Search for forced-colors media query:
precision_grep:
queries:
- id: forced_colors
pattern: "@media.*forced-colors|prefers-contrast"
glob: "**/*.{css,scss,tsx,jsx}"
output:
format: files_only
Support Windows High Contrast Mode:
/* Ensure borders are visible in forced-colors mode */
.card {
border: 1px solid #e5e7eb;
}
@media (forced-colors: active) {
.card {
border: 1px solid CanvasText;
}
}
/* Ensure custom controls are visible */
.custom-checkbox {
border: 2px solid #3b82f6;
}
@media (forced-colors: active) {
.custom-checkbox {
border: 2px solid ButtonText;
}
.custom-checkbox:checked {
background-color: Highlight;
}
}
Objective: Ensure forms are accessible and error handling is clear.
Search for input elements:
precision_grep:
queries:
- id: input_elements
pattern: "<input|<textarea|<select"
glob: "**/*.{tsx,jsx}"
output:
format: locations
Manual review: Verify each input has an associated label via:
<label> with matching htmlFor attributearia-label attributearia-labelledby pointing to label elementCommon violations:
htmlFor attributeAccessible form pattern:
import { useId, FormEvent } from 'react';
interface FormData {
email: string;
password: string;
}
interface LoginFormProps {
onSubmit: (data: FormData) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const emailId = useId();
const passwordId = useId();
const emailErrorId = useId();
const passwordErrorId = useId();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await onSubmit({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
};
return (
<form onSubmit={handleSubmit} noValidate>
<div className="form-field">
{/* Explicit label with htmlFor */}
<label htmlFor={emailId}>
Email address
</label>
<input
id={emailId}
type="email"
name="email"
required
aria-required="true"
aria-describedby={emailErrorId}
autoComplete="email"
/>
{/* Error message linked via aria-describedby */}
<p id={emailErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<div className="form-field">
<label htmlFor={passwordId}>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
aria-required="true"
aria-describedby={passwordErrorId}
autoComplete="current-password"
/>
<p id={passwordErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<button type="submit">
Sign in
</button>
</form>
);
}
Search for error patterns:
precision_grep:
queries:
- id: error_messages
pattern: "(error|invalid|required).*message"
glob: "**/*.{tsx,jsx,ts}"
output:
format: standard
Accessible error handling:
import { useState, useId } from 'react';
interface FieldError {
field: string;
message: string;
}
interface FormWithValidationProps {
onSubmit: (data: Record<string, string>) => void;
}
export function FormWithValidation({ onSubmit }: FormWithValidationProps) {
const [errors, setErrors] = useState<FieldError[]>([]);
const nameId = useId();
const nameErrorId = useId();
const errorSummaryId = useId();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
// Validation
const newErrors: FieldError[] = [];
if (!name || name.length < 2) {
newErrors.push({ field: 'name', message: 'Name must be at least 2 characters' });
}
if (newErrors.length > 0) {
setErrors(newErrors);
// Focus error summary for screen readers
document.getElementById(errorSummaryId)?.focus();
return;
}
setErrors([]);
onSubmit({ name });
};
const nameError = errors.find((e) => e.field === 'name');
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error summary at top of form */}
{errors.length > 0 && (
<div
id={errorSummaryId}
role="alert"
aria-labelledby="error-summary-title"
className="error-summary"
tabIndex={-1}
>
<h2 id="error-summary-title">There are {errors.length} errors</h2>
<ul>
{errors.map((error) => (
<li key={error.field}>
<a href={`#${error.field}`}>{error.message}</a>
</li>
))}
</ul>
</div>
)}
<div className="form-field">
<label htmlFor={nameId}>Name</label>
<input
id={nameId}
name="name"
aria-required="true"
aria-invalid={nameError ? 'true' : 'false'}
aria-describedby={nameError ? nameErrorId : undefined}
/>
{nameError && (
<p id={nameErrorId} className="error-message" role="alert">
{nameError.message}
</p>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Search for required field indicators:
precision_grep:
queries:
- id: required_fields
pattern: "(required|aria-required)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
Accessible required field indicator:
interface RequiredFieldLabelProps {
htmlFor: string;
children: React.ReactNode;
}
export function RequiredFieldLabel({ htmlFor, children }: RequiredFieldLabelProps) {
return (
<label htmlFor={htmlFor}>
{children}
{/* Visual indicator */}
<span className="text-red-600" aria-hidden="true"> *</span>
{/* Screen reader text */}
<span className="sr-only"> (required)</span>
</label>
);
}
Objective: Ensure images, videos, and audio content are accessible.
Search for images:
precision_grep:
queries:
- id: img_elements
pattern: "(<img|<Image|next/image)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
Manual review: Verify each image has:
alt text for content imagesalt="" for decorative imagesCommon violations:
alt attributeAccessible image patterns:
import Image from 'next/image';
interface ProductImageProps {
src: string;
productName: string;
}
// Content image with descriptive alt
export function ProductImage({ src, productName }: ProductImageProps) {
return (
<Image
src={src}
alt={`${productName} product photo`}
width={400}
height={400}
/>
);
}
// Decorative image with empty alt
export function DecorativePattern() {
return (
<div className="background-pattern">
<Image
src="/patterns/dots.svg"
alt=""
fill
aria-hidden="true"
/>
</div>
);
}
// Functional image (icon button)
interface DeleteButtonProps {
onDelete: () => void;
}
export function DeleteButton({ onDelete }: DeleteButtonProps) {
return (
<button onClick={onDelete} aria-label="Delete item">
<Image
src="/icons/trash.svg"
alt=""
width={20}
height={20}
aria-hidden="true"
/>
</button>
);
}
Search for video elements:
precision_grep:
queries:
- id: video_elements
pattern: "(<video|<track)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
Accessible video with captions:
interface AccessibleVideoProps {
src: string;
captionsSrc: string;
title: string;
}
export function AccessibleVideo({ src, captionsSrc, title }: AccessibleVideoProps) {
return (
<video controls aria-label={title}>
<source src={src} type="video/mp4" />
{/* Captions for deaf/hard of hearing users */}
<track
kind="captions"
src={captionsSrc}
srcLang="en"
label="English captions"
default
/>
{/* Fallback text */}
<p>
Your browser does not support the video element.
<a href={src}>Download the video</a>
</p>
</video>
);
}
Search for animations:
precision_grep:
queries:
- id: animations
pattern: "(animate|transition|prefers-reduced-motion)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: files_only
Respect prefers-reduced-motion:
import { motion, useReducedMotion } from 'framer-motion';
interface AnimatedCardProps {
children: React.ReactNode;
}
export function AnimatedCard({ children }: AnimatedCardProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.3,
}}
>
{children}
</motion.div>
);
}
CSS approach:
/* Default: animated */
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
/* Reduced motion: disable animations */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}
Run comprehensive accessibility checks:
discover:
queries:
# Missing alt attributes
- id: missing_alt
type: grep
pattern: "<img(?![^>]*alt=)"
glob: "**/*.{tsx,jsx}"
# onClick without keyboard handler
- id: onclick_no_keyboard
type: grep
pattern: 'onClick=\\{(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Non-button with role=button without keyboard
- id: role_button_no_keyboard
type: grep
pattern: 'role="button"(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Missing form labels
- id: unlabeled_inputs
type: grep
pattern: "<input(?![^>]*aria-label)(?![^>]*id=)"
glob: "**/*.{tsx,jsx}"
# Heading level skips
- id: heading_usage
type: grep
pattern: "<h[1-6]"
glob: "**/*.{tsx,jsx}"
verbosity: locations
Run these tools for comprehensive automated testing:
Lighthouse Accessibility Audit
npx lighthouse https://localhost:3000 --only-categories=accessibility --view
axe DevTools
Pa11y CI
npm install --save-dev pa11y-ci
npx pa11y-ci --sitemap https://yoursite.com/sitemap.xml
Report template:
# Accessibility Audit Report
**Date:** 2026-02-16
**Auditor:** [Name]
**WCAG Version:** 2.1 Level AA
**Pages Audited:** 12
## Executive Summary
- **Critical Issues:** 3
- **Serious Issues:** 8
- **Moderate Issues:** 15
- **Minor Issues:** 22
## Findings by WCAG Principle
### 1. Perceivable
#### 1.1.1 Non-text Content (Level A) - FAIL
**Issue:** 12 images missing alt text
**Location:**
- `src/components/ProductCard.tsx:45`
- `src/components/Gallery.tsx:78`
**Impact:** Screen reader users cannot understand image content
**Recommendation:**
```typescript
// Before
<img src="/product.jpg" />
// After
<img src="/product.jpg" alt="Blue cotton t-shirt" />
Issue: Primary button text has 3.2:1 contrast ratio (requires 4.5:1)
Location: src/styles/globals.css:45
Impact: Low vision users may not be able to read button text
Recommendation:
/* Before: #60a5fa on #3b82f6 = 3.2:1 */
.btn-primary {
background: #3b82f6;
color: #60a5fa;
}
/* After: white on #2563eb = 4.5:1 */
.btn-primary {
background: #2563eb;
color: #ffffff;
}
Issue: Custom dropdown not keyboard accessible
Location: src/components/Dropdown.tsx
Impact: Keyboard users cannot operate dropdown
Recommendation: Implement arrow key navigation and Enter/Space activation
Issue: Form inputs missing visible labels
Location: src/components/ContactForm.tsx:23-45
Recommendation: Add explicit <label> elements with htmlFor attributes
Issue: Custom checkbox missing ARIA states
Location: src/components/CustomCheckbox.tsx
Recommendation: Add aria-checked state and role="checkbox"
## Related Skills
- **code-review** - Apply accessibility checks during code review
- **component-architecture** - Design accessible component APIs
- **testing-strategy** - Include accessibility in test suites
## References
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)
Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.