UNPKG

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
/** * 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); }