about summary refs log tree commit diff stats
path: root/html/ccc/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'html/ccc/index.html')
-rw-r--r--html/ccc/index.html653
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 &mdash; 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>