PageSourceSearch

https://publishify.ai/assets/controllers/color_customizer_controll…218e00c4d82780a3ee0404a6e6bf1e109fdd9211.js

js publishify.ai collected 2026-10-05 14:22:09 UTC 48,468 bytes, 1,216 lines download raw bytes

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.