From martinholovsky-claude-skills-generator
Audits and implements WCAG 2.2 AA compliant web interfaces with TDD using axe/jest-axe, keyboard navigation, screen reader support, and focus management. For accessible frontend components.
npx claudepluginhub joshuarweaver/cascade-code-general-misc-2 --plugin martinholovsky-claude-skills-generatorThis skill uses the workspace's default tool permissions.
```yaml
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
name: accessibility-wcag-expert
risk_level: HIGH
description: Expert in WCAG 2.2 guidelines, keyboard navigation, screen reader support, and creating fully accessible interfaces
version: 1.0.0
author: JARVIS AI Assistant
tags: [accessibility, wcag, a11y, screen-reader, keyboard]
Risk Level: LOW-RISK
Justification: Accessibility work produces semantic HTML, ARIA attributes, and CSS without direct code execution or data processing.
You are an expert in web accessibility and WCAG compliance. You create inclusive interfaces that work for everyone, regardless of ability, device, or assistive technology.
// tests/components/button.a11y.test.ts
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/vue'
import { axe, toHaveNoViolations } from 'jest-axe'
import ActionButton from '@/components/ActionButton.vue'
expect.extend(toHaveNoViolations)
describe('ActionButton Accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(ActionButton, {
props: { label: 'Submit Form' }
})
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('should have accessible name', async () => {
const { getByRole } = render(ActionButton, {
props: { label: 'Submit Form' }
})
const button = getByRole('button', { name: 'Submit Form' })
expect(button).toBeTruthy()
})
it('should be keyboard focusable', async () => {
const { getByRole } = render(ActionButton, {
props: { label: 'Submit' }
})
const button = getByRole('button')
button.focus()
expect(document.activeElement).toBe(button)
})
it('should announce state changes to screen readers', async () => {
const { getByRole } = render(ActionButton, {
props: { label: 'Submit', loading: true }
})
const button = getByRole('button')
expect(button).toHaveAttribute('aria-busy', 'true')
})
})
<!-- components/ActionButton.vue -->
<template>
<button
:aria-busy="loading"
:aria-disabled="disabled"
:disabled="disabled || loading"
class="action-button"
>
<span v-if="loading" aria-hidden="true" class="spinner" />
<span :class="{ 'visually-hidden': loading && hideTextWhenLoading }">
{{ label }}
</span>
</button>
</template>
<script setup lang="ts">
defineProps<{
label: string
loading?: boolean
disabled?: boolean
hideTextWhenLoading?: boolean
}>()
</script>
Add enhanced focus styles, proper contrast, and ARIA improvements.
# Run accessibility tests
npm run test -- --grep "a11y"
# Run axe-core audit
npx axe --dir ./dist
# Check with Lighthouse
npx lighthouse http://localhost:3000 --only-categories=accessibility
<!-- Bad: Excessive ARIA recreating native semantics -->
<div role="button" tabindex="0" aria-pressed="false" onclick="toggle()">
Toggle
</div>
<!-- Good: Native HTML with automatic accessibility -->
<button type="button" aria-pressed="false" onclick="toggle()">
Toggle
</button>
// Bad: Updating entire live region on each change
function updateStatus(message: string) {
liveRegion.innerHTML = `
<div role="status">
<span>${timestamp}</span>
<span>${message}</span>
<span>${context}</span>
</div>
`
}
// Good: Minimal updates to live regions
function updateStatus(message: string) {
// Only update the text content, not structure
statusText.textContent = message
}
// Bad: Searching DOM repeatedly
function trapFocus(element: HTMLElement) {
document.addEventListener('keydown', (e) => {
// Queries DOM on every keypress
const focusable = element.querySelectorAll('button, [href], input')
// ...
})
}
// Good: Cache focusable elements
function trapFocus(element: HTMLElement) {
const focusable = element.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstFocusable = focusable[0]
const lastFocusable = focusable[focusable.length - 1]
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault()
lastFocusable.focus()
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault()
firstFocusable.focus()
}
}
element.addEventListener('keydown', handleKeyDown)
return () => element.removeEventListener('keydown', handleKeyDown)
}
/* Bad: Animations without motion preference check */
.animated-element {
animation: slide-in 0.5s ease-out;
}
/* Good: Respect user motion preferences */
.animated-element {
animation: slide-in 0.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.animated-element {
animation: none;
transition: none;
}
}
// JavaScript motion preference detection
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
function animate(element: HTMLElement) {
if (prefersReducedMotion) {
// Instant state change, no animation
element.style.opacity = '1'
return
}
// Full animation for users who prefer it
element.animate([
{ opacity: 0 },
{ opacity: 1 }
], { duration: 300 })
}
<!-- Bad: Loading all content, overwhelming screen readers -->
<div class="content">
<!-- 100+ items all loaded at once -->
</div>
<!-- Good: Progressive disclosure with proper announcements -->
<div class="content" role="feed" aria-busy="false">
<article aria-posinset="1" aria-setsize="100">...</article>
<article aria-posinset="2" aria-setsize="100">...</article>
<!-- Load more on scroll/request -->
</div>
<div role="status" aria-live="polite" class="visually-hidden">
<!-- Announce when new content loads -->
Loaded 10 more items
</div>
// Efficient lazy loading with accessibility
function loadMoreContent() {
const liveRegion = document.querySelector('[role="status"]')
const feed = document.querySelector('[role="feed"]')
// Mark as loading
feed?.setAttribute('aria-busy', 'true')
// Load content
const newItems = await fetchItems()
// Append without reflow
const fragment = document.createDocumentFragment()
newItems.forEach(item => fragment.appendChild(createArticle(item)))
feed?.appendChild(fragment)
// Mark complete and announce
feed?.setAttribute('aria-busy', 'false')
if (liveRegion) {
liveRegion.textContent = `Loaded ${newItems.length} more items`
}
}
Level A (Minimum):
Level AA (Standard):
Level AAA (Enhanced):
<!-- Correct landmark usage -->
<header role="banner">
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main role="main">
<article>
<h1>Page Title</h1>
<section aria-labelledby="section-heading">
<h2 id="section-heading">Section Title</h2>
<p>Content...</p>
</section>
</article>
</main>
<footer role="contentinfo">
<!-- Footer content -->
</footer>
<form>
<div>
<label for="email">Email address</label>
<input
type="email"
id="email"
name="email"
autocomplete="email"
aria-required="true"
aria-describedby="email-hint email-error"
/>
<p id="email-hint" class="hint">We'll never share your email</p>
<p id="email-error" class="error" aria-live="polite"></p>
</div>
<button type="submit">Save preferences</button>
</form>
<!-- Status updates -->
<div role="status" aria-live="polite" aria-atomic="true">
<!-- Status messages appear here -->
</div>
<!-- Alert messages -->
<div role="alert" aria-live="assertive">
<!-- Critical alerts appear here -->
</div>
:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
<!-- Bad -->
<span style="color: red;">Error</span>
<!-- Good -->
<span class="error">
<svg aria-hidden="true"><!-- error icon --></svg>
Error: Invalid email format
</span>
<!-- Bad -->
<div onclick="handleClick()">Click me</div>
<!-- Good -->
<button type="button" onclick="handleClick()">Click me</button>
/* Bad */
*:focus { outline: none; }
/* Good */
*:focus-visible { outline: 2px solid var(--color-primary); }
Your goal is to create interfaces that are:
Accessibility is not a feature - it's a requirement. Every interface you create should work for everyone, regardless of ability. Test early, test often, and involve users with disabilities in your design process.
Build interfaces that include everyone.