1import { Controller } from "@hotwired/stimulus" 2import { getPalettesForTier, getPalettesByCategory, getPalette } from "../color_palettes" 3 4console.log("Loading color customizer controller") 5 6export default class extends Controller { 7 static targets = [ 8 "primaryPicker", "primaryText", "secondaryPicker", "secondaryText", 9 "successPicker", "successText", "dangerPicker", "dangerText", 10 "warningPicker", "warningText", "infoPicker", "infoText", 11 "highContrast", "borderRadius", "borderRadiusValue", "shadowIntensity", "shadowValue", 12 "colorPreview", "primaryPreview", "secondaryPreview", "successPreview", "dangerPreview", 13 "warningPreview", "infoPreview", "lightPreview", "darkPreview", "sitePreview", "paletteName", 14 "paletteSelector", "darkModeToggle", "autoContrast", "contrastWarning", "contrastMessage" 15 ] 16 static values = { 17 colorsUrl: String, 18 resetUrl: String, 19 environment: String, 20 environmentError: String, 21 applyError: String 22 } 23 24 connect() { 25 console.log("Color customizer controller connected") 26 27 // Disable buttons if not in development 28 if (this.environmentValue !== 'development') { 29 const applyBtn = this.element.querySelector('[data-action*="applyColors"]') 30 const resetBtn = this.element.querySelector('[data-action*="resetColors"]') 31 32 if (applyBtn) { 33 applyBtn.disabled = true 34 applyBtn.title = "Only available in development environment" 35 } 36 if (resetBtn) { 37 resetBtn.disabled = true 38 resetBtn.title = "Only available in development environment" 39 } 40 } 41 42 // Reset everything to clean state 43 this.resetToCleanState() 44 45 this.initializePalettes() 46 this.initializeFromCurrentColors() 47 } 48 49 disconnect() { 50 // Clean up Select2 when controller disconnects 51 if (this.hasPaletteSelectorTarget && $(this.paletteSelectorTarget).data('select2')) { 52 $(this.paletteSelectorTarget).select2('destroy') 53 } 54 } 55 56 resetToCleanState() { 57 // Hide palette name badge completely 58 if (this.hasPaletteNameTarget) { 59 this.paletteNameTarget.style.cssText = 'display: none !important;' 60 this.paletteNameTarget.textContent = '' 61 this.paletteNameTarget.classList.add('d-none') 62 } 63 64 // Clear palette selector 65 if (this.hasPaletteSelectorTarget) { 66 this.paletteSelectorTarget.value = "" 67 this.paletteSelectorTarget.selectedIndex = 0 68 } 69 70 // Clear any inline styles on document 71 const rootElement = document.documentElement 72 const cssVariables = ['--bs-primary', '--bs-secondary', '--bs-success', '--bs-danger', 73 '--bs-warning', '--bs-info', '--bs-light', '--bs-dark', 74 '--bs-primary-rgb', '--bs-secondary-rgb', '--bs-success-rgb', 75 '--bs-danger-rgb', '--bs-warning-rgb', '--bs-info-rgb', 76 '--bs-light-rgb', '--bs-dark-rgb'] 77
78 cssVariables.forEach(varName => { 79 rootElement.style.removeProperty(varName) 80 }) 81 } 82 83 initializePalettes() { 84 // Get palettes for the current tier 85 this.beautifulPalettes = getPalettesForTier('plus') 86 this.paletteCategories = getPalettesByCategory('plus') 87 88 // Initialize Select2 if selector exists 89 if (this.hasPaletteSelectorTarget) { 90 this.initializePaletteSelector() 91 } 92 } 93 94 initializePaletteSelector() { 95 // Build options data for Select2 96 const selectData = [] 97 98 Object.entries(this.paletteCategories).forEach(([category, palettes]) => { 99 const children = Object.entries(palettes).map(([key, palette]) => ({ 100 id: key, 101 text: palette.name, 102 palette: palette 103 })) 104 105 selectData.push({ 106 text: category, 107 children: children 108 }) 109 }) 110 111 // Initialize Select2 with custom template 112 $(this.paletteSelectorTarget).select2({ 113 data: selectData, 114 placeholder: "Select a palette...", 115 allowClear: true, 116 templateResult: this.formatPaletteOption.bind(this), 117 templateSelection: this.formatPaletteSelection.bind(this), 118 escapeMarkup: function(markup) { return markup } 119 }) 120 121 // Handle select2 change event 122 $(this.paletteSelectorTarget).on('select2:select', (e) => { 123 if (e.params.data && e.params.data.id) { 124 console.log("Select2 palette selected:", e.params.data.id) 125 this.selectPaletteFromSelect2(e.params.data.id) 126 } 127 }) 128 129 // Handle clear event 130 $(this.paletteSelectorTarget).on('select2:clear', () => { 131 // Just clear the selection, don't change colors 132 }) 133 } 134 135 formatPaletteOption(data) { 136 if (!data.id || !data.palette) { 137 return data.text // Return category headers as-is 138 } 139 140 const palette = data.palette 141 return ` 142 <div class="d-flex align-items-center gap-2"> 143 <div class="d-flex gap-1"> 144 <span class="color-preview-dot" style="background-color: ${palette.primary}; width: 16px; height: 16px; border-radius: 50%; display: inline-block; border: 1px solid #dee2e6;"></span> 145 <span class="color-preview-dot" style="background-color: ${palette.secondary}; width: 16px; height: 16px; border-radius: 50%; display: inline-block; border: 1px solid #dee2e6;"></span> 146 <span class="color-preview-dot" style="background-color: ${palette.success}; width: 16px; height: 16px; border-radius: 50%; display: inline-block; border: 1px solid #dee2e6;"></span> 147 <span class="color-preview-dot" style="background-color: ${palette.danger}; width: 16px; height: 16px; border-radius: 50%; display: inline-block; border: 1px solid #dee2e6;"></span> 148 </div> 149 <span>${data.text}</span> 150 </div> 151 ` 152 } 153 154 formatPaletteSelection(data) { 155 if (!data.id || !data.palette) { 156 return data.text 157 } 158 159 const palette = data.palette 160 return ` 161 <div class="d-flex align-items-center gap-2"> 162 <div class="d-flex gap-1"> 163 <span class="color-preview-dot" style="background-color: ${palette.primary}; width: 12px; height: 12px; border-radius: 50%; display: inline-block; border: 1px solid #dee2e6;"></span> 164 <span class="color-preview-dot" style="background-color: ${palette.secondary}; width: 12px; height: 12px; border-radius: 50%; display: inline-block; border: 1px solid #dee2e6;"></span> 165 </div> 166 <span>${data.text}</span> 167 </div> 168 ` 169 } 170 171 selectPaletteFromSelect2(paletteKey) { 172 const palette = this.beautifulPalettes[paletteKey] 173 if (!palette) return 174 175 const fullPalette = { 176 ...palette, 177 light: '#f8f9fa', 178 dark: '#212529' 179 } 180 181 // Apply the palette 182 this.applyPaletteToUI(fullPalette) 183 184 // Auto-enable dark mode for dark theme palettes, disable for others 185 if (this.hasDarkModeToggleTarget) { 186 if (this.isDarkTheme(paletteKey)) { 187 if (!this.darkModeToggleTarget.checked) { 188 this.darkModeToggleTarget.checked = true 189 this.toggleDarkMode() 190 // Flash the toggle to draw attention 191 this.flashDarkModeToggle() 192 } 193 } else { 194 // Disable dark mode for non-dark themes 195 if (this.darkModeToggleTarget.checked) { 196 this.darkModeToggleTarget.checked = false 197 this.toggleDarkMode() 198 } 199 } 200 } 201 } 202 203 flashDarkModeToggle() { 204 // Add a visual indicator that dark mode was auto-enabled 205 const toggleLabel = this.darkModeToggleTarget.closest('.form-check').querySelector('label') 206 if (toggleLabel) { 207 toggleLabel.classList.add('text-primary', 'fw-bold') 208 setTimeout(() => { 209 toggleLabel.classList.remove('text-primary', 'fw-bold') 210 }, 2000) 211 } 212 } 213 214 isDarkTheme(paletteKey) { 215 // Check if palette is in Dark Themes category 216 const darkThemes = this.paletteCategories["Dark Themes"] || {} 217 return darkThemes.hasOwnProperty(paletteKey) 218 } 219 220 initializeFromCurrentColors() { 221 // Wait for CSS to be fully loaded 222 requestAnimationFrame(() => { 223 // Get the computed styles 224 const root = getComputedStyle(document.documentElement) 225 226 // Helper to convert CSS color to hex
227 const cssColorToHex = (cssColor) => { 228 if (!cssColor || cssColor === 'none') return null 229 230 // If already hex, return it 231 if (cssColor.startsWith('#')) return cssColor 232 233 // Create a temporary element to get computed color 234 const temp = document.createElement('div') 235 temp.style.color = cssColor 236 document.body.appendChild(temp) 237 const computed = getComputedStyle(temp).color 238 document.body.removeChild(temp) 239 240 // Parse rgb/rgba 241 const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) 242 if (!match) return cssColor // Return as-is if can't parse 243 244 const r = parseInt(match[1]) 245 const g = parseInt(match[2]) 246 const b = parseInt(match[3]) 247 248 const hex = "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase() 249 console.log(`Converted rgb(${r},${g},${b}) to ${hex}`) 250 return hex 251 } 252 253 // Debug: log raw CSS values 254 console.log("Raw CSS primary:", root.getPropertyValue('--bs-primary')) 255 console.log("Raw CSS secondary:", root.getPropertyValue('--bs-secondary')) 256 console.log("Document styles:", document.documentElement.style.cssText) 257 258 // Get all current Bootstrap colors 259 const currentColors = { 260 primary: cssColorToHex(root.getPropertyValue('--bs-primary').trim()) || '#1c75bc', 261 secondary: cssColorToHex(root.getPropertyValue('--bs-secondary').trim()) || '#2c5060', 262 success: cssColorToHex(root.getPropertyValue('--bs-success').trim()) || '#11a65d', 263 danger: cssColorToHex(root.getPropertyValue('--bs-danger').trim()) || '#f50e06', 264 warning: cssColorToHex(root.getPropertyValue('--bs-warning').trim()) || '#e4cb11', 265 info: cssColorToHex(root.getPropertyValue('--bs-info').trim()) || '#a6a737', 266 light: '#f8f9fa', // Always use Bootstrap defaults 267 dark: '#212529' // Always use Bootstrap defaults 268 } 269 270 console.log("Initialized with colors:", currentColors) 271 272 // Set all color pickers and text inputs 273 if (this.hasPrimaryPickerTarget) { 274 this.primaryPickerTarget.value = currentColors.primary 275 this.primaryTextTarget.value = currentColors.primary 276 } 277 if (this.hasSecondaryPickerTarget) { 278 this.secondaryPickerTarget.value = currentColors.secondary 279 this.secondaryTextTarget.value = currentColors.secondary 280 } 281 if (this.hasSuccessPickerTarget) { 282 this.successPickerTarget.value = currentColors.success 283 this.successTextTarget.value = currentColors.success 284 } 285 if (this.hasDangerPickerTarget) { 286 this.dangerPickerTarget.value = currentColors.danger 287 this.dangerTextTarget.value = currentColors.danger 288 } 289 if (this.hasWarningPickerTarget) { 290 this.warningPickerTarget.value = currentColors.warning 291 this.warningTextTarget.value = currentColors.warning 292 } 293 if (this.hasInfoPickerTarget) { 294 this.infoPickerTarget.value = currentColors.info 295 this.infoTextTarget.value = currentColors.info 296 } 297 298 // Update color preview squares with current colors (this also handles dynamic button contrast) 299 this.updateColorPreviews(currentColors) 300 301 // Ensure palette selector is empty on init 302 if (this.hasPaletteSelectorTarget) { 303 this.paletteSelectorTarget.value = "" 304 } 305 }) 306 } 307 308 updateColors(isManualEdit = true) { 309 console.log("updateColors called, isManualEdit:", isManualEdit) 310 try { 311 // Get all color values 312 const colors = { 313 primary: this.primaryPickerTarget.value, 314 secondary: this.secondaryPickerTarget.value, 315 success: this.hasSuccessPickerTarget ? this.successPickerTarget.value : '#11a65d', 316 danger: this.hasDangerPickerTarget ? this.dangerPickerTarget.value : '#f50e06', 317 warning: this.hasWarningPickerTarget ? this.warningPickerTarget.value : '#e4cb11', 318 info: this.hasInfoPickerTarget ? this.infoPickerTarget.value : '#a6a737', 319 light: '#f8f9fa', 320 dark: '#212529' 321 } 322 323 // Sync text inputs with color pickers 324 if (this.hasPrimaryTextTarget) this.primaryTextTarget.value = colors.primary 325 if (this.hasSecondaryTextTarget) this.secondaryTextTarget.value = colors.secondary 326 if (this.hasSuccessTextTarget) this.successTextTarget.value = colors.success 327 if (this.hasDangerTextTarget) this.dangerTextTarget.value = colors.danger 328 if (this.hasWarningTextTarget) this.warningTextTarget.value = colors.warning 329 if (this.hasInfoTextTarget) this.infoTextTarget.value = colors.info 330 331 // Hide palette name only when manually editing colors 332 if (this.hasPaletteNameTarget && isManualEdit) { 333 this.paletteNameTarget.style.display = 'none' 334 } 335 336 // Update live preview 337 this.updateColorPreviews(colors) 338 } catch (error) { 339 console.error("Error updating colors:", error) 340 } 341 } 342 343 updateFromText(event) { 344 const target = event.target 345 const value = target.value 346 347 if (this.isValidHexColor(value)) { 348 if (target === this.primaryTextTarget && this.hasPrimaryPickerTarget) { 349 this.primaryPickerTarget.value = value 350 } else if (target === this.secondaryTextTarget && this.hasSecondaryPickerTarget) { 351 this.secondaryPickerTarget.value = value 352 } else if (target === this.successTextTarget && this.hasSuccessPickerTarget) { 353 this.successPickerTarget.value = value 354 } else if (target === this.dangerTextTarget && this.hasDangerPickerTarget) { 355 this.dangerPickerTarget.value = value 356 } else if (target === this.warningTextTarget && this.hasWarningPickerTarget) { 357 this.warningPickerTarget.value = value 358 } else if (target === this.infoTextTarget && this.hasInfoPickerTarget) { 359 this.infoPickerTarget.value = value 360 } 361 this.updateColors() 362 } 363 } 364 365 updateStyles() { 366 // Update border radius 367 if (this.hasBorderRadiusTarget) { 368 const radius = this.borderRadiusTarget.value 369 this.borderRadiusValueTarget.textContent = `${radius}px` 370 371 // Apply to preview 372 if (this.hasSitePreviewTarget) { 373 // Reapply color overrides with new styles 374 const colors = this.getCurrentColors() 375 this.updateColorPreviews(colors) 376 } 377 } 378 379 // Update shadow intensity 380 if (this.hasShadowIntensityTarget) { 381 const intensity = this.shadowIntensityTarget.value 382 const labels = ['None', 'Minimal', 'Subtle', 'Normal', 'Medium', 'Strong', 'Heavy', 'Intense', 'Maximum', 'Extreme', 'Ultra'] 383 this.shadowValueTarget.textContent = labels[intensity] || 'Normal' 384 } 385 } 386 387 getCurrentColors() { 388 return { 389 primary: this.primaryPickerTarget.value, 390 secondary: this.secondaryPickerTarget.value, 391 success: this.hasSuccessPickerTarget ? this.successPickerTarget.value : '#11a65d', 392 danger: this.hasDangerPickerTarget ? this.dangerPickerTarget.value : '#f50e06', 393 warning: this.hasWarningPickerTarget ? this.warningPickerTarget.value : '#e4cb11', 394 info: this.hasInfoPickerTarget ? this.infoPickerTarget.value : '#a6a737', 395 light: '#f8f9fa', 396 dark: '#212529' 397 } 398 } 399 400 // Legacy method kept for compatibility if needed
401 selectPalette(event) { 402 // This is now handled by Select2 events 403 // Keeping method to avoid breaking existing references 404 } 405 406 applyPaletteToUI(palette) { 407 console.log("applyPaletteToUI called with:", palette) 408 409 // Update all color pickers with the palette colors 410 if (this.hasPrimaryPickerTarget) { 411 this.primaryPickerTarget.value = palette.primary 412 this.primaryTextTarget.value = palette.primary 413 } 414 if (this.hasSecondaryPickerTarget) { 415 this.secondaryPickerTarget.value = palette.secondary 416 this.secondaryTextTarget.value = palette.secondary 417 } 418 if (this.hasSuccessPickerTarget) { 419 this.successPickerTarget.value = palette.success 420 this.successTextTarget.value = palette.success 421 } 422 if (this.hasDangerPickerTarget) { 423 this.dangerPickerTarget.value = palette.danger 424 this.dangerTextTarget.value = palette.danger 425 } 426 if (this.hasWarningPickerTarget) { 427 this.warningPickerTarget.value = palette.warning 428 this.warningTextTarget.value = palette.warning 429 } 430 if (this.hasInfoPickerTarget) { 431 this.infoPickerTarget.value = palette.info 432 this.infoTextTarget.value = palette.info 433 } 434 435 // Update palette name display 436 if (this.hasPaletteNameTarget && palette.name) { 437 console.log("Setting palette name to:", palette.name) 438 console.trace("Palette name set from:") 439 this.paletteNameTarget.textContent = palette.name 440 this.paletteNameTarget.classList.remove('d-none') 441 this.paletteNameTarget.style.display = 'inline-block' 442 } 443 444 // Trigger color update which will update previews (not a manual edit) 445 this.updateColors(false) 446 } 447 448 generateRandom() { 449 console.log("generateRandom called") 450 const colorScheme = this.generateHarmoniousColorPair() 451 452 // Use the unified method to apply palette 453 this.applyPaletteToUI(colorScheme) 454 } 455 456 async applyColors() { 457 const colors = { 458 primary: this.primaryPickerTarget.value, 459 secondary: this.secondaryPickerTarget.value, 460 success: this.hasSuccessPickerTarget ? this.successPickerTarget.value : '#11a65d', 461 danger: this.hasDangerPickerTarget ? this.dangerPickerTarget.value : '#f50e06', 462 warning: this.hasWarningPickerTarget ? this.warningPickerTarget.value : '#e4cb11', 463 info: this.hasInfoPickerTarget ? this.infoPickerTarget.value : '#a6a737', 464 light: '#f8f9fa', 465 dark: '#212529' 466 } 467 468 try { 469 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') 470 471 const response = await fetch(this.colorsUrlValue, { 472 method: 'POST', 473 headers: { 474 'Content-Type': 'application/json', 475 'X-CSRF-Token': csrfToken 476 }, 477 body: JSON.stringify({ colors: colors }) 478 }) 479 480 if (response.ok) { 481 // Also update the actual page CSS variables 482 this.updatePreview(colors) 483 this.showNotification("Colors applied successfully! CSS will reload automatically.", "success") 484 } else { 485 this.showNotification("Error applying colors. Please try again.", "error") 486 } 487 } catch (error) { 488 console.error("Error applying colors:", error) 489 this.showNotification("Error applying colors. Please try again.", "error") 490 } 491 } 492 493 async resetColors() { 494 console.log("resetColors called") 495 496 if (this.environmentValue !== 'development') { 497 this.showNotification(this.environmentErrorValue, "error") 498 return 499 } 500 501 try { 502 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') 503 504 const response = await fetch(this.resetUrlValue, { 505 method: 'POST', 506 headers: { 507 'Content-Type': 'application/json', 508 'X-CSRF-Token': csrfToken 509 } 510 }) 511 512 const data = await response.json() 513 514 if (response.ok && data.status === 'success') { 515 this.showNotification(data.message || "Colors reset to defaults!", "success") 516 // Reload the page to get the updated colors from the file 517 setTimeout(() => { 518 window.location.reload() 519 }, 1000) 520 } else { 521 this.showNotification(data.message || this.applyErrorValue, "error") 522 } 523 } catch (error) { 524 console.error("Error resetting colors:", error) 525 this.showNotification(this.applyErrorValue, "error") 526 } 527 } 528
529 generateColorPalette(primary, secondary) { 530 const colors = {} 531 const primaryHsl = this.hexToHsl(primary) 532 const secondaryHsl = this.hexToHsl(secondary) 533 534 if (!primaryHsl || !secondaryHsl) return colors 535 536 // Basic colors 537 colors.primary = primary 538 colors.secondary = secondary 539 540 // For better color generation, use specific semantic colors 541 // that work well in Bootstrap contexts 542 543 // Success: Always use a green that works well 544 const successHue = 140 // Green range 545 colors.success = this.hslToHex({ 546 h: successHue, 547 s: 0.7, 548 l: 0.35 549 }) 550 551 // Danger: Always use a red that works well 552 const dangerHue = 0 // Red 553 colors.danger = this.hslToHex({ 554 h: dangerHue, 555 s: 0.8, 556 l: 0.45 557 }) 558 559 // Warning: Always use an orange/yellow that works well 560 const warningHue = 40 // Orange range 561 colors.warning = this.hslToHex({ 562 h: warningHue, 563 s: 0.9, 564 l: 0.45 565 }) 566 567 // Info: Use a cyan/blue that complements 568 const infoHue = 195 // Cyan range 569 colors.info = this.hslToHex({ 570 h: infoHue, 571 s: 0.7, 572 l: 0.45 573 }) 574 575 // Light: very light neutral 576 colors.light = '#f8f9fa' 577 578 // Dark: very dark neutral 579 colors.dark = '#212529' 580 581 // Apply high contrast adjustments if needed 582 if (highContrast) { 583 return this.applyHighContrastAdjustments(colors) 584 } 585 586 return colors 587 } 588 589 generateVariation(baseColor, options = {}) { 590 const hsl = this.hexToHsl(baseColor) 591 592 return this.hslToHex({ 593 h: options.hue !== undefined ? options.hue : hsl.h, 594 s: options.saturation !== undefined ? options.saturation : hsl.s, 595 l: options.lightness !== undefined ? options.lightness : hsl.l 596 }) 597 } 598 599 applyHighContrastAdjustments(colors) { 600 const adjusted = { ...colors } 601 602 // WCAG AAA compliant high contrast adjustments 603 // Ensure 7:1 contrast ratio for normal text, 4.5:1 for large text 604 605 // Make dark colors darker for better contrast 606 adjusted.primary = this.ensureContrast(colors.primary, '#ffffff', 7.0, 'darken') 607 adjusted.secondary = this.ensureContrast(colors.secondary, '#ffffff', 7.0, 'darken') 608 adjusted.success = this.ensureContrast(colors.success, '#ffffff', 7.0, 'darken') 609 adjusted.danger = this.ensureContrast(colors.danger, '#ffffff', 7.0, 'darken') 610 adjusted.info = this.ensureContrast(colors.info, '#ffffff', 7.0, 'darken') 611 612 // Warning needs dark text, so ensure contrast with dark text 613 adjusted.warning = this.ensureContrast(colors.warning, '#000000', 7.0, 'lighten') 614 615 // Light should have excellent contrast with dark text 616 adjusted.light = this.ensureContrast(colors.light, '#000000', 7.0, 'lighten') 617 618 // Dark should be very dark for maximum contrast 619 adjusted.dark = '#000000' 620 621 return adjusted 622 } 623 624 ensureContrast(color, textColor, targetRatio, direction) {
625 let currentColor = color 626 let attempts = 0 627 const maxAttempts = 20 628 629 while (attempts < maxAttempts) { 630 const ratio = this.getContrastRatio(currentColor, textColor) 631 if (ratio >= targetRatio) { 632 return currentColor 633 } 634 635 const hsl = this.hexToHsl(currentColor) 636 if (!hsl) return color 637 638 // Adjust lightness based on direction 639 if (direction === 'darken') { 640 hsl.l = Math.max(0, hsl.l - 0.05) 641 } else { 642 hsl.l = Math.min(1, hsl.l + 0.05) 643 } 644 645 currentColor = this.hslToHex(hsl) 646 attempts++ 647 } 648 649 return currentColor 650 } 651 652 getContrastRatio(color1, color2) { 653 const lum1 = this.getLuminance(color1) 654 const lum2 = this.getLuminance(color2) 655 const bright = Math.max(lum1, lum2) 656 const dark = Math.min(lum1, lum2) 657 return (bright + 0.05) / (dark + 0.05) 658 } 659 660 generateHarmoniousColorPair() { 661 // Convert stored palettes to array format 662 const beautifulPalettes = Object.values(this.beautifulPalettes || {}) 663 664 // 70% chance to use a variation of a palette 665 const useVariation = Math.random() > 0.3 666 let selectedPalette 667 668 if (useVariation) { 669 const basePalette = beautifulPalettes[Math.floor(Math.random() * beautifulPalettes.length)] 670 selectedPalette = this.generateBeautifulVariation(basePalette) 671 } else { 672 selectedPalette = beautifulPalettes[Math.floor(Math.random() * beautifulPalettes.length)] 673 } 674 675 console.log(`Generated palette: ${selectedPalette.name}`) 676 677 // Use Bootstrap's default light and dark colors which work universally 678 selectedPalette.light = '#f8f9fa' // Bootstrap's default light 679 selectedPalette.dark = '#212529' // Bootstrap's default dark 680 681 return selectedPalette 682 } 683 684 // Generate beautiful color variations based on curated palettes 685 generateBeautifulVariation(basePalette) { 686 const variation = { 687 ...basePalette, 688 name: basePalette.name + " (Variation)" 689 } 690 691 // Create slight variations of the base colors 692 const colorKeys = ['primary', 'secondary', 'success', 'danger', 'w
692arning', 'info'] 693 694 colorKeys.forEach(colorName => { 695 const baseColor = basePalette[colorName] 696 const hsl = this.hexToHsl(baseColor) 697 698 // Apply more noticeable variations 699 const hueShift = (Math.random() - 0.5) * 30 // ±15 degrees 700 const satShift = (Math.random() - 0.5) * 0.3 // ±15% 701 const lightShift = (Math.random() - 0.5) * 0.25 // ±12.5% 702 703 const newHsl = { 704 h: (hsl.h + hueShift + 360) % 360, 705 s: Math.max(0.1, Math.min(0.9, hsl.s + satShift)), 706 l: Math.max(0.15, Math.min(0.85, hsl.l + lightShift)) 707 } 708 709 variation[colorName] = this.hslToHex(newHsl) 710 }) 711 712 return variation 713 } 714 715 generateRandomColor() { 716 // Fallback method for single random colors 717 const hue = Math.floor(Math.random() * 360) 718 const saturation = 0.4 + Math.random() * 0.4 // 40-80% 719 const lightness = 0.3 + Math.random() * 0.4 // 30-70% 720 721 return this.hslToHex({ h: hue, s: saturation, l: lightness }) 722 } 723 724 toggleDarkMode() { 725 const isDark = this.darkModeToggleTarget.checked 726 727 if (this.hasSitePreviewTarget) { 728 if (isDark) { 729 // Apply dark mode styles 730 this.sitePreviewTarget.style.backgroundColor = '#212529' 731 this.sitePreviewTarget.style.color = '#f8f9fa' 732 733 // Add Bootstrap dark mode variables 734 this.sitePreviewTarget.style.setProperty('--bs-body-bg', '#212529') 735 this.sitePreviewTarget.style.setProperty('--bs-body-color', '#f8f9fa') 736 this.sitePreviewTarget.style.setProperty('--bs-border-color', '#495057') 737 this.sitePreviewTarget.style.setProperty('--bs-body-bg-rgb', '33, 37, 41') 738 this.sitePreviewTarget.style.setProperty('--bs-body-color-rgb', '248, 249, 250') 739 740 // Add dark mode class for additional styling 741 this.sitePreviewTarget.classList.add('dark-mode') 742 } else { 743 // Revert to light mode 744 this.sitePreviewTarget.style.backgroundColor = 'white' 745 this.sitePreviewTarget.style.color = '#212529' 746 747 // Reset Bootstrap variables 748 this.sitePreviewTarget.style.setProperty('--bs-body-bg', '#ffffff') 749 this.sitePreviewTarget.style.setProperty('--bs-body-color', '#212529') 750 this.sitePreviewTarget.style.setProperty('--bs-border-color', '#dee2e6') 751 this.sitePreviewTarget.style.setProperty('--bs-body-bg-rgb', '255, 255, 255') 752 this.sitePreviewTarget.style.setProperty('--bs-body-color-rgb', '33, 37, 41') 753 754 this.sitePreviewTarget.classList.remove('dark-mode') 755 } 756 757 // Reapply colors to ensure they work in new mode 758 const currentColors = this.getCurrentColors() 759 this.updateColorPreviews(currentColors) 760 } 761 } 762 763 updateColorPreviews(colors) { 764 console.log("updateColorPreviews called with:", colors) 765 766 // Check if hero gradient is dark or light 767 const primaryLuminance = this.getLuminance(this.hexToRgb(colors.primary)) 768 const secondaryLuminance = this.getLuminance(this.hexToRgb(colors.secondary)) 769 const avgLuminance = (primaryLuminance + secondaryLuminance) / 2 770 const isDarkHero = avgLuminance < 0.5 771 772 console.log('Hero contrast check:', { 773 primary: colors.primary, 774 secondary: colors.secondary, 775 primaryLuminance, 776 secondaryLuminance, 777 avgLuminance, 778 isDarkHero 779 }) 780 781 // Update hero buttons based on gradient darkness 782 if (this.hasSitePreviewTarget) { 783 const heroButtons = this.sitePreviewTarget.querySelectorAll('[data-dynamic-contrast="true"]') 784 heroButtons.forEach(btn => { 785 // Check if it's an outline button 786 const isOutline = btn.classList.contains('btn-outline-light') || btn.classList.contains('btn-outline-dark') 787 788 // Remove all button color classes 789 btn.classList.remove('btn-light', 'btn-dark', 'btn-outline-light', 'btn-outline-dark') 790 791 if (isDarkHero) { 792 // Dark gradient - use light buttons 793 btn.classList.add(isOutline ? 'btn-outline-light' : 'btn-light') 794 } else { 795 // Light gradient - use dark buttons 796 btn.classList.add(isOutline ? 'btn-outline-dark' : 'btn-dark') 797 } 798 }) 799 } 800 801 // Update palette preview swatches (small squares) 802 const updatePreview = (target, key, color) => { 803 target.style.backgroundColor = color 804 target.classList.remove('adjusted') 805 target.title = `${key.charAt(0).toUpperCase() + key.slice(1)}: ${color}` 806 } 807 808 updatePreview(this.primaryPreviewTarget, 'primary', colors.primary) 809 updatePreview(this.secondaryPreviewTarget, 'secondary', colors.secondary) 810 updatePreview(this.successPreviewTarget, 'success', colors.success) 811 updatePreview(this.dangerPreviewTarget, 'danger', colors.danger) 812 updatePreview(this.warningPreviewTarget, 'warning', colors.warning) 813 updatePreview(this.infoPreviewTarget, 'info', colors.info) 814 updatePreview(this.lightPreviewTarget, 'light', colors.light) 815 updatePreview(this.darkPreviewTarget, 'dark', colors.dark) 816 817 // Update live site preview by setting CSS variables on the preview container 818 if (this.hasSitePreviewTarget) { 819 // Set CSS variables 820 this.sitePreviewTarget.style.setProperty('--bs-primary', colors.primary) 821 this.sitePreviewTarget.style.setProperty('--bs-secondary', colors.secondary) 822 this.sitePreviewTarget.style.setProperty('--bs-success', colors.success) 823 this.sitePreviewTarget.style.setProperty('--bs-danger', colors.danger) 824 this.sitePreviewTarget.style.setProperty('--bs-warning', colors.warning) 825 this.sitePreviewTarget.style.setProperty('--bs-info', colors.info) 826 this.sitePreviewTarget.style.setProperty('--bs-light', colors.light) 827 this.sitePreviewTarget.style.setProperty('--bs-dark', colors.dark) 828 829 // Update RGB variants for the site preview
830 Object.keys(colors).forEach(key => { 831 const rgb = this.hexToRgb(colors[key]) 832 if (rgb) { 833 this.sitePreviewTarget.style.setProperty(`--bs-${key}-rgb`, `${rgb.r}, ${rgb.g}, ${rgb.b}`) 834 } 835 }) 836 837 // Add important Bootstrap component variables that depend on theme colors 838 // These are needed for proper component styling 839 this.sitePreviewTarget.style.setProperty('--bs-primary-text-emphasis', this.darken(colors.primary, 0.6)) 840 this.sitePreviewTarget.style.setProperty('--bs-secondary-text-emphasis', this.darken(colors.secondary, 0.6)) 841 this.sitePreviewTarget.style.setProperty('--bs-success-text-emphasis', this.darken(colors.success, 0.6)) 842 this.sitePreviewTarget.style.setProperty('--bs-danger-text-emphasis', this.darken(colors.danger, 0.6)) 843 this.sitePreviewTarget.style.setProperty('--bs-warning-text-emphasis', this.darken(colors.warning, 0.6)) 844 this.sitePreviewTarget.style.setProperty('--bs-info-text-emphasis', this.darken(colors.info, 0.6)) 845 846 this.sitePreviewTarget.style.setProperty('--bs-primary-bg-subtle', this.lighten(colors.primary, 0.8)) 847 this.sitePreviewTarget.style.setProperty('--bs-secondary-bg-subtle', this.lighten(colors.secondary, 0.8)) 848 this.sitePreviewTarget.style.setProperty('--bs-success-bg-subtle', this.lighten(colors.success, 0.8)) 849 this.sitePreviewTarget.style.setProperty('--bs-danger-bg-subtle', this.lighten(colors.danger, 0.8)) 850 this.sitePreviewTarget.style.setProperty('--bs-warning-bg-subtle', this.lighten(colors.warning, 0.8)) 851 this.sitePreviewTarget.style.setProperty('--bs-info-bg-subtle', this.lighten(colors.info, 0.8)) 852 853 this.sitePreviewTarget.style.setProperty('--bs-primary-border-subtle', this.lighten(colors.primary, 0.6)) 854 this.sitePreviewTarget.style.setProperty('--bs-secondary-border-subtle', this.lighten(colors.secondary, 0.6)) 855 this.sitePreviewTarget.style.setProperty('--bs-success-border-subtle', this.lighten(colors.success, 0.6)) 856 this.sitePreviewTarget.style.setProperty('--bs-danger-border-subtle', this.lighten(colors.danger, 0.6)) 857 this.sitePreviewTarget.style.setProperty('--bs-warning-border-subtle', this.lighten(colors.warning, 0.6)) 858 this.sitePreviewTarget.style.setProperty('--bs-info-border-subtle', this.lighten(colors.info, 0.6)) 859 860 // Force Bootstrap utilities to use our custom colors by adding style overrides 861 const styles = { 862 borderRadius: this.hasBorderRadiusTarget ? this.borderRadiusTarget.value : 4, 863 shadowIntensity: this.hasShadowIntensityTarget ? this.shadowIntensityTarget.value : 2 864 } 865 this.addColorOverrides(colors, styles) 866 } 867 } 868 869 addColorOverrides(colors, styles = {}) { 870 // Remove existing style element if present 871 const existingStyle = document.getElementById('color-customizer-overrides') 872 if (existingStyle) { 873 existingStyle.remove() 874 } 875 876 const borderRadius = styles.borderRadius || 4 877 const shadowIntensity = styles.shadowIntensity || 2 878 879 // Define shadow values based on intensity 880 const shadows = [ 881 'none', 882 '0 1px 2px rgba(0,0,0,0.05)', 883 '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.06)', 884 '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)', 885 '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)', 886 '0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04)', 887 '0 25px 50px -12px rgba(0,0,0,0.25)', 888 '0 35px 60px -15px rgba(0,0,0,0.3)', 889 '0 50px 100px -20px rgba(0,0,0,0.25), 0 30px 60px -30px rgba(0,0,0,0.3)', 890 '0 60px 120px -25px rgba(0,0,0,0.35), 0 35px 70px -35px rgba(0,0,0,0.35)', 891 '0 70px 140px -30px rgba(0,0,0,0.4), 0 40px 80px -40px rgba(0,0,0,0.4)' 892 ] 893 894 const boxShadow = shadows[shadowIntensity] || shadows[2] 895 896 // Create style element with scoped overrides for the preview 897 const style = document.createElement('style') 898 style.id = 'color-customizer-overrides' 899 style.textContent = ` 900 .site-preview .bg-primary { background-color: ${colors.primary} !important; } 901 .site-preview .bg-secondary { background-color: ${colors.secondary} !important; } 902 .site-preview .bg-success { background-color: ${colors.success} !important; } 903 .site-preview .bg-danger { background-color: ${colors.danger} !important; } 904 .site-preview .bg-warning { background-color: ${colors.warning} !important; } 905 .site-preview .bg-info { background-color: ${colors.info} !important; } 906 .site-preview .bg-light { background-color: ${colors.light} !important; } 907 .site-preview .bg-dark { background-color: ${colors.dark} !important; } 908 909 .site-preview .text-primary { color: ${colors.primary} !important; } 910 .site-preview .text-secondary { color: ${colors.secondary} !important; } 911 .site-preview .text-success { color: ${colors.success} !important; } 912 .site-preview .text-danger { color: ${colors.danger} !important; } 913 .site-preview .text-warning { color: ${colors.warning} !important; } 914 .site-preview .text-info { color: ${colors.info} !important; } 915 .site-preview .text-light { color: ${colors.light} !important; } 916 .site-preview .text-dark { color: ${colors.dark} !important; } 917 918 .site-preview .btn-primary { background-color: ${colors.primary} !important; border-color: ${colors.primary} !important; } 919 .site-preview .btn-secondary { background-color: ${colors.secondary} !important; border-color: ${colors.secondary} !important; } 920 .site-preview .btn-success { background-color: ${colors.success} !important; border-color: ${colors.success} !important; } 921 .site-preview .btn-danger { background-color: ${colors.danger} !important; border-color: ${colors.danger} !important; } 922 .site-preview .btn-warning { background-color: ${colors.warning} !important; border-color: ${colors.warning} !important; } 923 .site-preview .btn-info { background-color: ${colors.info} !important; border-color: ${colors.info} !important; } 924 925 .site-preview .btn-outline-primary { color: ${colors.primary} !important; border-color: ${colors.primary} !important; } 926 .site-preview .btn-outline-primary:hover { background-color: ${colors.primary} !important; } 927 .site-preview .btn-outline-secondary { color: ${colors.secondary} !important; border-color: ${colors.secondary} !important; } 928 .site-preview .btn-outline-secondary:hover { background-color: ${colors.secondary} !important; } 929 .site-preview .btn-outline-success { color: ${colors.success} !important; border-color: ${colors.success} !important; } 930 .site-preview .btn-outline-success:hover { background-color: ${colors.success} !important; } 931 .site-preview .btn-outline-danger { color: ${colors.danger} !important; border-color: ${colors.danger} !important; } 932 .site-preview .btn-outline-danger:hover { background-color: ${colors.danger} !important; } 933 934 .site-preview .alert-primary { background-color: ${this.hexToRgba(colors.primary, 0.1)} !important; border-color: ${this.hexToRgba(colors.primary, 0.3)} !important; color: ${colors.primary} !important; } 935 .site-preview .alert-success { background-color: ${this.hexToRgba(colors.success, 0.1)} !important; border-color: ${this.hexToRgba(colors.success, 0.3)} !important; color: ${colors.success} !important; } 936 .site-preview .alert-warning { background-color: ${this.hexToRgba(colors.warning, 0.1)} !important; border-color: ${this.hexToRgba(colors.warning, 0.3)} !important; color: ${colors.warning} !important; } 937 .site-preview .alert-danger { background-color: ${this.hexToRgba(colors.danger, 0.1)} !important; border-color: ${this.hexToRgba(colors.danger, 0.3)} !important; color: ${colors.danger} !important; } 938 939 .site-preview .progress-bar.bg-primary { background-color: ${colors.primary} !important; } 940 .site-preview .progress-bar.bg-success { background-color: ${colors.success} !important; } 941 .site-preview .progress-bar.bg-warning { background-color: ${colors.warning} !important; } 942 .site-preview .progress-bar.bg-danger { background-color: ${colors.danger} !important; } 943 .site-preview .progress-bar.bg-info { background-color: ${colors.info} !important; } 944 945 .site-preview .badge.bg-primary { background-color: ${colors.primary} !important; } 946 .site-preview .badge.bg-secondary { background-color: ${colors.secondary} !important; } 947 .site-preview .badge.bg-success { background-color: ${colors.success} !important; } 948 .site-preview .badge.bg-danger { background-color: ${colors.danger} !important; } 949 .site-preview .badge.bg-warning { background-color: ${colors.warning} !important; } 950 .site-preview .badge.bg-info { background-color: ${colors.info} !important; } 951 .site-preview .badge.bg-light { background-color: ${colors.light} !important; } 952 .site-preview .badge.bg-dark { background-color: ${colors.dark} !important; } 953 954 .site-preview .nav-pills .nav-link.active.bg-primary { background-color: ${colors.primary} !important; } 955 .site-preview .nav-pills .nav-link.text-primary { color: ${colors.primary} !important; } 956 957 /* Border radius overrides */ 958 .site-preview .btn { border-radius: ${borderRadius}px !important; } 959 .site-preview .card { border-radius: ${borderRadius}px !important; } 960 .site-preview .alert { border-radius: ${borderRadius}px !important; } 961 .site-preview .form-control { border-radius: ${borderRadius}px !important; } 962 .site-preview .form-select { border-radius: ${borderRadius}px !important; } 963 .site-preview .badge { border-radius: ${borderRadius}px !important; } 964 .site-preview .progress { border-radius: ${borderRadius}px !important; } 965 .site-preview .nav-pills .nav-link { border-radius: ${borderRadius}px !important; } 966 967 /* Box shadow overrides */ 968 .site-preview .card { box-shadow: ${boxShadow} !important; } 969 .site-preview .btn:hover { box-shadow: ${boxShadow} !important; } 970 .site-preview .form-control:focus { box-shadow: 0 0 0 0.25rem ${this.hexToRgba(colors.primary, 0.25)} !important; } 971 ` 972 document.head.appendChild(style) 973 } 974 975 hexToRgba(hex, alpha) { 976 const rgb = this.hexToRgb(hex) 977 if (!rgb) return hex 978 return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})` 979 } 980 981 updatePreview(colors) { 982 const root = document.documentElement 983 984 // Update CSS custom properties for immediate preview 985 root.style.setProperty('--bs-primary', colors.primary) 986 root.style.setProperty('--bs-secondary', colors.secondary) 987 root.style.setProperty('--bs-success', colors.success) 988 root.style.setProperty('--bs-danger', colors.danger) 989 root.style.setProperty('--bs-warning', colors.warning) 990 root.style.setProperty('--bs-info', colors.info) 991 root.style.setProperty('--bs-light', colors.light) 992 root.style.setProperty('--bs-dark', colors.dark) 993 994 // Update RGB variants
995 Object.keys(colors).forEach(key => { 996 const rgb = this.hexToRgb(colors[key]) 997 root.style.setProperty(`--bs-${key}-rgb`, `${rgb.r}, ${rgb.g}, ${rgb.b}`) 998 }) 999 } 1000 1001 getContrastColor(backgroundColor) { 1002 // Determine if text should be white or black for best contrast 1003 const luminance = this.getLuminance(backgroundColor) 1004 return luminance > 0.5 ? '#000000' : '#ffffff' 1005 } 1006 1007 // Color utility functions 1008 isValidHexColor(hex) { 1009 return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hex) 1010 } 1011 1012 hexToRgb(hex) { 1013 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) 1014 return result ? { 1015 r: parseInt(result[1], 16), 1016 g: parseInt(result[2], 16), 1017 b: parseInt(result[3], 16) 1018 } : null 1019 } 1020 1021 // Calculate relative luminance for WCAG contrast ratio 1022 getLuminance(rgb) { 1023 const { r, g, b } = rgb 1024 const sRGB = [r, g, b].map(val => { 1025 val = val / 255 1026 return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4) 1027 }) 1028 return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2] 1029 } 1030 1031 // Calculate contrast ratio between two colors 1032 getContrastRatio(color1, color2) { 1033 const rgb1 = this.hexToRgb(color1) 1034 const rgb2 = this.hexToRgb(color2) 1035 if (!rgb1 || !rgb2) return 1 1036 1037 const lum1 = this.getLuminance(rgb1) 1038 const lum2 = this.getLuminance(rgb2) 1039 const brightest = Math.max(lum1, lum2) 1040 const darkest = Math.min(lum1, lum2) 1041 1042 return (brightest + 0.05) / (darkest + 0.05) 1043 } 1044 1045 // Check if color has sufficient contrast against background 1046 hasGoodContrast(color, background, minRatio = 4.5) { 1047 return this.getContrastRatio(color, background) >= minRatio 1048 } 1049 1050 // Auto-adjust color to meet contrast requirements 1051 autoAdjustContrast(color, background, targetRatio = 4.5) { 1052 const currentRatio = this.getContrastRatio(color, background) 1053 if (currentRatio >= targetRatio) return color 1054 1055 // Convert to HSL for easier manipulation 1056 const hsl = this.hexToHsl(color) 1057 if (!hsl) return color // Return original if can't convert 1058 1059 const bgRgb = this.hexToRgb(background) 1060 if (!bgRgb) return color 1061 1062 const bgLuminance = this.getLuminance(bgRgb) 1063 1064 // Determine if we should darken or lighten 1065 const shouldDarken = bgLuminance > 0.5 1066 1067 // Binary search for the right lightness 1068 let low = shouldDarken ? 0 : hsl.l 1069 let high = shouldDarken ? hsl.l : 100 1070 let bestL = hsl.l 1071 let bestRatio = currentRatio 1072 1073 // More aggressive search with more iterations 1074 for (let i = 0; i < 30; i++) { 1075 const mid = (low + high) / 2 1076 const testColor = this.hslToHex({ ...hsl, l: mid }) 1077 const ratio = this.getContrastRatio(testColor, background) 1078 1079 // Keep track of the best ratio we've found 1080 if (ratio >= targetRatio && (bestRatio < targetRatio || ratio < bestRatio)) { 1081 bestL = mid 1082 bestRatio = ratio 1083 } 1084 1085 if (Math.abs(ratio - targetRatio) < 0.05) { 1086 bestL = mid 1087 break 1088 } 1089 1090 if (ratio < targetRatio) { 1091 if (shouldDarken) { 1092 high = mid 1093 } else { 1094 low = mid 1095 } 1096 } else { 1097 if (shouldDarken) { 1098 low = mid 1099 } else { 1100 high = mid 1101 } 1102 } 1103 } 1104 1105 // If we still don't have good contrast, be more aggressive 1106 if (bestRatio < targetRatio) { 1107 bestL = shouldDarken ? Math.max(0, hsl.l - 40) : Math.min(100, hsl.l + 40) 1108 } 1109 1110 return this.hslToHex({ ...hsl, l: bestL }) 1111 } 1112 1113 darken(color, amount) { 1114 const hsl = this.hexToHsl(color) 1115 if (!hsl) return color // Return original if can't convert 1116 hsl.l = Math.max(0, hsl.l - amount * 100) 1117 return this.hslToHex(hsl) 1118 } 1119 1120 lighten(color, amount) { 1121 const hsl = this.hexToHsl(color) 1122 if (!hsl) return color // Return original if can't convert 1123 hsl.l = Math.min(100, hsl.l + amount * 100) 1124 return this.hslToHex(hsl) 1125 } 1126 1127 hexToHsl(hex) { 1128 const rgb = this.hexToRgb(hex) 1129 if (!rgb) return null 1130 1131 const r = rgb.r / 255 1132 const g = rgb.g / 255 1133 const b = rgb.b / 255 1134 1135 const max = Math.max(r, g, b) 1136 const min = Math.min(r, g, b) 1137 let h, s, l = (max + min) / 2 1138 1139 if (max === min) { 1140 h = s = 0 1141 } else { 1142 const d = max - min 1143 s = l > 0.5 ? d / (2 - max - min) : d / (max + min) 1144 1145 switch (max) { 1146 case r: h = (g - b) / d + (g < b ? 6 : 0); break 1147 case g: h = (b - r) / d + 2; break 1148 case b: h = (r - g) / d + 4; break 1149 } 1150 h /= 6 1151 } 1152 1153 // Return HSL with s and l as 0-1 range (not percentage) 1154 return { h: h * 360, s: s, l: l } 1155 } 1156 1157 hslToHex({ h, s, l }) { 1158 // Normalize inputs 1159 h = ((h % 360) + 360) % 360 // Normalize hue to 0-360 1160 // s and l should already be in 0-1 range from hexToHsl 1161 1162 const c = (1 - Math.abs(2 * l - 1)) * s 1163 const x = c * (1 - Math.abs((h / 60) % 2 - 1)) 1164 const m = l - c / 2 1165 1166 let r, g, b 1167 1168 if (0 <= h && h < 60) { 1169 r = c; g = x; b = 0 1170 } else if (60 <= h && h < 120) { 1171 r = x; g = c; b = 0 1172 } else if (120 <= h && h < 180) { 1173 r = 0; g = c; b = x 1174 } else if (180 <= h && h < 240) { 1175 r = 0; g = x; b = c 1176 } else if (240 <= h && h < 300) { 1177 r = x; g = 0; b = c 1178 } else { 1179 r = c; g = 0; b = x 1180 } 1181 1182 r = Math.round((r + m) * 255) 1183 g = Math.round((g + m) * 255) 1184 b = Math.round((b + m) * 255) 1185 1186 return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase() 1187 } 1188 1189 getLuminance(hex) { 1190 const rgb = this.hexToRgb(hex) 1191 if (!rgb) return 0 1192 1193 // Simplified luminance calculation 1194 return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255 1195 } 1196 1197 showNotification(message, type = "info") { 1198 // Create a simple notification 1199 const notification = document.createElement("div") 1200 notification.className = `alert alert-${type === "error" ? "danger" : type} alert-dismissible fade show position-fixed` 1201 notification.style.cssText = "top: 20px; right: 20px; z-index: 9999; max-width: 400px;" 1202 notification.innerHTML = ` 1203 ${message} 1204 <button type="button" class="btn-close" data-bs-dismiss="alert"></button> 1205 ` 1206 1207 document.body.appendChild(notification) 1208 1209 // Auto-remove after 5 seconds 1210 setTimeout(() => { 1211 if (notification.parentNode) { 1212 notification.remove() 1213 } 1214 }, 5000) 1215 } 1216};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.