|
|
|
@ -17,6 +17,78 @@ export const DEFAULT_THEME = { |
|
|
|
background: "#000000", |
|
|
|
background: "#000000", |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- WCAG & Color Utilities (Updated for HSL generation/checking) ---
|
|
|
|
|
|
|
|
const ColorUtils = { |
|
|
|
|
|
|
|
// Convert Hex to RGB array
|
|
|
|
|
|
|
|
hexToRgb(hex: string) { |
|
|
|
|
|
|
|
const bigint = parseInt(hex.replace('#', ''), 16); |
|
|
|
|
|
|
|
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]; |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate Relative Luminance (WCAG 2.0 formula)
|
|
|
|
|
|
|
|
getLuminance(r: number, g: number, b: number) { |
|
|
|
|
|
|
|
const a = [r, g, b].map((v) => { |
|
|
|
|
|
|
|
v /= 255; |
|
|
|
|
|
|
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate Contrast Ratio
|
|
|
|
|
|
|
|
getContrast(hex1: string, hex2: string) { |
|
|
|
|
|
|
|
const rgb1 = this.hexToRgb(hex1); |
|
|
|
|
|
|
|
const rgb2 = this.hexToRgb(hex2); |
|
|
|
|
|
|
|
const l1 = this.getLuminance(rgb1[0], rgb1[1], rgb1[2]); |
|
|
|
|
|
|
|
const l2 = this.getLuminance(rgb2[0], rgb2[1], rgb2[2]); |
|
|
|
|
|
|
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Convert Hex to HSL for checking hue distance
|
|
|
|
|
|
|
|
hexToHsl(hex: string): [number, number, number] { |
|
|
|
|
|
|
|
let r = 0, g = 0, b = 0; |
|
|
|
|
|
|
|
const rgb = this.hexToRgb(hex); |
|
|
|
|
|
|
|
r = rgb[0] / 255; g = rgb[1] / 255; b = rgb[2] / 255; |
|
|
|
|
|
|
|
const max = Math.max(r, g, b), min = Math.min(r, g, b); |
|
|
|
|
|
|
|
let h = 0, s = 0, l = (max + min) / 2; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (max !== min) { |
|
|
|
|
|
|
|
const d = max - min; |
|
|
|
|
|
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); |
|
|
|
|
|
|
|
switch (max) { |
|
|
|
|
|
|
|
case r: h = (g - b) / d + (g < b ? 6 : 0); break; |
|
|
|
|
|
|
|
case g: h = (b - r) / d + 2; break; |
|
|
|
|
|
|
|
case b: h = (r - g) / d + 4; break; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
h /= 6; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return [h * 360, s, l]; // Return H in degrees, S/L as 0-1.
|
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Generate a color with strictly controlled HSL (Luminance and Saturation)
|
|
|
|
|
|
|
|
generateHslColor(hueRange: [number, number] | null, satRange: [number, number], lumRange: [number, number]): string { |
|
|
|
|
|
|
|
const h = hueRange |
|
|
|
|
|
|
|
? Math.floor(Math.random() * (hueRange[1] - hueRange[0])) + hueRange[0] |
|
|
|
|
|
|
|
: Math.floor(Math.random() * 360); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const s = Math.floor(Math.random() * (satRange[1] - satRange[0])) + satRange[0]; |
|
|
|
|
|
|
|
const l = Math.floor(Math.random() * (lumRange[1] - lumRange[0])) + lumRange[0]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return this.hslToHex(h, s, l); |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hslToHex(h: number, s: number, l: number) { |
|
|
|
|
|
|
|
l /= 100; |
|
|
|
|
|
|
|
const a = s * Math.min(l, 1 - l) / 100; |
|
|
|
|
|
|
|
const f = (n: number) => { |
|
|
|
|
|
|
|
const k = (n + h / 30) % 12; |
|
|
|
|
|
|
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); |
|
|
|
|
|
|
|
return Math.round(255 * color).toString(16).padStart(2, '0'); |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
return `#${f(0)}${f(8)}${f(4)}`; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Load initial values from localStorage
|
|
|
|
// Load initial values from localStorage
|
|
|
|
function getInitialTheme() { |
|
|
|
function getInitialTheme() { |
|
|
|
if (browser) { |
|
|
|
if (browser) { |
|
|
|
@ -113,7 +185,82 @@ function resetToDefaults() { |
|
|
|
applyTheme(); |
|
|
|
applyTheme(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Reset and save (for full reset)
|
|
|
|
// Returns a simple random hex (utility for single color needs)
|
|
|
|
|
|
|
|
function getRandomColor() { |
|
|
|
|
|
|
|
// High saturation for a beautiful single random color
|
|
|
|
|
|
|
|
return ColorUtils.generateHslColor(null, [70, 100], [40, 60]); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Smart Randomization with WCAG and Vibrancy Enforcement
|
|
|
|
|
|
|
|
function randomizeColors() { |
|
|
|
|
|
|
|
let attempts = 0; |
|
|
|
|
|
|
|
const maxAttempts = 50; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
while (attempts < maxAttempts) { |
|
|
|
|
|
|
|
attempts++; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Decide Polarity
|
|
|
|
|
|
|
|
const isDarkTheme = Math.random() < 0.5; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Generate Background (Low Saturation, Extreme Luminance)
|
|
|
|
|
|
|
|
const newBg = ColorUtils.generateHslColor( |
|
|
|
|
|
|
|
null, |
|
|
|
|
|
|
|
[10, 30], // Low Saturation for a neutral BG
|
|
|
|
|
|
|
|
isDarkTheme ? [4, 10] : [95, 99] // Extreme Dark or Light
|
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Generate Text (Pure White or Near Black)
|
|
|
|
|
|
|
|
const newText = isDarkTheme ? "#FFFFFF" : "#050505"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Generate Secondary (The "Pop" Color - 7:1 against BG)
|
|
|
|
|
|
|
|
const newSec = ColorUtils.generateHslColor( |
|
|
|
|
|
|
|
null, |
|
|
|
|
|
|
|
[75, 100], // High Saturation (Vibrant!)
|
|
|
|
|
|
|
|
isDarkTheme ? [60, 85] : [20, 45] // Opposite L to BG
|
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Generate Primary (The "Brand" Color - Mid-Tone Vibrancy)
|
|
|
|
|
|
|
|
const newPrim = ColorUtils.generateHslColor( |
|
|
|
|
|
|
|
null, |
|
|
|
|
|
|
|
[70, 100], // High Saturation
|
|
|
|
|
|
|
|
isDarkTheme ? [30, 60] : [40, 60] // Mid-Tone Luminance for True Color
|
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- VALIDATION ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check Contrast
|
|
|
|
|
|
|
|
const ratio_Prim_Text = ColorUtils.getContrast(newPrim, newText); |
|
|
|
|
|
|
|
const ratio_Sec_Bg = ColorUtils.getContrast(newSec, newBg); |
|
|
|
|
|
|
|
const ratio_Prim_Sec = ColorUtils.getContrast(newPrim, newSec); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check Hue Diversity (Ensures Primary and Secondary don't look like the same color)
|
|
|
|
|
|
|
|
const primHsl = ColorUtils.hexToHsl(newPrim); |
|
|
|
|
|
|
|
const secHsl = ColorUtils.hexToHsl(newSec); |
|
|
|
|
|
|
|
// We use Math.min to handle the 360-degree color wheel wrap
|
|
|
|
|
|
|
|
const hueDiff = Math.min(Math.abs(primHsl[0] - secHsl[0]), 360 - Math.abs(primHsl[0] - secHsl[0])); |
|
|
|
|
|
|
|
// Require at least 40 degrees difference for visual distinction
|
|
|
|
|
|
|
|
const distinctHues = hueDiff >= 40; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if ( |
|
|
|
|
|
|
|
ratio_Prim_Text >= 4.5 && // WCAG AA for normal text (Relaxed for vibrancy)
|
|
|
|
|
|
|
|
ratio_Sec_Bg >= 7.0 && // WCAG AAA (Ensures Secondary/CTA pops)
|
|
|
|
|
|
|
|
ratio_Prim_Sec >= 3.0 && // WCAG for non-text/graphical elements (Ensures distinction)
|
|
|
|
|
|
|
|
distinctHues // Ensures visual diversity
|
|
|
|
|
|
|
|
) { |
|
|
|
|
|
|
|
primary = newPrim; |
|
|
|
|
|
|
|
secondary = newSec; |
|
|
|
|
|
|
|
text = newText; |
|
|
|
|
|
|
|
background = newBg; |
|
|
|
|
|
|
|
applyTheme(); |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback if 50 attempts fail
|
|
|
|
|
|
|
|
console.warn("Could not generate vibrant palette with accessibility. Resetting to defaults."); |
|
|
|
|
|
|
|
resetToDefaults(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function reset() { |
|
|
|
function reset() { |
|
|
|
resetToDefaults(); |
|
|
|
resetToDefaults(); |
|
|
|
save(); |
|
|
|
save(); |
|
|
|
@ -133,4 +280,6 @@ export const themeStore = { |
|
|
|
revert, |
|
|
|
revert, |
|
|
|
reset, |
|
|
|
reset, |
|
|
|
resetToDefaults, |
|
|
|
resetToDefaults, |
|
|
|
|
|
|
|
getRandomColor, |
|
|
|
|
|
|
|
randomizeColors, |
|
|
|
}; |
|
|
|
}; |