pramoddutta/accessibility a11y enhanced
Comprehensive WCAG compliance and accessibility testing covering ARIA, keyboard navigation, screen readers, color contrast, and automated a11y validation.
npx skills add https://github.com/PramodDutta/qaskills --skill Accessibility A11y Enhanced
You are an expert accessibility engineer specializing in WCAG compliance and inclusive web design. When asked to test or improve accessibility, follow these comprehensive instructions.
Level A (Minimum)
- Basic accessibility features
- Essential for some users
- Examples: Alt text, keyboard access, labels
Level AA (Standard)
- Recommended baseline for most sites
- Addresses major barriers
- Examples: Color contrast 4.5:1, focus indicators, skip links
Level AAA (Enhanced)
- Highest accessibility standard
- Not always achievable for all content
- Examples: Color contrast 7:1, sign language, extended descriptions
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
npm install --save-dev @axe-core/playwright
// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility tests', () => {
test('should not have any automatically detectable accessibility issues', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('should have accessible homepage', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.exclude('#third-party-widget') // Exclude third-party content
.analyze();
// Log violations for debugging
if (results.violations.length > 0) {
console.log('Accessibility violations:', JSON.stringify(results.violations, null, 2));
}
expect(results.violations).toEqual([]);
});
test('should have accessible forms', async ({ page }) => {
await page.goto('/contact');
const results = await new AxeBuilder({ page })
.include('form') // Test only forms
.analyze();
expect(results.violations).toEqual([]);
});
test('should meet specific WCAG rules', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast', 'image-alt', 'label', 'aria-required-attr'])
.analyze();
expect(results.violations).toEqual([]);
});
});
// cypress/support/commands.ts
import 'cypress-axe';
Cypress.Commands.add('checkA11y', (context?: string, options?: any) => {
cy.injectAxe();
cy.checkA11y(context, options, (violations) => {
if (violations.length) {
cy.task('log', violations);
}
});
});
// cypress/e2e/accessibility.cy.ts
describe('Accessibility', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('should have no accessibility violations on homepage', () => {
cy.checkA11y();
});
it('should have accessible navigation', () => {
cy.checkA11y('nav');
});
it('should meet WCAG AA color contrast', () => {
cy.checkA11y(null, {
rules: {
'color-contrast': { enabled: true },
},
});
});
});
test.describe('Keyboard navigation', () => {
test('should navigate through interactive elements with Tab', async ({ page }) => {
await page.goto('/');
// Start at the first focusable element
await page.keyboard.press('Tab');
const firstFocusedElement = await page.evaluate(() => document.activeElement?.tagName);
expect(['A', 'BUTTON', 'INPUT']).toContain(firstFocusedElement);
// Tab through all interactive elements
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => {
const el = document.activeElement;
return {
tag: el?.tagName,
visible: el ? window.getComputedStyle(el).display !== 'none' : false,
};
});
expect(focused.visible).toBe(true);
}
});
test('should submit form with Enter key', async ({ page }) => {
await page.goto('/contact');
await page.fill('#name', 'Test User');
await page.fill('#email', '[email protected]');
await page.fill('#message', 'Test message');
// Focus on submit button and press Enter
await page.focus('button[type="submit"]');
await page.keyboard.press('Enter');
await expect(page.getByText('Message sent')).toBeVisible();
});
test('should close modal with Escape key', async ({ page }) => {
await page.goto('/');
await page.click('button[aria-label="Open modal"]');
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('should skip to main content with skip link', async ({ page }) => {
await page.goto('/');
// Tab to skip link
await page.keyboard.press('Tab');
const skipLink = page.getByText('Skip to main content');
await expect(skipLink).toBeFocused();
// Activate skip link
await page.keyboard.press('Enter');
// Main content should now be focused
const mainContent = page.locator('main');
await expect(mainContent).toBeFocused();
});
});
test.describe('Focus management', () => {
test('should have visible focus indicators', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
// Check that focused element has visible outline or custom focus styles
const styles = await focusedElement.evaluate((el) => {
const computed = window.getComputedStyle(el);
return {
outline: computed.outline,
outlineWidth: computed.outlineWidth,
boxShadow: computed.boxShadow,
};
});
// Should have either outline or box-shadow for focus
expect(
styles.outlineWidth !== '0px' ||
styles.boxShadow !== 'none'
).toBe(true);
});
test('should trap focus inside modal', async ({ page }) => {
await page.goto('/');
await page.click('button[aria-label="Open modal"]');
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// Tab through modal elements
await page.keyboard.press('Tab');
const firstFocusable = await page.evaluate(() => document.activeElement?.id);
// Keep tabbing until we cycle back
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
}
const currentFocus = await page.evaluate(() => document.activeElement?.id);
// Focus should cycle within modal, not escape to body
const focusedParent = await page.evaluate(() =>
document.activeElement?.closest('[role="dialog"]') !== null
);
expect(focusedParent).toBe(true);
});
});
test.describe('Screen reader support', () => {
test('should have proper ARIA labels', async ({ page }) => {
await page.goto('/');
// Check navigation has aria-label
const nav = page.locator('nav');
const ariaLabel = await nav.getAttribute('aria-label');
expect(ariaLabel).toBeTruthy();
// Check buttons have accessible names
const buttons = page.locator('button');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const button = buttons.nth(i);
const accessibleName = await button.evaluate((el) =>
(el as HTMLElement).ariaLabel ||
(el as HTMLElement).innerText ||
(el as HTMLElement).title
);
expect(accessibleName).toBeTruthy();
}
});
test('should announce page regions correctly', async ({ page }) => {
await page.goto('/');
// Check for landmark regions
const landmarks = await page.evaluate(() => {
return {
header: document.querySelector('header')?.getAttribute('role') || 'banner',
nav: document.querySelector('nav')?.getAttribute('role') || 'navigation',
main: document.querySelector('main')?.getAttribute('role') || 'main',
footer: document.querySelector('footer')?.getAttribute('role') || 'contentinfo',
};
});
expect(landmarks.header).toBeTruthy();
expect(landmarks.nav).toBeTruthy();
expect(landmarks.main).toBeTruthy();
expect(landmarks.footer).toBeTruthy();
});
test('should have accessible image alt text', async ({ page }) => {
await page.goto('/');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
const alt = await img.getAttribute('alt');
const role = await img.getAttribute('role');
// Images should have alt text or role="presentation" for decorative images
expect(alt !== null || role === 'presentation').toBe(true);
}
});
test('should use ARIA live regions for dynamic content', async ({ page }) => {
await page.goto('/notifications');
// Trigger a notification
await page.click('button[aria-label="Show notification"]');
const liveRegion = page.locator('[aria-live="polite"]');
await expect(liveRegion).toHaveText('Notification message');
});
});
test.describe('Color contrast', () => {
test('should meet WCAG AA contrast ratio for text', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('should be readable in high contrast mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark', forcedColors: 'active' });
await page.goto('/');
// Check that text is visible
const heading = page.getByRole('heading', { level: 1 });
await expect(heading).toBeVisible();
});
});
test.describe('Form accessibility', () => {
test('should have proper labels for inputs', async ({ page }) => {
await page.goto('/contact');
const inputs = page.locator('input, textarea, select');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
// Input should have associated label
const hasLabel = id
? await page.locator(`label[for="${id}"]`).count() > 0
: false;
expect(hasLabel || ariaLabel || ariaLabelledBy).toBeTruthy();
}
});
test('should show validation errors accessibly', async ({ page }) => {
await page.goto('/contact');
// Submit form without filling required fields
await page.click('button[type="submit"]');
// Error message should be announced
const errorMessage = page.locator('[role="alert"]');
await expect(errorMessage).toBeVisible();
// Invalid field should have aria-invalid
const emailInput = page.locator('#email');
const ariaInvalid = await emailInput.getAttribute('aria-invalid');
expect(ariaInvalid).toBe('true');
// Error should be associated with field
const ariaDescribedBy = await emailInput.getAttribute('aria-describedby');
expect(ariaDescribedBy).toBeTruthy();
});
test('should have accessible required field indicators', async ({ page }) => {
await page.goto('/contact');
const requiredInputs = page.locator('[required]');
const count = await requiredInputs.count();
for (let i = 0; i < count; i++) {
const input = requiredInputs.nth(i);
const ariaRequired = await input.getAttribute('aria-required');
expect(ariaRequired).toBe('true');
}
});
});
<!-- Good: Button with accessible name -->
<button aria-label="Close dialog">×</button>
<!-- Good: Button with text content -->
<button>Submit</button>
<!-- Bad: No accessible name -->
<button><span class="icon-close"></span></button>
<!-- Modal with proper ARIA -->
<div
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
aria-modal="true"
>
<h2 id="modal-title">Confirm Action</h2>
<p id="modal-description">Are you sure you want to proceed?</p>
<button>Confirm</button>
<button>Cancel</button>
</div>
test('should implement accessible tabs', async ({ page }) => {
await page.goto('/tabs-demo');
// Tab list should have role="tablist"
const tablist = page.getByRole('tablist');
await expect(tablist).toBeVisible();
// Individual tabs should have role="tab"
const firstTab = page.getByRole('tab', { name: 'Tab 1' });
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
// Tab panels should have role="tabpanel"
const firstPanel = page.getByRole('tabpanel', { name: 'Tab 1' });
await expect(firstPanel).toBeVisible();
// Arrow keys should navigate tabs
await firstTab.focus();
await page.keyboard.press('ArrowRight');
const secondTab = page.getByRole('tab', { name: 'Tab 2' });
await expect(secondTab).toBeFocused();
await expect(secondTab).toHaveAttribute('aria-selected', 'true');
});
test('should implement accessible autocomplete', async ({ page }) => {
await page.goto('/search');
const combobox = page.getByRole('combobox');
await expect(combobox).toHaveAttribute('aria-expanded', 'false');
// Type to trigger autocomplete
await combobox.fill('test');
await expect(combobox).toHaveAttribute('aria-expanded', 'true');
// Listbox should appear
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
// Navigate with arrow keys
await page.keyboard.press('ArrowDown');
const firstOption = page.getByRole('option').first();
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
<html lang="en">)alt="" or role="presentation"<button> over <div role="button">.10. Document accessibility features -- Help users discover a11y features.
role="button" on <div> -- Use native <button>.outline: none without replacement.10. Relying on automated tests alone -- Manual testing is essential.
Automated Testing:
Manual Testing:
Learning Resources:
Accessibility is not optional. Building inclusive experiences makes the web better for everyone.
Take pramoddutta/accessibility a11y enhanced from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npm.
Without those the skill loads but fails at the first command.