create-roadkit
Version:
Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export
695 lines (622 loc) • 24.1 kB
text/typescript
/**
* Comprehensive Accessibility Testing Utilities
*
* This module provides tools for testing and validating theme accessibility,
* ensuring compliance with WCAG guidelines and providing detailed reporting
* of accessibility issues and recommendations.
*/
import { ColorUtils } from './utils';
import type { Theme, ThemeValidationResult } from './types';
/**
* WCAG compliance levels
*/
export type WCAGLevel = 'A' | 'AA' | 'AAA';
/**
* Text size categories for contrast calculations
*/
export type TextSize = 'normal' | 'large' | 'extra-large';
/**
* Detailed contrast requirement based on WCAG 2.1
*/
export interface ContrastRequirement {
level: WCAGLevel;
textSize: TextSize;
minimumRatio: number;
description: string;
}
/**
* Comprehensive accessibility test result
*/
export interface AccessibilityTestResult {
/** Overall pass/fail status */
passed: boolean;
/** WCAG level achieved */
wcagLevel: WCAGLevel | 'fail';
/** Individual test results */
tests: AccessibilityTest[];
/** Summary statistics */
summary: {
totalTests: number;
passedTests: number;
failedTests: number;
warnings: number;
};
/** Recommendations for improvements */
recommendations: AccessibilityRecommendation[];
/** Performance metrics */
performance: {
testDurationMs: number;
cacheHitRate: number;
};
}
/**
* Individual accessibility test result
*/
export interface AccessibilityTest {
/** Test identifier */
id: string;
/** Human-readable test name */
name: string;
/** Test category */
category: 'contrast' | 'color-blindness' | 'structure' | 'usability';
/** Pass/fail status */
passed: boolean;
/** WCAG level this test validates */
wcagLevel: WCAGLevel;
/** Actual measured value */
actualValue: number;
/** Required value for compliance */
requiredValue: number;
/** Detailed description */
description: string;
/** Specific colors or elements tested */
elements: {
foreground?: string;
background?: string;
elementType?: string;
};
/** Severity of failure if test failed */
severity: 'critical' | 'major' | 'minor';
}
/**
* Accessibility improvement recommendation
*/
export interface AccessibilityRecommendation {
/** Priority level */
priority: 'high' | 'medium' | 'low';
/** Category of recommendation */
category: string;
/** Description of the issue */
issue: string;
/** Recommended solution */
solution: string;
/** Estimated impact of implementing the fix */
impact: 'high' | 'medium' | 'low';
/** Code examples or specific changes */
implementation?: {
before: string;
after: string;
};
}
/**
* Color blindness simulation types
*/
export type ColorBlindnessType =
| 'protanopia' | 'deuteranopia' | 'tritanopia'
| 'protanomaly' | 'deuteranomaly' | 'tritanomaly'
| 'achromatopsia' | 'achromatomaly';
/**
* Comprehensive Accessibility Testing Suite
*/
export class AccessibilityTester {
private readonly contrastRequirements: ContrastRequirement[] = [
{ level: 'AA', textSize: 'normal', minimumRatio: 4.5, description: 'Normal text WCAG AA' },
{ level: 'AA', textSize: 'large', minimumRatio: 3.0, description: 'Large text WCAG AA' },
{ level: 'AAA', textSize: 'normal', minimumRatio: 7.0, description: 'Normal text WCAG AAA' },
{ level: 'AAA', textSize: 'large', minimumRatio: 4.5, description: 'Large text WCAG AAA' },
];
/**
* Run comprehensive accessibility tests on a theme
*
* @param theme - Theme to test
* @param options - Testing options
* @returns Detailed accessibility test results
*/
async runAccessibilityTests(
theme: Theme,
options: {
includeColorBlindnessTests?: boolean;
includeUsabilityTests?: boolean;
strictMode?: boolean; // Require AAA compliance instead of AA
} = {}
): Promise<AccessibilityTestResult> {
const startTime = Date.now();
const tests: AccessibilityTest[] = [];
const recommendations: AccessibilityRecommendation[] = [];
// Clear caches to get accurate performance metrics
const initialCacheStats = ColorUtils.getCacheStats();
try {
// Run contrast tests for both light and dark modes
tests.push(...await this.testContrastCompliance(theme.light, 'light'));
tests.push(...await this.testContrastCompliance(theme.dark, 'dark'));
// Run color blindness tests if requested
if (options.includeColorBlindnessTests) {
tests.push(...await this.testColorBlindnessAccessibility(theme));
}
// Run structural accessibility tests
tests.push(...await this.testStructuralAccessibility(theme));
// Run usability tests if requested
if (options.includeUsabilityTests) {
tests.push(...await this.testUsabilityFeatures(theme));
}
// Generate recommendations based on test results
recommendations.push(...this.generateRecommendations(tests, theme));
// Calculate overall results
const passedTests = tests.filter(t => t.passed);
const failedTests = tests.filter(t => !t.passed);
const criticalFailures = failedTests.filter(t => t.severity === 'critical');
// Determine WCAG level achieved
const targetLevel: WCAGLevel = options.strictMode ? 'AAA' : 'AA';
const levelTests = tests.filter(t => t.wcagLevel === targetLevel);
const levelPassed = levelTests.filter(t => t.passed);
let wcagLevel: WCAGLevel | 'fail' = 'fail';
if (criticalFailures.length === 0) {
if (levelPassed.length === levelTests.length) {
wcagLevel = targetLevel;
} else if (targetLevel === 'AAA') {
// Check if AA level passes
const aaTests = tests.filter(t => t.wcagLevel === 'AA');
const aaPassed = aaTests.filter(t => t.passed);
if (aaPassed.length === aaTests.length) {
wcagLevel = 'AA';
}
}
}
// Calculate performance metrics
const endTime = Date.now();
const finalCacheStats = ColorUtils.getCacheStats();
const cacheHitRate = finalCacheStats.totalMemoryUsage > initialCacheStats.totalMemoryUsage
? (initialCacheStats.totalMemoryUsage / finalCacheStats.totalMemoryUsage) * 100
: 100;
return {
passed: criticalFailures.length === 0 && wcagLevel !== 'fail',
wcagLevel,
tests,
summary: {
totalTests: tests.length,
passedTests: passedTests.length,
failedTests: failedTests.length,
warnings: tests.filter(t => !t.passed && t.severity !== 'critical').length,
},
recommendations,
performance: {
testDurationMs: endTime - startTime,
cacheHitRate: Math.round(cacheHitRate * 100) / 100,
},
};
} catch (error) {
// Return error state if testing fails
return {
passed: false,
wcagLevel: 'fail',
tests,
summary: {
totalTests: tests.length,
passedTests: 0,
failedTests: tests.length,
warnings: 0,
},
recommendations: [{
priority: 'high',
category: 'system',
issue: `Accessibility testing failed: ${error instanceof Error ? error.message : String(error)}`,
solution: 'Review theme configuration and fix any structural issues',
impact: 'high',
}],
performance: {
testDurationMs: Date.now() - startTime,
cacheHitRate: 0,
},
};
}
}
/**
* Test contrast compliance for a theme mode
*/
private async testContrastCompliance(
themeConfig: Theme['light'] | Theme['dark'],
mode: 'light' | 'dark'
): Promise<AccessibilityTest[]> {
const tests: AccessibilityTest[] = [];
// Define critical color pairs that must meet contrast requirements
const criticalPairs: Array<{
fg: keyof typeof themeConfig.palette;
bg: keyof typeof themeConfig.palette;
textSize: TextSize;
elementType: string;
}> = [
{ fg: 'foreground', bg: 'background', textSize: 'normal', elementType: 'body text' },
{ fg: 'primary-foreground', bg: 'primary', textSize: 'normal', elementType: 'primary buttons' },
{ fg: 'secondary-foreground', bg: 'secondary', textSize: 'normal', elementType: 'secondary buttons' },
{ fg: 'card-foreground', bg: 'card', textSize: 'normal', elementType: 'card content' },
{ fg: 'popover-foreground', bg: 'popover', textSize: 'normal', elementType: 'popover content' },
{ fg: 'muted-foreground', bg: 'muted', textSize: 'normal', elementType: 'muted content' },
{ fg: 'accent-foreground', bg: 'accent', textSize: 'normal', elementType: 'accent elements' },
{ fg: 'destructive-foreground', bg: 'destructive', textSize: 'normal', elementType: 'error messages' },
];
for (const pair of criticalPairs) {
const fg = themeConfig.palette[pair.fg];
const bg = themeConfig.palette[pair.bg];
if (!fg || !bg) {
tests.push({
id: `missing-color-${mode}-${pair.fg}-${pair.bg}`,
name: `Missing color definition`,
category: 'structure',
passed: false,
wcagLevel: 'AA',
actualValue: 0,
requiredValue: 1,
description: `Missing color definition for ${pair.fg} or ${pair.bg} in ${mode} mode`,
elements: { elementType: pair.elementType },
severity: 'critical',
});
continue;
}
try {
const contrastRatio = ColorUtils.getContrastRatio(fg.value, bg.value);
// Test against both AA and AAA requirements
for (const requirement of this.contrastRequirements) {
if (requirement.textSize !== pair.textSize) continue;
const passed = contrastRatio >= requirement.minimumRatio;
tests.push({
id: `contrast-${mode}-${pair.fg}-${pair.bg}-${requirement.level}`,
name: `${pair.elementType} contrast (${requirement.level})`,
category: 'contrast',
passed,
wcagLevel: requirement.level,
actualValue: Math.round(contrastRatio * 100) / 100,
requiredValue: requirement.minimumRatio,
description: `${requirement.description} for ${pair.elementType} in ${mode} mode`,
elements: {
foreground: fg.value,
background: bg.value,
elementType: pair.elementType,
},
severity: requirement.level === 'AA' ? 'critical' : 'major',
});
}
} catch (error) {
tests.push({
id: `contrast-error-${mode}-${pair.fg}-${pair.bg}`,
name: `Contrast calculation error`,
category: 'contrast',
passed: false,
wcagLevel: 'AA',
actualValue: 0,
requiredValue: 4.5,
description: `Failed to calculate contrast ratio: ${error instanceof Error ? error.message : String(error)}`,
elements: {
foreground: fg.value,
background: bg.value,
elementType: pair.elementType,
},
severity: 'major',
});
}
}
return tests;
}
/**
* Test color blindness accessibility
*/
private async testColorBlindnessAccessibility(theme: Theme): Promise<AccessibilityTest[]> {
const tests: AccessibilityTest[] = [];
// Check if primary/secondary colors are distinguishable for color blind users
// This is a simplified test - in practice, you'd simulate different types of color blindness
const lightPrimary = theme.light.palette.primary.value;
const lightSecondary = theme.light.palette.secondary.value;
const darkPrimary = theme.dark.palette.primary.value;
const darkSecondary = theme.dark.palette.secondary.value;
// Test color distinguishability (simplified - doesn't actually simulate color blindness)
const lightDistinguishable = ColorUtils.getContrastRatio(lightPrimary, lightSecondary) >= 3.0;
const darkDistinguishable = ColorUtils.getContrastRatio(darkPrimary, darkSecondary) >= 3.0;
tests.push({
id: 'color-blindness-light-primary-secondary',
name: 'Primary/Secondary color distinguishability (light mode)',
category: 'color-blindness',
passed: lightDistinguishable,
wcagLevel: 'AA',
actualValue: ColorUtils.getContrastRatio(lightPrimary, lightSecondary),
requiredValue: 3.0,
description: 'Primary and secondary colors must be distinguishable for color blind users',
elements: {
foreground: lightPrimary,
background: lightSecondary,
elementType: 'primary/secondary colors',
},
severity: 'major',
});
tests.push({
id: 'color-blindness-dark-primary-secondary',
name: 'Primary/Secondary color distinguishability (dark mode)',
category: 'color-blindness',
passed: darkDistinguishable,
wcagLevel: 'AA',
actualValue: ColorUtils.getContrastRatio(darkPrimary, darkSecondary),
requiredValue: 3.0,
description: 'Primary and secondary colors must be distinguishable for color blind users',
elements: {
foreground: darkPrimary,
background: darkSecondary,
elementType: 'primary/secondary colors',
},
severity: 'major',
});
return tests;
}
/**
* Test structural accessibility features
*/
private async testStructuralAccessibility(theme: Theme): Promise<AccessibilityTest[]> {
const tests: AccessibilityTest[] = [];
// Test that both light and dark modes are available
tests.push({
id: 'structure-light-mode-available',
name: 'Light mode configuration available',
category: 'structure',
passed: !!theme.light && !!theme.light.palette,
wcagLevel: 'AA',
actualValue: theme.light ? 1 : 0,
requiredValue: 1,
description: 'Theme must provide a light mode configuration',
elements: { elementType: 'theme structure' },
severity: 'critical',
});
tests.push({
id: 'structure-dark-mode-available',
name: 'Dark mode configuration available',
category: 'structure',
passed: !!theme.dark && !!theme.dark.palette,
wcagLevel: 'AA',
actualValue: theme.dark ? 1 : 0,
requiredValue: 1,
description: 'Theme must provide a dark mode configuration',
elements: { elementType: 'theme structure' },
severity: 'critical',
});
// Test that accessibility metadata is present and accurate
tests.push({
id: 'structure-accessibility-metadata',
name: 'Accessibility metadata present',
category: 'structure',
passed: typeof theme.meta.accessible === 'boolean',
wcagLevel: 'A',
actualValue: theme.meta.accessible ? 1 : 0,
requiredValue: 1,
description: 'Theme must declare its accessibility compliance status',
elements: { elementType: 'theme metadata' },
severity: 'minor',
});
return tests;
}
/**
* Test usability features
*/
private async testUsabilityFeatures(theme: Theme): Promise<AccessibilityTest[]> {
const tests: AccessibilityTest[] = [];
// Test focus ring visibility
const lightRingContrast = ColorUtils.getContrastRatio(
theme.light.palette.ring.value,
theme.light.palette.background.value
);
const darkRingContrast = ColorUtils.getContrastRatio(
theme.dark.palette.ring.value,
theme.dark.palette.background.value
);
tests.push({
id: 'usability-focus-ring-light',
name: 'Focus ring visibility (light mode)',
category: 'usability',
passed: lightRingContrast >= 3.0,
wcagLevel: 'AA',
actualValue: lightRingContrast,
requiredValue: 3.0,
description: 'Focus rings must be clearly visible for keyboard navigation',
elements: {
foreground: theme.light.palette.ring.value,
background: theme.light.palette.background.value,
elementType: 'focus ring',
},
severity: 'major',
});
tests.push({
id: 'usability-focus-ring-dark',
name: 'Focus ring visibility (dark mode)',
category: 'usability',
passed: darkRingContrast >= 3.0,
wcagLevel: 'AA',
actualValue: darkRingContrast,
requiredValue: 3.0,
description: 'Focus rings must be clearly visible for keyboard navigation',
elements: {
foreground: theme.dark.palette.ring.value,
background: theme.dark.palette.background.value,
elementType: 'focus ring',
},
severity: 'major',
});
return tests;
}
/**
* Generate accessibility improvement recommendations
*/
private generateRecommendations(
tests: AccessibilityTest[],
theme: Theme
): AccessibilityRecommendation[] {
const recommendations: AccessibilityRecommendation[] = [];
const failedTests = tests.filter(t => !t.passed);
// Group failed tests by category
const contrastFailures = failedTests.filter(t => t.category === 'contrast');
const structureFailures = failedTests.filter(t => t.category === 'structure');
const colorBlindnessFailures = failedTests.filter(t => t.category === 'color-blindness');
const usabilityFailures = failedTests.filter(t => t.category === 'usability');
// Generate contrast-specific recommendations
if (contrastFailures.length > 0) {
const criticalContrast = contrastFailures.filter(t => t.severity === 'critical');
if (criticalContrast.length > 0) {
recommendations.push({
priority: 'high',
category: 'contrast',
issue: `${criticalContrast.length} critical contrast violations found`,
solution: 'Adjust color lightness values to meet WCAG AA requirements (minimum 4.5:1 ratio)',
impact: 'high',
implementation: {
before: 'primary: "210 70% 65%"',
after: 'primary: "210 70% 45%" // Darker for better contrast',
},
});
}
}
// Generate structure-specific recommendations
if (structureFailures.length > 0) {
recommendations.push({
priority: 'high',
category: 'structure',
issue: 'Theme structure is incomplete or invalid',
solution: 'Ensure theme includes all required color definitions and metadata',
impact: 'high',
});
}
// Generate color blindness recommendations
if (colorBlindnessFailures.length > 0) {
recommendations.push({
priority: 'medium',
category: 'color-blindness',
issue: 'Colors may not be distinguishable for color blind users',
solution: 'Increase contrast between primary and secondary colors, or use different lightness values',
impact: 'medium',
});
}
// Generate usability recommendations
if (usabilityFailures.length > 0) {
recommendations.push({
priority: 'medium',
category: 'usability',
issue: 'Focus indicators or interactive elements may not be clearly visible',
solution: 'Ensure focus rings and interactive states have sufficient contrast',
impact: 'medium',
});
}
// Add general improvement recommendations
const aaaTests = tests.filter(t => t.wcagLevel === 'AAA');
const aaaFailures = aaaTests.filter(t => !t.passed);
if (aaaFailures.length > 0 && failedTests.filter(t => t.severity === 'critical').length === 0) {
recommendations.push({
priority: 'low',
category: 'enhancement',
issue: `Theme meets WCAG AA but not AAA standards (${aaaFailures.length} AAA violations)`,
solution: 'Consider increasing contrast ratios to 7:1 for AAA compliance',
impact: 'low',
});
}
return recommendations;
}
/**
* Generate a detailed accessibility report
*/
generateAccessibilityReport(result: AccessibilityTestResult, theme: Theme): string {
const lines: string[] = [];
lines.push(`# Accessibility Test Report for ${theme.name}`);
lines.push(`Generated on ${new Date().toISOString()}`);
lines.push('');
// Summary
lines.push('## Summary');
lines.push(`- **Overall Status**: ${result.passed ? '✅ PASSED' : '❌ FAILED'}`);
lines.push(`- **WCAG Level**: ${result.wcagLevel}`);
lines.push(`- **Total Tests**: ${result.summary.totalTests}`);
lines.push(`- **Passed**: ${result.summary.passedTests}`);
lines.push(`- **Failed**: ${result.summary.failedTests}`);
lines.push(`- **Warnings**: ${result.summary.warnings}`);
lines.push(`- **Test Duration**: ${result.performance.testDurationMs}ms`);
lines.push(`- **Cache Hit Rate**: ${result.performance.cacheHitRate}%`);
lines.push('');
// Failed tests
if (result.summary.failedTests > 0) {
lines.push('## Failed Tests');
const failedTests = result.tests.filter(t => !t.passed);
for (const test of failedTests) {
const icon = test.severity === 'critical' ? '🚨' : test.severity === 'major' ? '⚠️' : '💡';
lines.push(`### ${icon} ${test.name}`);
lines.push(`- **Severity**: ${test.severity.toUpperCase()}`);
lines.push(`- **WCAG Level**: ${test.wcagLevel}`);
lines.push(`- **Actual**: ${test.actualValue}`);
lines.push(`- **Required**: ${test.requiredValue}`);
lines.push(`- **Description**: ${test.description}`);
if (test.elements.foreground && test.elements.background) {
lines.push(`- **Colors**: Foreground: ${test.elements.foreground}, Background: ${test.elements.background}`);
}
lines.push('');
}
}
// Recommendations
if (result.recommendations.length > 0) {
lines.push('## Recommendations');
for (const rec of result.recommendations) {
const priorityIcon = rec.priority === 'high' ? '🔥' : rec.priority === 'medium' ? '⚡' : '💡';
lines.push(`### ${priorityIcon} ${rec.category.toUpperCase()} - ${rec.priority.toUpperCase()} Priority`);
lines.push(`**Issue**: ${rec.issue}`);
lines.push(`**Solution**: ${rec.solution}`);
lines.push(`**Impact**: ${rec.impact.toUpperCase()}`);
if (rec.implementation) {
lines.push('**Implementation**:');
lines.push('```css');
lines.push(`/* Before */`);
lines.push(rec.implementation.before);
lines.push('');
lines.push(`/* After */`);
lines.push(rec.implementation.after);
lines.push('```');
}
lines.push('');
}
}
// All test results
lines.push('## Detailed Test Results');
const categories = [...new Set(result.tests.map(t => t.category))];
for (const category of categories) {
const categoryTests = result.tests.filter(t => t.category === category);
lines.push(`### ${category.toUpperCase()}`);
for (const test of categoryTests) {
const status = test.passed ? '✅' : '❌';
lines.push(`- ${status} ${test.name} (${test.actualValue}/${test.requiredValue})`);
}
lines.push('');
}
return lines.join('\n');
}
}
/**
* Convenience function to run accessibility tests
*/
export async function testThemeAccessibility(
theme: Theme,
options?: {
includeColorBlindnessTests?: boolean;
includeUsabilityTests?: boolean;
strictMode?: boolean;
}
): Promise<AccessibilityTestResult> {
const tester = new AccessibilityTester();
return tester.runAccessibilityTests(theme, options);
}
/**
* Convenience function to generate accessibility report
*/
export function generateAccessibilityReport(
result: AccessibilityTestResult,
theme: Theme
): string {
const tester = new AccessibilityTester();
return tester.generateAccessibilityReport(result, theme);
}