Created
February 10, 2026 01:15
-
-
Save alecslupu/61d6583487e75ba8e92e3a05ed7386d8 to your computer and use it in GitHub Desktop.
wcag toolbar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * WCAG 2.1 Checker - Manual Individual Check Triggers | |
| * Run each accessibility check independently | |
| */ | |
| const WCAG_CHECKS = [ | |
| { | |
| id: 'images', | |
| name: 'Images', | |
| icon: '🖼️', | |
| description: 'Alt text and decorative images', | |
| focusArea: 'Images and visual content' | |
| }, | |
| { | |
| id: 'forms', | |
| name: 'Forms', | |
| icon: '📝', | |
| description: 'Labels, fieldsets, and inputs', | |
| focusArea: 'Form elements and input controls' | |
| }, | |
| { | |
| id: 'headings', | |
| name: 'Headings', | |
| icon: '📑', | |
| description: 'Heading hierarchy and structure', | |
| focusArea: 'Heading structure and hierarchy' | |
| }, | |
| { | |
| id: 'links', | |
| name: 'Links', | |
| icon: '🔗', | |
| description: 'Link text and semantics', | |
| focusArea: 'Links and navigation' | |
| }, | |
| { | |
| id: 'contrast', | |
| name: 'Contrast', | |
| icon: '🎨', | |
| description: 'Color-only information and contrast', | |
| focusArea: 'Color contrast and visual indicators' | |
| }, | |
| { | |
| id: 'aria', | |
| name: 'ARIA', | |
| icon: '♿', | |
| description: 'ARIA roles and attributes', | |
| focusArea: 'ARIA roles and attributes' | |
| }, | |
| { | |
| id: 'keyboard', | |
| name: 'Keyboard', | |
| icon: '⌨️', | |
| description: 'Tab order and focus management', | |
| focusArea: 'Keyboard navigation and focus' | |
| }, | |
| { | |
| id: 'semantic', | |
| name: 'Semantic', | |
| icon: '📐', | |
| description: 'Proper use of semantic elements', | |
| focusArea: 'Semantic HTML structure' | |
| }, | |
| { | |
| id: 'tables', | |
| name: 'Tables', | |
| icon: '📊', | |
| description: 'Table structure and headers', | |
| focusArea: 'Data tables' | |
| }, | |
| { | |
| id: 'language', | |
| name: 'Language', | |
| icon: '🌐', | |
| description: 'Language attributes', | |
| focusArea: 'Language attributes' | |
| } | |
| ]; | |
| let fullDocumentHTML = ''; | |
| let issueCounter = 0; | |
| let checkResults = new Map(); | |
| let runningChecks = new Set(); | |
| /** | |
| * Initialize the toolbar | |
| */ | |
| function initWCAGToolbar() { | |
| fullDocumentHTML = document.documentElement.outerHTML; | |
| console.log(`📄 Document size: ${fullDocumentHTML.length} characters (~${Math.round(fullDocumentHTML.length / 4)} tokens)`); | |
| showToolbar(); | |
| console.log('✅ WCAG Toolbar initialized. Click any check button to analyze that category.'); | |
| } | |
| /** | |
| * Run a single check | |
| */ | |
| async function runSingleCheck(checkId) { | |
| if (runningChecks.has(checkId)) { | |
| console.log(`⚠️ ${checkId} check already in progress`); | |
| announceToScreenReader(`${checkId} check already in progress`); | |
| return; | |
| } | |
| const check = WCAG_CHECKS.find(c => c.id === checkId); | |
| if (!check) return; | |
| runningChecks.add(checkId); | |
| updateCheckButton(checkId, 'running'); | |
| updateTabStatus(checkId, 'loading'); | |
| // Show initial loading state in panel | |
| const panel = document.getElementById(`wcag-panel-${checkId}`); | |
| if (panel) { | |
| panel.innerHTML = ` | |
| <div class="wcag-loading-state"> | |
| <div class="wcag-loading-icon" aria-hidden="true">⏳</div> | |
| <h3 class="wcag-loading-title">Analyzing ${check.name}...</h3> | |
| <p class="wcag-loading-description">This may take a few seconds depending on document size.</p> | |
| </div> | |
| `; | |
| } | |
| try { | |
| const result = await analyzeSection(check); | |
| checkResults.set(checkId, result); | |
| updateTabStatus(checkId, 'complete'); | |
| updateCheckButton(checkId, 'complete'); | |
| updateTabContent(checkId, result); | |
| updateSummary(); | |
| announceToScreenReader(`${check.name} check complete. Found ${result.count} issues.`); | |
| } catch (error) { | |
| console.error(`Error in ${check.name}:`, error); | |
| const errorResult = { error: error.message }; | |
| checkResults.set(checkId, errorResult); | |
| updateTabStatus(checkId, 'error'); | |
| updateCheckButton(checkId, 'error'); | |
| updateTabContent(checkId, errorResult); | |
| announceToScreenReader(`${check.name} check failed: ${error.message}`); | |
| } finally { | |
| runningChecks.delete(checkId); | |
| } | |
| } | |
| /** | |
| * Run all checks | |
| */ | |
| async function runAllChecks() { | |
| announceToScreenReader('Running all accessibility checks'); | |
| const promises = []; | |
| for (const check of WCAG_CHECKS) { | |
| promises.push(runSingleCheck(check.id)); | |
| } | |
| await Promise.all(promises); | |
| announceToScreenReader('All checks complete'); | |
| } | |
| /** | |
| * Analyze a single section with full document context | |
| */ | |
| async function analyzeSection(check) { | |
| let htmlToAnalyze = fullDocumentHTML; | |
| let note = ''; | |
| if (fullDocumentHTML.length > 200000) { | |
| const headMatch = fullDocumentHTML.match(/<head[^>]*>[\s\S]*?<\/head>/i); | |
| const bodyStart = fullDocumentHTML.substring(0, 150000); | |
| const bodyEnd = fullDocumentHTML.substring(fullDocumentHTML.length - 10000); | |
| htmlToAnalyze = headMatch ? headMatch[0] : ''; | |
| htmlToAnalyze += bodyStart + '\n<!-- ... document truncated for analysis ... -->\n' + bodyEnd; | |
| note = `(Document truncated: ${fullDocumentHTML.length} → ${htmlToAnalyze.length} characters)`; | |
| } | |
| const prompt = `You are a WCAG 2.1 Level AA auditor. Analyze this COMPLETE HTML document focusing on: ${check.focusArea} | |
| FOCUS AREAS FOR ${check.name.toUpperCase()}: | |
| ${getFocusInstructions(check.id)} | |
| HTML DOCUMENT ${note}: | |
| \`\`\`html | |
| ${htmlToAnalyze} | |
| \`\`\` | |
| OUTPUT FORMAT (one issue per line): | |
| [SEVERITY] Element: <element_description> - Issue description | |
| Fix: Remediation advice | |
| SEVERITY: CRITICAL | WARNING | INFO | |
| Element description should include: tag name, id, class, or unique identifier | |
| Be specific and actionable. Provide 5-15 most important issues.`; | |
| try { | |
| const startTime = Date.now(); | |
| const response = await fetch('http://localhost:11434/api/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| model: 'llama3.2:1b', | |
| prompt: prompt, | |
| stream: false, | |
| options: { | |
| temperature: 0.2, | |
| num_ctx: 32768, | |
| num_predict: 2048, | |
| } | |
| }), | |
| }); | |
| const data = await response.json(); | |
| const duration = ((Date.now() - startTime) / 1000).toFixed(1); | |
| console.log(`✓ ${check.name} completed in ${duration}s`); | |
| const issues = parseIssuesFromFullDoc(data.response); | |
| return { | |
| checkId: check.id, | |
| issues: issues, | |
| count: issues.length, | |
| rawResponse: data.response, | |
| duration: duration | |
| }; | |
| } catch (error) { | |
| throw new Error(`Ollama request failed: ${error.message}`); | |
| } | |
| } | |
| /** | |
| * Get focus instructions for each check type | |
| */ | |
| function getFocusInstructions(checkId) { | |
| const instructions = { | |
| images: `- Missing alt attributes on <img> tags | |
| - Empty alt on meaningful images | |
| - Alt text that's just filenames (image.jpg) | |
| - Decorative images without alt="" | |
| - <svg> without proper labels | |
| - Background images conveying information`, | |
| forms: `- <input>, <textarea>, <select> without associated <label> | |
| - Missing aria-label or aria-labelledby | |
| - Required fields without indicators | |
| - Radio/checkbox groups without <fieldset> and <legend> | |
| - Submit buttons without clear labels | |
| - Error messages not associated with fields`, | |
| headings: `- Missing <h1> or multiple <h1> tags | |
| - Skipped heading levels (h1 → h3) | |
| - Empty headings | |
| - Heading order not logical | |
| - Headings used only for styling | |
| - Content hierarchy unclear`, | |
| links: `- Links with empty text content | |
| - Generic link text ("click here", "read more") | |
| - Links and buttons used incorrectly | |
| - target="_blank" without warning | |
| - Links without href | |
| - Non-descriptive link text`, | |
| contrast: `- Text with insufficient color contrast | |
| - Links not distinguishable by more than color | |
| - Form inputs relying only on color for state | |
| - Color-coded information without text alternative | |
| - Disabled elements with poor contrast | |
| - Focus indicators with low contrast`, | |
| aria: `- Invalid ARIA roles | |
| - Required ARIA properties missing | |
| - Redundant ARIA (role="button" on <button>) | |
| - aria-hidden on focusable elements | |
| - Incorrect ARIA relationships | |
| - ARIA used instead of semantic HTML`, | |
| keyboard: `- Missing tabindex on custom controls | |
| - Positive tabindex values | |
| - Elements with onclick but not keyboard accessible | |
| - No visible focus indicators | |
| - Focus traps | |
| - Skip navigation links missing | |
| - Modal dialogs that trap focus incorrectly`, | |
| semantic: `- <div> or <span> with click handlers instead of <button> | |
| - Missing landmark regions (<main>, <nav>, <header>, <footer>) | |
| - Lists not using <ul>, <ol>, <li> | |
| - <div> used instead of semantic elements | |
| - Tables used for layout | |
| - Improper nesting of elements`, | |
| tables: `- Data tables without <th> elements | |
| - Missing <caption> on tables | |
| - <th> without scope attribute | |
| - Complex tables without proper headers/ids | |
| - Layout tables with table markup | |
| - Missing summary for complex tables`, | |
| language: `- Missing lang attribute on <html> | |
| - Invalid language codes | |
| - Content in different language without lang attribute | |
| - Incorrect primary language specified` | |
| }; | |
| return instructions[checkId] || 'Review for WCAG compliance'; | |
| } | |
| /** | |
| * Parse issues from AI response | |
| */ | |
| function parseIssuesFromFullDoc(text) { | |
| const issues = []; | |
| const lines = text.split('\n'); | |
| let currentIssue = null; | |
| for (const line of lines) { | |
| const issueMatch = line.match(/\[(CRITICAL|WARNING|INFO)\]\s*Element:\s*(.+?)\s*-\s*(.+)/i); | |
| if (issueMatch) { | |
| if (currentIssue) issues.push(currentIssue); | |
| const [, severity, elementDesc, description] = issueMatch; | |
| currentIssue = { | |
| severity: severity.toUpperCase(), | |
| issueId: issueCounter++, | |
| elementDescription: elementDesc.trim(), | |
| description: description.trim(), | |
| fix: '', | |
| element: findElementByDescription(elementDesc.trim()) | |
| }; | |
| } else if (currentIssue && line.trim().startsWith('Fix:')) { | |
| currentIssue.fix = line.replace(/^Fix:\s*/i, '').trim(); | |
| } else if (currentIssue && line.trim() && !line.startsWith('[')) { | |
| if (currentIssue.fix) { | |
| currentIssue.fix += ' ' + line.trim(); | |
| } | |
| } | |
| } | |
| if (currentIssue) issues.push(currentIssue); | |
| return issues; | |
| } | |
| /** | |
| * Try to find element by AI's description | |
| */ | |
| function findElementByDescription(description) { | |
| const idMatch = description.match(/id="([^"]+)"/i) || description.match(/id:?\s*([^\s,]+)/i); | |
| const classMatch = description.match(/class="([^"]+)"/i) || description.match(/class:?\s*([^\s,]+)/i); | |
| const tagMatch = description.match(/<(\w+)/); | |
| if (idMatch) { | |
| const element = document.getElementById(idMatch[1]); | |
| if (element) return element; | |
| } | |
| if (classMatch && tagMatch) { | |
| const elements = document.querySelectorAll(`${tagMatch[1]}.${classMatch[1].split(' ')[0]}`); | |
| if (elements.length > 0) return elements[0]; | |
| } | |
| if (tagMatch) { | |
| const elements = document.querySelectorAll(tagMatch[1]); | |
| if (elements.length > 0) return elements[0]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Highlight element on page | |
| */ | |
| let currentHighlight = null; | |
| function highlightElement(element) { | |
| if (currentHighlight) { | |
| currentHighlight.style.outline = ''; | |
| currentHighlight.style.outlineOffset = ''; | |
| } | |
| if (element) { | |
| element.style.outline = '3px solid #ff0000'; | |
| element.style.outlineOffset = '2px'; | |
| element.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| currentHighlight = element; | |
| announceToScreenReader('Highlighting element'); | |
| } else { | |
| announceToScreenReader('Element not found on page'); | |
| } | |
| } | |
| function unhighlightElement() { | |
| if (currentHighlight) { | |
| currentHighlight.style.outline = ''; | |
| currentHighlight.style.outlineOffset = ''; | |
| currentHighlight = null; | |
| } | |
| } | |
| function announceToScreenReader(message) { | |
| const announcement = document.getElementById('wcag-announcer'); | |
| if (announcement) { | |
| announcement.textContent = message; | |
| } | |
| } | |
| // ============= UI FUNCTIONS ============= | |
| function showToolbar() { | |
| const existing = document.getElementById('wcag-toolbar'); | |
| if (existing) existing.remove(); | |
| const toolbar = document.createElement('aside'); | |
| toolbar.id = 'wcag-toolbar'; | |
| toolbar.setAttribute('role', 'complementary'); | |
| toolbar.setAttribute('aria-label', 'WCAG Accessibility Checker Toolbar'); | |
| toolbar.innerHTML = ` | |
| <style> | |
| #wcag-toolbar { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| background: #1e1e1e; | |
| color: #d4d4d4; | |
| box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); | |
| z-index: 999999; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| font-size: 14px; | |
| border-top: 3px solid #0066cc; | |
| } | |
| #wcag-toolbar-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 12px 20px; | |
| background: #2d2d2d; | |
| border-bottom: 1px solid #444; | |
| } | |
| #wcag-toolbar-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin: 0; | |
| font-size: 16px; | |
| font-weight: 600; | |
| color: #ffffff; | |
| } | |
| #wcag-toolbar-actions { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .wcag-btn { | |
| padding: 8px 16px; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: 500; | |
| transition: all 0.2s; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .wcag-btn:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); | |
| } | |
| .wcag-btn:focus { | |
| outline: 3px solid #ffc107; | |
| outline-offset: 2px; | |
| } | |
| .wcag-btn:active { | |
| transform: translateY(0); | |
| } | |
| .wcag-btn-primary { | |
| background: #0066cc; | |
| color: white; | |
| } | |
| .wcag-btn-primary:hover { | |
| background: #0052a3; | |
| } | |
| .wcag-btn-secondary { | |
| background: #555; | |
| color: white; | |
| } | |
| .wcag-btn-secondary:hover { | |
| background: #666; | |
| } | |
| .wcag-btn-toggle { | |
| background: #444; | |
| color: white; | |
| min-width: 120px; | |
| } | |
| .wcag-btn-toggle:hover { | |
| background: #555; | |
| } | |
| .wcag-btn-danger { | |
| background: #dc3545; | |
| color: white; | |
| } | |
| .wcag-btn-danger:hover { | |
| background: #c82333; | |
| } | |
| #wcag-toolbar-content { | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: max-height 0.3s ease-in-out; | |
| } | |
| #wcag-toolbar-content.expanded { | |
| max-height: 70vh; | |
| } | |
| #wcag-tabs-wrapper { | |
| background: #2d2d2d; | |
| border-bottom: 2px solid #444; | |
| overflow-x: auto; | |
| overflow-y: hidden; | |
| } | |
| #wcag-tabs { | |
| display: flex; | |
| padding: 10px 20px 0 20px; | |
| gap: 8px; | |
| min-width: min-content; | |
| } | |
| #wcag-tabs::-webkit-scrollbar { | |
| height: 6px; | |
| } | |
| #wcag-tabs::-webkit-scrollbar-track { | |
| background: #2d2d2d; | |
| } | |
| #wcag-tabs::-webkit-scrollbar-thumb { | |
| background: #555; | |
| border-radius: 3px; | |
| } | |
| .wcag-tab { | |
| padding: 10px 16px; | |
| background: #3a3a3a; | |
| border: 2px solid transparent; | |
| border-radius: 6px 6px 0 0; | |
| cursor: pointer; | |
| color: #aaa; | |
| font-size: 13px; | |
| white-space: nowrap; | |
| transition: all 0.2s; | |
| flex-shrink: 0; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .wcag-tab:hover { | |
| background: #4a4a4a; | |
| color: #fff; | |
| } | |
| .wcag-tab:focus { | |
| outline: 3px solid #ffc107; | |
| outline-offset: -3px; | |
| border-color: #ffc107; | |
| } | |
| .wcag-tab[aria-selected="true"] { | |
| background: #1e1e1e; | |
| color: #fff; | |
| border-bottom-color: #1e1e1e; | |
| font-weight: 600; | |
| } | |
| .wcag-tab-status { | |
| font-size: 16px; | |
| line-height: 1; | |
| } | |
| .wcag-tab[data-status="loading"] .wcag-tab-status::after { | |
| content: '⏳'; | |
| } | |
| .wcag-tab[data-status="complete"] .wcag-tab-status::after { | |
| content: '✓'; | |
| color: #28a745; | |
| } | |
| .wcag-tab[data-status="error"] .wcag-tab-status::after { | |
| content: '✗'; | |
| color: #dc3545; | |
| } | |
| #wcag-panels { | |
| padding: 20px; | |
| overflow-y: auto; | |
| max-height: calc(70vh - 120px); | |
| background: #1e1e1e; | |
| } | |
| #wcag-panels::-webkit-scrollbar { | |
| width: 10px; | |
| } | |
| #wcag-panels::-webkit-scrollbar-track { | |
| background: #1e1e1e; | |
| } | |
| #wcag-panels::-webkit-scrollbar-thumb { | |
| background: #555; | |
| border-radius: 5px; | |
| } | |
| .wcag-panel { | |
| display: none; | |
| } | |
| .wcag-panel[aria-hidden="false"] { | |
| display: block; | |
| animation: fadeIn 0.3s; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .wcag-panel-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| padding-bottom: 15px; | |
| border-bottom: 2px solid #333; | |
| } | |
| .wcag-panel-info h3 { | |
| margin: 0 0 5px 0; | |
| color: #fff; | |
| font-size: 20px; | |
| } | |
| .wcag-panel-info p { | |
| margin: 0; | |
| color: #888; | |
| font-size: 13px; | |
| } | |
| .wcag-check-btn { | |
| padding: 10px 20px; | |
| background: #28a745; | |
| color: white; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: 600; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .wcag-check-btn:hover { | |
| background: #218838; | |
| transform: translateY(-1px); | |
| } | |
| .wcag-check-btn:focus { | |
| outline: 3px solid #ffc107; | |
| outline-offset: 2px; | |
| } | |
| .wcag-check-btn:disabled { | |
| background: #555; | |
| cursor: not-allowed; | |
| opacity: 0.6; | |
| } | |
| .wcag-check-btn[data-status="running"] { | |
| background: #ffc107; | |
| color: #000; | |
| } | |
| .wcag-check-btn[data-status="complete"] { | |
| background: #17a2b8; | |
| } | |
| .wcag-check-btn[data-status="error"] { | |
| background: #dc3545; | |
| } | |
| .wcag-issue-list { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .wcag-issue-item { | |
| background: #2d2d2d; | |
| padding: 16px; | |
| margin-bottom: 12px; | |
| border-radius: 8px; | |
| border-left: 4px solid #666; | |
| transition: all 0.2s; | |
| } | |
| .wcag-issue-item:hover, | |
| .wcag-issue-item:focus-within { | |
| background: #3a3a3a; | |
| transform: translateX(4px); | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); | |
| } | |
| .wcag-issue-item.critical { | |
| border-left-color: #dc3545; | |
| } | |
| .wcag-issue-item.warning { | |
| border-left-color: #ffc107; | |
| } | |
| .wcag-issue-item.info { | |
| border-left-color: #17a2b8; | |
| } | |
| .wcag-issue-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .wcag-severity-badge { | |
| padding: 4px 10px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: bold; | |
| text-transform: uppercase; | |
| } | |
| .wcag-severity-badge.critical { | |
| background: #dc3545; | |
| color: white; | |
| } | |
| .wcag-severity-badge.warning { | |
| background: #ffc107; | |
| color: #000; | |
| } | |
| .wcag-severity-badge.info { | |
| background: #17a2b8; | |
| color: white; | |
| } | |
| .wcag-element-desc { | |
| font-family: 'Courier New', monospace; | |
| font-size: 11px; | |
| color: #61dafb; | |
| background: #1a1a1a; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .wcag-issue-description { | |
| color: #fff; | |
| line-height: 1.6; | |
| margin-bottom: 10px; | |
| } | |
| .wcag-issue-fix { | |
| font-size: 13px; | |
| color: #aaa; | |
| padding-top: 10px; | |
| border-top: 1px solid #444; | |
| line-height: 1.5; | |
| } | |
| .wcag-issue-fix::before { | |
| content: '💡 '; | |
| } | |
| .wcag-highlight-btn { | |
| background: transparent; | |
| border: 1px solid #555; | |
| color: #61dafb; | |
| padding: 6px 12px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| margin-top: 8px; | |
| transition: all 0.2s; | |
| } | |
| .wcag-highlight-btn:hover { | |
| background: #2a2a2a; | |
| border-color: #61dafb; | |
| } | |
| .wcag-highlight-btn:focus { | |
| outline: 2px solid #ffc107; | |
| outline-offset: 2px; | |
| } | |
| .wcag-highlight-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| #wcag-summary { | |
| padding: 20px; | |
| background: #2d2d2d; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| } | |
| #wcag-summary h3 { | |
| margin: 0 0 15px 0; | |
| font-size: 18px; | |
| color: #fff; | |
| } | |
| .wcag-stats { | |
| display: flex; | |
| gap: 30px; | |
| flex-wrap: wrap; | |
| } | |
| .wcag-stat { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 14px; | |
| } | |
| .wcag-stat-value { | |
| font-size: 24px; | |
| font-weight: bold; | |
| color: #fff; | |
| } | |
| .wcag-stat.critical .wcag-stat-value { | |
| color: #dc3545; | |
| } | |
| .wcag-stat.warning .wcag-stat-value { | |
| color: #ffc107; | |
| } | |
| .wcag-stat.info .wcag-stat-value { | |
| color: #17a2b8; | |
| } | |
| .wcag-empty-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: #888; | |
| } | |
| .wcag-empty-state-icon { | |
| font-size: 64px; | |
| margin-bottom: 16px; | |
| } | |
| .wcag-empty-state-title { | |
| font-size: 18px; | |
| color: #aaa; | |
| margin-bottom: 8px; | |
| } | |
| .wcag-empty-state-description { | |
| font-size: 14px; | |
| line-height: 1.6; | |
| } | |
| .wcag-loading-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: #ffc107; | |
| } | |
| .wcag-loading-icon { | |
| font-size: 64px; | |
| margin-bottom: 16px; | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .wcag-loading-title { | |
| font-size: 18px; | |
| color: #ffc107; | |
| margin-bottom: 8px; | |
| } | |
| .wcag-loading-description { | |
| font-size: 14px; | |
| color: #888; | |
| } | |
| .wcag-success-state { | |
| text-align: center; | |
| padding: 40px 20px; | |
| color: #28a745; | |
| } | |
| .wcag-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; | |
| } | |
| #wcag-announcer { | |
| position: absolute; | |
| left: -10000px; | |
| width: 1px; | |
| height: 1px; | |
| overflow: hidden; | |
| } | |
| .wcag-doc-info { | |
| font-size: 12px; | |
| color: #888; | |
| margin-top: 10px; | |
| padding-top: 10px; | |
| border-top: 1px solid #333; | |
| } | |
| </style> | |
| <header id="wcag-toolbar-header"> | |
| <h2 id="wcag-toolbar-title"> | |
| <span aria-hidden="true">♿</span> | |
| <span>WCAG 2.1 Manual Checker</span> | |
| </h2> | |
| <div id="wcag-toolbar-actions"> | |
| <button | |
| id="wcag-run-all-btn" | |
| class="wcag-btn wcag-btn-secondary" | |
| aria-label="Run all accessibility checks" | |
| onclick="runAllChecks()"> | |
| <span aria-hidden="true">▶▶</span> | |
| <span>Run All Checks</span> | |
| </button> | |
| <button | |
| id="wcag-toggle-btn" | |
| class="wcag-btn wcag-btn-toggle" | |
| aria-expanded="false" | |
| aria-controls="wcag-toolbar-content" | |
| aria-label="Toggle results panel"> | |
| <span id="wcag-toggle-text">Show Panel</span> | |
| <span aria-hidden="true">▲</span> | |
| </button> | |
| <button | |
| id="wcag-close-btn" | |
| class="wcag-btn wcag-btn-danger" | |
| aria-label="Close WCAG toolbar"> | |
| <span aria-hidden="true">✕</span> | |
| <span class="wcag-sr-only">Close</span> | |
| </button> | |
| </div> | |
| </header> | |
| <div id="wcag-toolbar-content" aria-live="polite"> | |
| <nav id="wcag-tabs-wrapper" aria-label="Accessibility check categories"> | |
| <div id="wcag-tabs" role="tablist" aria-label="WCAG check categories"> | |
| ${WCAG_CHECKS.map((check, index) => ` | |
| <button | |
| id="wcag-tab-${check.id}" | |
| class="wcag-tab" | |
| role="tab" | |
| aria-selected="${index === 0 ? 'true' : 'false'}" | |
| aria-controls="wcag-panel-${check.id}" | |
| tabindex="${index === 0 ? '0' : '-1'}" | |
| data-tab="${check.id}" | |
| data-status=""> | |
| <span class="wcag-tab-icon" aria-hidden="true">${check.icon}</span> | |
| <span class="wcag-tab-label">${check.name}</span> | |
| <span class="wcag-tab-status" aria-live="polite" aria-atomic="true"></span> | |
| </button> | |
| `).join('')} | |
| </div> | |
| </nav> | |
| <div id="wcag-panels"> | |
| <div id="wcag-summary" aria-live="polite" aria-atomic="true"> | |
| <h3>Overall Summary</h3> | |
| <p style="color: #888; margin: 10px 0;">Click individual check buttons to analyze specific categories, or use "Run All Checks" to analyze everything.</p> | |
| <div class="wcag-stats" style="margin-top: 20px;"> | |
| <div class="wcag-stat"> | |
| <span class="wcag-stat-value" id="summary-checks-run">0</span> | |
| <span>Checks Run</span> | |
| </div> | |
| </div> | |
| <p class="wcag-doc-info"> | |
| Document size: ${fullDocumentHTML.length.toLocaleString()} characters | |
| </p> | |
| </div> | |
| ${WCAG_CHECKS.map((check, index) => ` | |
| <section | |
| id="wcag-panel-${check.id}" | |
| class="wcag-panel" | |
| role="tabpanel" | |
| aria-labelledby="wcag-tab-${check.id}" | |
| aria-hidden="${index === 0 ? 'false' : 'true'}" | |
| tabindex="0"> | |
| <div class="wcag-panel-header"> | |
| <div class="wcag-panel-info"> | |
| <h3><span aria-hidden="true">${check.icon}</span> ${check.name}</h3> | |
| <p>${check.description}</p> | |
| </div> | |
| <button | |
| id="wcag-check-btn-${check.id}" | |
| class="wcag-check-btn" | |
| data-check-id="${check.id}" | |
| data-status="ready" | |
| onclick="runSingleCheck('${check.id}')" | |
| aria-label="Run ${check.name} check"> | |
| <span aria-hidden="true">▶</span> | |
| <span class="wcag-check-btn-text">Run Check</span> | |
| </button> | |
| </div> | |
| <div class="wcag-empty-state"> | |
| <div class="wcag-empty-state-icon" aria-hidden="true">👆</div> | |
| <h4 class="wcag-empty-state-title">Not Analyzed Yet</h4> | |
| <p class="wcag-empty-state-description">Click "Run Check" above to analyze ${check.name.toLowerCase()} in the document.</p> | |
| </div> | |
| </section> | |
| `).join('')} | |
| </div> | |
| </div> | |
| <div id="wcag-announcer" role="status" aria-live="polite" aria-atomic="true"></div> | |
| `; | |
| document.body.appendChild(toolbar); | |
| setupEventListeners(); | |
| setupKeyboardNavigation(); | |
| } | |
| function setupEventListeners() { | |
| const toggleBtn = document.getElementById('wcag-toggle-btn'); | |
| const content = document.getElementById('wcag-toolbar-content'); | |
| toggleBtn.addEventListener('click', () => { | |
| const isExpanded = content.classList.toggle('expanded'); | |
| toggleBtn.setAttribute('aria-expanded', isExpanded); | |
| document.getElementById('wcag-toggle-text').textContent = isExpanded ? 'Hide Panel' : 'Show Panel'; | |
| announceToScreenReader(isExpanded ? 'Panel expanded' : 'Panel collapsed'); | |
| }); | |
| document.getElementById('wcag-close-btn').addEventListener('click', () => { | |
| document.getElementById('wcag-toolbar').remove(); | |
| unhighlightElement(); | |
| announceToScreenReader('WCAG toolbar closed'); | |
| }); | |
| document.querySelectorAll('.wcag-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| selectTab(tab.getAttribute('data-tab')); | |
| }); | |
| }); | |
| } | |
| function setupKeyboardNavigation() { | |
| const tablist = document.getElementById('wcag-tabs'); | |
| const tabs = Array.from(document.querySelectorAll('.wcag-tab')); | |
| tablist.addEventListener('keydown', (e) => { | |
| const currentTab = document.activeElement; | |
| const currentIndex = tabs.indexOf(currentTab); | |
| let targetTab = null; | |
| switch(e.key) { | |
| case 'ArrowRight': | |
| case 'ArrowDown': | |
| e.preventDefault(); | |
| targetTab = tabs[(currentIndex + 1) % tabs.length]; | |
| break; | |
| case 'ArrowLeft': | |
| case 'ArrowUp': | |
| e.preventDefault(); | |
| targetTab = tabs[(currentIndex - 1 + tabs.length) % tabs.length]; | |
| break; | |
| case 'Home': | |
| e.preventDefault(); | |
| targetTab = tabs[0]; | |
| break; | |
| case 'End': | |
| e.preventDefault(); | |
| targetTab = tabs[tabs.length - 1]; | |
| break; | |
| } | |
| if (targetTab) { | |
| selectTab(targetTab.getAttribute('data-tab')); | |
| targetTab.focus(); | |
| } | |
| }); | |
| } | |
| function selectTab(tabId) { | |
| document.querySelectorAll('.wcag-tab').forEach(tab => { | |
| const isSelected = tab.getAttribute('data-tab') === tabId; | |
| tab.setAttribute('aria-selected', isSelected); | |
| tab.setAttribute('tabindex', isSelected ? '0' : '-1'); | |
| }); | |
| document.querySelectorAll('.wcag-panel').forEach(panel => { | |
| const isVisible = panel.id === `wcag-panel-${tabId}`; | |
| panel.setAttribute('aria-hidden', !isVisible); | |
| }); | |
| const activeTab = document.querySelector(`[data-tab="${tabId}"]`); | |
| if (activeTab) { | |
| activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); | |
| } | |
| // Auto-expand toolbar when tab is selected | |
| const content = document.getElementById('wcag-toolbar-content'); | |
| const toggleBtn = document.getElementById('wcag-toggle-btn'); | |
| if (!content.classList.contains('expanded')) { | |
| content.classList.add('expanded'); | |
| toggleBtn.setAttribute('aria-expanded', 'true'); | |
| document.getElementById('wcag-toggle-text').textContent = 'Hide Panel'; | |
| } | |
| unhighlightElement(); | |
| } | |
| function updateCheckButton(checkId, status) { | |
| const btn = document.getElementById(`wcag-check-btn-${checkId}`); | |
| if (!btn) return; | |
| const textSpan = btn.querySelector('.wcag-check-btn-text'); | |
| btn.setAttribute('data-status', status); | |
| switch(status) { | |
| case 'running': | |
| btn.disabled = true; | |
| textSpan.textContent = 'Analyzing...'; | |
| btn.querySelector('span[aria-hidden]').textContent = '⏳'; | |
| break; | |
| case 'complete': | |
| btn.disabled = false; | |
| textSpan.textContent = 'Re-run Check'; | |
| btn.querySelector('span[aria-hidden]').textContent = '🔄'; | |
| break; | |
| case 'error': | |
| btn.disabled = false; | |
| textSpan.textContent = 'Retry Check'; | |
| btn.querySelector('span[aria-hidden]').textContent = '⚠️'; | |
| break; | |
| default: | |
| btn.disabled = false; | |
| textSpan.textContent = 'Run Check'; | |
| btn.querySelector('span[aria-hidden]').textContent = '▶'; | |
| } | |
| } | |
| function updateTabStatus(checkId, status) { | |
| const tab = document.querySelector(`[data-tab="${checkId}"]`); | |
| if (tab) { | |
| tab.setAttribute('data-status', status); | |
| } | |
| } | |
| function updateTabContent(checkId, result) { | |
| const panel = document.getElementById(`wcag-panel-${checkId}`); | |
| if (!panel) return; | |
| // Keep the header | |
| const header = panel.querySelector('.wcag-panel-header'); | |
| const headerHTML = header ? header.outerHTML : ''; | |
| if (result.error) { | |
| panel.innerHTML = headerHTML + ` | |
| <div class="wcag-empty-state"> | |
| <div class="wcag-empty-state-icon" aria-hidden="true">❌</div> | |
| <h3 class="wcag-empty-state-title">Error</h3> | |
| <p class="wcag-empty-state-description">${result.error}</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| if (result.count === 0) { | |
| panel.innerHTML = headerHTML + ` | |
| <div class="wcag-success-state"> | |
| <div class="wcag-empty-state-icon" aria-hidden="true">✅</div> | |
| <h3 class="wcag-empty-state-title">No Issues Found</h3> | |
| <p class="wcag-empty-state-description">No accessibility issues detected in this category.</p> | |
| <p class="wcag-doc-info">Analysis completed in ${result.duration}s</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| const issuesHTML = headerHTML + ` | |
| <h3 style="color: #fff; margin-bottom: 16px;"> | |
| Found ${result.count} Issue${result.count !== 1 ? 's' : ''} | |
| <span style="font-size: 14px; font-weight: normal; color: #888;">(${result.duration}s)</span> | |
| </h3> | |
| <ul class="wcag-issue-list"> | |
| ${result.issues.map(issue => ` | |
| <li class="wcag-issue-item ${issue.severity.toLowerCase()}"> | |
| <div class="wcag-issue-header"> | |
| <span class="wcag-severity-badge ${issue.severity.toLowerCase()}">${issue.severity}</span> | |
| <code class="wcag-element-desc" title="${issue.elementDescription}">${issue.elementDescription}</code> | |
| </div> | |
| <div class="wcag-issue-description">${issue.description}</div> | |
| ${issue.fix ? `<div class="wcag-issue-fix">${issue.fix}</div>` : ''} | |
| <button | |
| class="wcag-highlight-btn" | |
| onclick="highlightElementById(${issue.issueId})" | |
| ${!issue.element ? 'disabled title="Element not found in DOM"' : ''} | |
| aria-label="Highlight element on page"> | |
| ${issue.element ? 'Highlight Element' : 'Element Not Found'} | |
| </button> | |
| </li> | |
| `).join('')} | |
| </ul> | |
| `; | |
| panel.innerHTML = issuesHTML; | |
| } | |
| function updateSummary() { | |
| let totalCritical = 0; | |
| let totalWarning = 0; | |
| let totalInfo = 0; | |
| let checksRun = checkResults.size; | |
| checkResults.forEach(result => { | |
| if (result.issues) { | |
| result.issues.forEach(issue => { | |
| if (issue.severity === 'CRITICAL') totalCritical++; | |
| else if (issue.severity === 'WARNING') totalWarning++; | |
| else if (issue.severity === 'INFO') totalInfo++; | |
| }); | |
| } | |
| }); | |
| document.getElementById('summary-checks-run').textContent = checksRun; | |
| const summary = document.getElementById('wcag-summary'); | |
| if (checksRun === 0) return; | |
| summary.innerHTML = ` | |
| <h3>Overall Summary</h3> | |
| <div class="wcag-stats"> | |
| <div class="wcag-stat"> | |
| <span class="wcag-stat-value">${checksRun}</span> | |
| <span>Checks Run</span> | |
| </div> | |
| <div class="wcag-stat critical"> | |
| <span class="wcag-stat-value">${totalCritical}</span> | |
| <span>Critical</span> | |
| </div> | |
| <div class="wcag-stat warning"> | |
| <span class="wcag-stat-value">${totalWarning}</span> | |
| <span>Warnings</span> | |
| </div> | |
| <div class="wcag-stat info"> | |
| <span class="wcag-stat-value">${totalInfo}</span> | |
| <span>Info</span> | |
| </div> | |
| </div> | |
| <p class="wcag-doc-info"> | |
| ${checksRun} of ${WCAG_CHECKS.length} checks completed • Document: ${fullDocumentHTML.length.toLocaleString()} chars | |
| </p> | |
| `; | |
| } | |
| // Store issue elements for highlighting | |
| const issueElements = new Map(); | |
| function highlightElementById(issueId) { | |
| let element = null; | |
| checkResults.forEach(result => { | |
| if (result.issues) { | |
| const issue = result.issues.find(i => i.issueId === issueId); | |
| if (issue && issue.element) { | |
| element = issue.element; | |
| } | |
| } | |
| }); | |
| highlightElement(element); | |
| } | |
| // Make functions globally accessible | |
| window.runSingleCheck = runSingleCheck; | |
| window.runAllChecks = runAllChecks; | |
| window.highlightElementById = highlightElementById; | |
| // Initialize toolbar | |
| console.log('🚀 Initializing WCAG Manual Checker...'); | |
| initWCAGToolbar(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment