<!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>