diff options
Diffstat (limited to 'html/ccc/index.html')
-rw-r--r-- | html/ccc/index.html | 653 |
1 files changed, 653 insertions, 0 deletions
diff --git a/html/ccc/index.html b/html/ccc/index.html new file mode 100644 index 0000000..96fee2e --- /dev/null +++ b/html/ccc/index.html @@ -0,0 +1,653 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Color Contrast Checker</title> + <meta name="description" content="Give it a list of colors, find accessible combinations."> + <style> + body { font-family: Arial, sans-serif; padding: 20px; size: 16px;} + .color-input { margin-bottom: 10px; width: 100%; height: 100px; font-size: 16px; /* Prevent iOS zoom */ } + .color-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } + .color-box { padding: 10px; text-align: center; border: 1px solid #ccc; } + .hidden { display: none; } + .form-controls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 1rem; + background: #f5f5f5; + border-radius: 4px; + margin-top: 1rem; + justify-content: space-between; + } + .checkbox-controls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: flex-end; + } + @media (max-width: 600px) { + .form-controls { + flex-direction: column; + align-items: stretch; + } + .checkbox-controls { + flex-direction: column; + align-items: stretch; + } + button { + width: 100%; + } + } + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + background: #007bff; + color: white; + cursor: pointer; + } + button:hover { + background: #0056b3; + } + button[onclick="shareColors()"] { + background: #6c757d; + } + button[onclick="shareColors()"]:hover { + background: #545b62; + } + label { + white-space: nowrap; + } + .error { + color: #721c24; + background-color: #f8d7da; + border: 1px solid #f5c6cb; + padding: 1rem; + border-radius: 4px; + margin: 1rem 0; + grid-column: 1 / -1; + } + .warning { + color: #856404; + background-color: #fff3cd; + border: 1px solid #ffeeba; + padding: 1rem; + border-radius: 4px; + margin: 1rem 0; + grid-column: 1 / -1; + } + </style> +</head> +<body> + <header> + <h1>What color combinations are accessible?</h1> + </header> + <main> + <form id="colorForm" onsubmit="event.preventDefault(); updateColors();"> + <label for="colors">Colors you want to check for contrast:</label> + <textarea id="colors" class="color-input" required placeholder="Feel free to mix and match hex, rgb, and rgba values!"></textarea> + <p style="margin-top: 0;">You can enter colors as a comma separated list, or as a newline separated list...or a mix of both! Colors can be in hex (#RGB or #RRGGBB), rgb(r,g,b), or rgba(r,g,b,a) format. You can mix and match color formats, too!</p> + <div class="form-controls"> + <button type="submit">Check Contrast</button> + <div class="checkbox-controls"> + <label><input type="checkbox" id="toggleFails" onchange="updateVisibility()"> Hide failing pairs</label> + <label><input type="checkbox" id="sortContrast" onchange="updateColors()"> Sort by contrast</label> + <button type="button" onclick="shareColors()">Share Palette</button> + </div> + </div> + </form> + <br> + <br> + <section id="results" class="color-grid" aria-live="polite"></section> + </main> + <footer> + <p>Color Contrast Checker — A tool to help you find accessible color combinations.</p> + <p>This tool can only determine if two color combinations have a mathmatically correct contrast ratio. Digital accessiblity isn't just about math, though, so use this as a starting place, not as the final word on if a color combination is accessible.</p> + </footer> + + <script> + // ===== Initialization and URL Parameter Handling ===== + window.addEventListener('load', () => { + const urlParams = new URLSearchParams(window.location.search); + const colors = urlParams.get('colors'); + if (colors) { + document.getElementById('colors').value = decodeURIComponent(colors); + updateColors(); + } + + // Only create test UI if tests parameter is present and true + if (urlParams.get('tests') === 'true' || urlParams.get('tests') === 't') { + createTestUI(); + } + }); + + // ===== Share Functionality ===== + function shareColors() { + const colors = document.getElementById('colors').value; + if (!colors) return; + + const encodedColors = encodeURIComponent(colors); + const url = `${window.location.origin}${window.location.pathname}?colors=${encodedColors}`; + + // Copy to clipboard + navigator.clipboard.writeText(url).then(() => { + alert('Share link copied to clipboard!'); + }).catch(() => { + // Fallback if clipboard API fails + prompt('Copy this share link:', url); + }); + } + + // ===== Color Parsing and Conversion ===== + function parseColor(color) { + // Remove all whitespace and convert to lowercase + const originalColor = color; // Store original format + color = color.replace(/\s/g, '').toLowerCase(); + + // If it's just numbers, assume it's an error + if (/^\d+$/.test(color)) { + throw new Error(`Invalid color format: ${color}. Please use hex (#RGB or #RRGGBB) or rgb()/rgba() format.`); + } + + // Add # prefix if it's a hex code without it + if (/^[0-9a-f]{3}$|^[0-9a-f]{6}$/.test(color)) { + color = '#' + color; + } + + // Handle hex colors + if (color.startsWith('#')) { + // Validate hex format + if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(color)) { + throw new Error(`Invalid hex color format: ${color}. Use #RGB or #RRGGBB format.`); + } + + // Convert 3-digit hex to 6-digit + if (color.length === 4) { + color = '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]; + } + // Remove # and convert to RGB array + const hex = color.substring(1); + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + a: 1, + originalFormat: originalColor + }; + } + + // Handle rgb/rgba colors + if (color.startsWith('rgb')) { + // Validate rgb/rgba format + if (!/^rgba?\(\d+(\.\d+)?(,\d+(\.\d+)?){2}(,\d*\.?\d+)?\)$/.test(color)) { + throw new Error(`Invalid rgb/rgba format: ${color}. Use rgb(r,g,b) or rgba(r,g,b,a) format.`); + } + + const values = color.match(/[\d.]+/g); + const rgb = values.map(v => parseFloat(v)); + + // Validate rgb values are in range + if (rgb.slice(0, 3).some(v => v < 0 || v > 255)) { + throw new Error(`RGB values must be between 0 and 255: ${color}`); + } + if (rgb.length === 4 && (rgb[3] < 0 || rgb[3] > 1)) { + throw new Error(`Alpha value must be between 0 and 1: ${color}`); + } + + return { + r: rgb[0], + g: rgb[1], + b: rgb[2], + a: rgb.length === 4 ? rgb[3] : 1, + originalFormat: originalColor + }; + } + + throw new Error(`Unsupported color format: ${color}. Please use hex (#RGB or #RRGGBB) or rgb()/rgba() format.`); + } + + function colorToHex({r, g, b}) { + const toHex = (n) => { + const hex = Math.round(n).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + // ===== Contrast Calculation and WCAG Compliance ===== + function getContrastRatio(color1, color2) { + // Calculate relative luminance using WCAG formula + // L = 0.2126 * R + 0.7152 * G + 0.0722 * B + // where R, G, and B are the red, green, and blue values of the color + // the formula normalizes the RGB values to a 0-1 range + // and then applies coefficients to represent human perception of color + // Returns a value between 0 and 1, where 0 is darkest and 1 is brightest + function luminance(r, g, b) { + // Convert RGB values to sRGB colorspace values + let channels = [r, g, b].map(v => { + // Step 1: Normalize RGB values to 0-1 range + v /= 255; + + // Step 2: Convert to sRGB using WCAG formula + // If value is small (≤ 0.03928), use simple linear calculation + // Otherwise, use the more complex power formula + return v <= 0.03928 + ? v / 12.92 + : Math.pow((v + 0.055) / 1.055, 2.4); + }); + + // Step 3: Apply luminance coefficients + // These coefficients represent human perception of color + // Red contributes 21.26%, Green 71.52%, and Blue 7.22% to perceived brightness + return channels[0] * 0.2126 // Red coefficient + + channels[1] * 0.7152 // Green coefficient + + channels[2] * 0.0722; // Blue coefficient + } + + // Handle colors with transparency by compositing them with white background + const parseAndComposite = (color) => { + const parsed = parseColor(color); + // If color is fully opaque, return as is + if (parsed.a === 1) return parsed; + + // For semi-transparent colors, composite with white background + // Using alpha compositing formula: result = (foreground × alpha) + (background × (1 - alpha)) + const alpha = parsed.a; + return { + r: (parsed.r * alpha) + (255 * (1 - alpha)), // Blend red channel + g: (parsed.g * alpha) + (255 * (1 - alpha)), // Blend green channel + b: (parsed.b * alpha) + (255 * (1 - alpha)), // Blend blue channel + a: 1 // Result is fully opaque + }; + }; + + // Process both colors, handling any transparency + const rgb1 = parseAndComposite(color1); + const rgb2 = parseAndComposite(color2); + + // Calculate luminance for both colors + const lum1 = luminance(rgb1.r, rgb1.g, rgb1.b); + const lum2 = luminance(rgb2.r, rgb2.g, rgb2.b); + + // Calculate contrast ratio using WCAG formula: + // (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter color's luminance + // The 0.05 constant prevents division by zero and handles very dark colors + const lighter = Math.max(lum1, lum2); + const darker = Math.min(lum1, lum2); + return (lighter + 0.05) / (darker + 0.05); + } + + + // This is an incomplete check since it is only dealing with color, and doesn't consider font sizing + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Perceivable/Color_contrast + function getWCAGLevel(contrast) { + // Level AAA: Contrast ratio of at least 7:1 + if (contrast >= 7) return "AAA"; + // Level AA: Contrast ratio of at least 4.5:1 + if (contrast >= 4.5) return "AA"; + // Failing: Contrast ratio below 4.5:1 + return "Fail"; + } + + // ===== UI Generation and Updates ===== + function createColorBox(color1, color2, contrast, level, accessible, showGlow) { + const classes = ["color-box"]; + if (!accessible) classes.push("failing"); + if (level === "AAA" && showGlow) classes.push("glowing-border"); + + const foreground = parseColor(color1); + const background = parseColor(color2); + + return ` + <div class="${classes.join(' ')}" style="background: ${background.originalFormat}; color: ${foreground.originalFormat};"> + ${foreground.originalFormat} on ${background.originalFormat}<br> + Ratio: ${contrast} ${accessible ? "✅" : "❌"}<br> + WCAG: ${level} + </div> + `; + } + + function updateColors() { + const input = document.getElementById("colors").value; + const results = document.getElementById("results"); + + // First split by newlines + const colorLines = input.split(/\n+/).map(line => line.trim()).filter(line => line); + + // Then process each line to handle both comma-separated values and rgb/rgba formats + let colors = colorLines.flatMap(line => { + // Temporary replace commas in rgb/rgba values with a special marker + line = line.replace(/rgba?\([^)]+\)/g, match => match.replace(/,/g, '|')); + + // Split by commas and restore the original commas in rgb/rgba values + return line.split(',') + .map(color => color.trim().replace(/\|/g, ',')) + .filter(color => color); + }); + + // Clear any existing warnings + document.querySelectorAll('.warning').forEach(el => el.remove()); + + // Deduplicate colors by converting to a standard format and back + colors = [...new Set(colors.map(color => { + try { + const parsed = parseColor(color); + return parsed.originalFormat; + } catch (error) { + return color; // Keep invalid colors for error handling + } + }))]; + + const duplicates = findDuplicateColors(colors); + if (duplicates.length > 0) { + console.warn('Duplicate colors found:', duplicates); + } + + try { + const colorCombinations = colors.flatMap((color1, i) => + colors.slice(i + 1).map(color2 => { + try { + const contrast = getContrastRatio(color1, color2).toFixed(2); + const level = getWCAGLevel(contrast); + const accessible = level !== "Fail"; + return { color1, color2, contrast, level, accessible }; + } catch (error) { + console.error(`Error processing colors ${color1} and ${color2}:`, error); + return null; + } + }).filter(combo => combo !== null) + ); + + const sortByContrast = document.getElementById("sortContrast").checked; + if (sortByContrast) { + colorCombinations.sort((a, b) => b.contrast - a.contrast); + } + + const colorBoxes = colorCombinations.map(({ color1, color2, contrast, level, accessible }) => { + return createColorBox(color1, color2, contrast, level, accessible, false); + }); + + results.innerHTML = colorBoxes.join(''); + updateVisibility(); + } catch (error) { + results.innerHTML = `<div class="error">Error: ${error.message}</div>`; + } + } + + function findDuplicateColors(colors) { + const seen = new Map(); + const duplicates = new Set(); + + colors.forEach(color => { + try { + const parsed = parseColor(color); + const normalized = `rgb(${parsed.r},${parsed.g},${parsed.b},${parsed.a})`; + + if (seen.has(normalized)) { + duplicates.add(color); + } + seen.set(normalized, color); + } catch (error) { + // Ignore invalid colors + } + }); + + return Array.from(duplicates); + } + + function updateVisibility() { + let hideFails = document.getElementById("toggleFails").checked; + + document.querySelectorAll(".failing").forEach(box => { + box.style.display = hideFails ? "none" : "block"; + }); + } + + + + + + + + + + + + + // ===== Testing Functions ===== + function runContrastTests() { + const testCases = [ + { + color1: '#000000', + color2: '#FFFFFF', + expectedRatio: 21, // Black on White should be ~21:1 + description: 'Black on White - Maximum Contrast' + }, + { + color1: '#000000', + color2: '#000000', + expectedRatio: 1, // Same colors should be 1:1 + description: 'Same Colors - Minimum Contrast' + }, + { + color1: '#000', + color2: 'rgb(255, 255, 255)', + expectedRatio: 21, + description: 'Short hex vs RGB - Should match black on white' + }, + { + color1: 'rgba(0, 0, 0, 1)', + color2: '#FFFFFF', + expectedRatio: 21, + description: 'RGBA vs Hex - Should match black on white' + }, + { + color1: 'rgba(0, 0, 0, 0.5)', + color2: '#FFFFFF', + expectedRatio: 3.95, // Corrected value based on WCAG formula + description: 'Semi-transparent black on white' + }, + { + color1: '#777777', + color2: '#FFFFFF', + expectedRatio: 4.48, // Common gray on white + description: 'Gray on White - Just below AA' + }, + { + color1: '#565656', + color2: '#FFFFFF', + expectedRatio: 7.34, + description: 'Dark Gray on White - AAA level' + }, + { + color1: '#FF0000', + color2: '#FFFFFF', + expectedRatio: 3.99, + description: 'Red on White' + }, + { + color1: 'rgba(0, 0, 0, 0.8)', + color2: '#FFFFFF', + expectedRatio: 12.63, + description: '80% transparent black on white' + }, + { + color1: '#FFFFFF', + color2: '#FFFFFE', + expectedRatio: 1.0, + description: 'Nearly identical colors' + }, + { + color1: 'rgba(0, 0, 0, 0.001)', + color2: '#FFFFFF', + expectedRatio: 1.0, + description: 'Nearly transparent color' + }, + { + color1: '#808080', + color2: '#FFFFFF', + expectedRatio: 3.95, + description: '50% gray on white' + }, + { + color1: '#FFFFFF', + color2: '#000000', + expectedRatio: 21, + description: 'White on Black - Should match Black on White' + } + ]; + + function runTestGroup(tests) { + let groupResults = { + total: tests.length, + passed: 0, + failed: 0, + details: [] + }; + + tests.forEach(test => { + const actualRatio = parseFloat(getContrastRatio(test.color1, test.color2).toFixed(2)); + const expectedRatio = parseFloat(test.expectedRatio.toFixed(2)); + const difference = Math.abs(actualRatio - expectedRatio); + const passed = difference <= 0.1; + + groupResults.details.push({ + description: test.description, + passed, + expected: expectedRatio, + actual: actualRatio, + difference + }); + + if (passed) groupResults.passed++; + else groupResults.failed++; + }); + + // Log results in a table format + console.table(groupResults.details); + + return groupResults; + } + + // Group tests by category + console.group('Running Contrast Ratio Tests'); + + console.group('Basic Contrast Tests'); + const basicResults = runTestGroup(testCases.filter(t => !t.color1.includes('rgba'))); + console.groupEnd(); + + console.group('Transparency Tests'); + const transparencyResults = runTestGroup(testCases.filter(t => t.color1.includes('rgba'))); + console.groupEnd(); + + console.groupEnd(); + + // Return combined results + return { + total: basicResults.total + transparencyResults.total, + passed: basicResults.passed + transparencyResults.passed, + failed: basicResults.failed + transparencyResults.failed + }; + } + + function runValidationTests() { + const invalidCases = [ + { + input: 'not-a-color', + expectedError: 'Unsupported color format' + }, + { + input: '#GGG', + expectedError: 'Invalid hex color format' + }, + { + input: 'rgb(256,0,0)', + expectedError: 'RGB values must be between 0 and 255' + }, + { + input: 'rgba(0,0,0,1.1)', + expectedError: 'Alpha value must be between 0 and 1' + } + ]; + + console.group('Running Validation Tests'); + + let results = { + total: invalidCases.length, + passed: 0, + failed: 0 + }; + + invalidCases.forEach(test => { + try { + parseColor(test.input); + console.error(`❌ FAILED: Expected error for "${test.input}"`); + results.failed++; + } catch (error) { + if (error.message.includes(test.expectedError)) { + console.log(`✅ PASSED: Correctly rejected "${test.input}"`); + results.passed++; + } else { + console.error(`❌ FAILED: Wrong error for "${test.input}"`); + console.error(` Expected: ${test.expectedError}`); + console.error(` Got: ${error.message}`); + results.failed++; + } + } + }); + + console.groupEnd(); + return results; + } + + function createTestUI() { + const testSection = document.createElement('section'); + testSection.innerHTML = ` + <div class="test-controls"> + <h2>Test Suite</h2> + <button onclick="runContrastTests()">Run Contrast Tests</button> + <button onclick="runValidationTests()">Run Validation Tests</button> + <button onclick="runAllTests()">Run All Tests</button> + <div id="test-results"></div> + </div> + `; + + // Add some styles + const style = document.createElement('style'); + style.textContent = ` + .test-controls { + margin: 20px 0; + padding: 20px; + border: 1px solid #ccc; + border-radius: 4px; + } + .test-controls button { + margin: 0 10px 10px 0; + } + #test-results { + margin-top: 20px; + font-family: monospace; + } + `; + + document.head.appendChild(style); + document.querySelector('main').appendChild(testSection); + } + + function runAllTests() { + const contrastResults = runContrastTests(); + const validationResults = runValidationTests(); + + const resultsDiv = document.getElementById('test-results'); + resultsDiv.innerHTML = ` + <h3>Test Results</h3> + <p>Contrast Tests: ${contrastResults.passed}/${contrastResults.total} passed</p> + <p>Validation Tests: ${validationResults.passed}/${validationResults.total} passed</p> + `; + } + </script> +</body> +</html> |