| 1 | /** |
| 2 | * @module ThemeGenerator |
| 3 | * Theme color palette generation from background images using Oklch color space |
| 4 | * and color theory for complementary/accessible text colors. |
| 5 | */ |
| 6 | |
| 7 | // ===== sRGB <-> Linear RGB <-> Oklch conversions ===== |
| 8 | |
| 9 | /** |
| 10 | * Converts an sRGB component [0,255] to linear RGB [0,1]. |
| 11 | * @param {number} c sRGB component (0–255) |
| 12 | * @returns {number} Linear RGB value (0–1) |
| 13 | */ |
| 14 | function srgbToLinear(c) { |
| 15 | c /= 255; |
| 16 | return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); |
| 17 | } |
| 18 | |
| 19 | /** |
| 20 | * Converts a linear RGB component [0,1] to sRGB [0,255]. |
| 21 | * @param {number} c Linear RGB value (0–1) |
| 22 | * @returns {number} sRGB component (0–255), clamped |
| 23 | */ |
| 24 | function linearToSrgb(c) { |
| 25 | const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; |
| 26 | return Math.round(Math.min(255, Math.max(0, v * 255))); |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * Converts sRGB {r,g,b} (0–255 each) to Oklch {L, C, h}. |
| 31 | * @param {number} r Red (0–255) |
| 32 | * @param {number} g Green (0–255) |
| 33 | * @param {number} b Blue (0–255) |
| 34 | * @returns {{L: number, C: number, h: number}} Oklch color (h in radians) |
| 35 | */ |
| 36 | function srgbToOklch(r, g, b) { |
| 37 | const lr = srgbToLinear(r); |
| 38 | const lg = srgbToLinear(g); |
| 39 | const lb = srgbToLinear(b); |
| 40 | |
| 41 | const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); |
| 42 | const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); |
| 43 | const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); |
| 44 | |
| 45 | const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; |
| 46 | const ok_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; |
| 47 | |
| 48 | return { |
| 49 | L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, |
| 50 | C: Math.sqrt(a * a + ok_b * ok_b), |
| 51 | h: Math.atan2(ok_b, a), |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Converts Oklch {L, C, h} to sRGB {r, g, b} (0–255 each). |
| 57 | * @param {number} L Lightness (0–1) |
| 58 | * @param {number} C Chroma |
| 59 | * @param {number} h Hue (radians) |
| 60 | * @returns {{r: number, g: number, b: number}} sRGB color |
| 61 | */ |
| 62 | function oklchToSrgb(L, C, h) { |
| 63 | const a = C * Math.cos(h); |
| 64 | const b = C * Math.sin(h); |
| 65 | |
| 66 | const l_ = L + 0.3963377774 * a + 0.2158037573 * b; |
| 67 | const m_ = L - 0.1055613458 * a - 0.0638541728 * b; |
| 68 | const s_ = L - 0.0894841775 * a - 1.2914855480 * b; |
| 69 | |
| 70 | const l = l_ * l_ * l_; |
| 71 | const m = m_ * m_ * m_; |
| 72 | const s = s_ * s_ * s_; |
| 73 | |
| 74 | return { |
| 75 | r: linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), |
| 76 | g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), |
| 77 | b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), |
| 78 | }; |
| 79 | } |
| 80 | |
| 81 | // ===== Relative luminance & contrast ratio (WCAG) ===== |
| 82 | |
| 83 | /** |
| 84 | * Calculates the relative luminance of an sRGB color (WCAG 2.x definition). |
| 85 | * @param {number} r Red (0–255) |
| 86 | * @param {number} g Green (0–255) |
| 87 | * @param {number} b Blue (0–255) |
| 88 | * @returns {number} Relative luminance (0–1) |
| 89 | */ |
| 90 | function relativeLuminance(r, g, b) { |
| 91 | return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Calculates WCAG contrast ratio between two colors. |
| 96 | * @param {{r: number, g: number, b: number}} c1 First color |
| 97 | * @param {{r: number, g: number, b: number}} c2 Second color |
| 98 | * @returns {number} Contrast ratio (1–21) |
| 99 | */ |
| 100 | function contrastRatio(c1, c2) { |
| 101 | const l1 = relativeLuminance(c1.r, c1.g, c1.b); |
| 102 | const l2 = relativeLuminance(c2.r, c2.g, c2.b); |
| 103 | const lighter = Math.max(l1, l2); |
| 104 | const darker = Math.min(l1, l2); |
| 105 | return (lighter + 0.05) / (darker + 0.05); |
| 106 | } |
| 107 | |
| 108 | // ===== Dominant color extraction ===== |
| 109 | |
| 110 | /** |
| 111 | * Extracts the dominant vivid color from an image element. |
| 112 | * Uses chroma-weighted averaging in Oklch space to prefer vivid colors |
| 113 | * over the muddy averages that simple mean-RGB produces. |
| 114 | * @param {HTMLImageElement} imgEl Image element to sample |
| 115 | * @returns {{r: number, g: number, b: number}} Dominant vivid RGB color |
| 116 | */ |
| 117 | export function extractDominantColor(imgEl) { |
| 118 | const canvas = document.createElement('canvas'); |
| 119 | const context = canvas.getContext('2d'); |
| 120 | |
| 121 | if (!context) { |
| 122 | return { r: 128, g: 128, b: 128 }; |
| 123 | } |
| 124 | |
| 125 | // Sample at reduced resolution for performance |
| 126 | const maxDim = 150; |
| 127 | const scale = Math.min(1, maxDim / Math.max(imgEl.naturalWidth, imgEl.naturalHeight)); |
| 128 | const width = canvas.width = Math.floor(imgEl.naturalWidth * scale); |
| 129 | const height = canvas.height = Math.floor(imgEl.naturalHeight * scale); |
| 130 | context.drawImage(imgEl, 0, 0, width, height); |
| 131 | |
| 132 | let data; |
| 133 | try { |
| 134 | data = context.getImageData(0, 0, width, height).data; |
| 135 | } catch { |
| 136 | return { r: 128, g: 128, b: 128 }; |
| 137 | } |
| 138 | |
| 139 | // Collect pixel samples in Oklch space |
| 140 | const step = 4; // sample every 4th pixel for speed |
| 141 | /** @type {{L: number, C: number, h: number}[]} */ |
| 142 | const pixels = []; |
| 143 | |
| 144 | for (let i = 0; i < data.length; i += 4 * step) { |
| 145 | const pr = data[i], pg = data[i + 1], pb = data[i + 2], alpha = data[i + 3]; |
| 146 | if (alpha < 128) continue; // skip transparent pixels |
| 147 | |
| 148 | const lch = srgbToOklch(pr, pg, pb); |
| 149 | pixels.push(lch); |
| 150 | } |
| 151 | |
| 152 | if (pixels.length === 0) { |
| 153 | return { r: 128, g: 128, b: 128 }; |
| 154 | } |
| 155 | |
| 156 | // Weighted average in Oklch, weighting by chroma^2 to prioritize vivid colors |
| 157 | // Average hue using circular mean (sin/cos) to handle wraparound |
| 158 | let totalWeight = 0; |
| 159 | let wL = 0, wC = 0, wSinH = 0, wCosH = 0; |
| 160 | |
| 161 | for (const px of pixels) { |
| 162 | // Weight: chroma squared + small base so even gray images produce a result |
| 163 | const w = px.C * px.C + 0.001; |
| 164 | totalWeight += w; |
| 165 | wL += px.L * w; |
| 166 | wC += px.C * w; |
| 167 | wSinH += Math.sin(px.h) * w; |
| 168 | wCosH += Math.cos(px.h) * w; |
| 169 | } |
| 170 | |
| 171 | wL /= totalWeight; |
| 172 | wC /= totalWeight; |
| 173 | const avgH = Math.atan2(wSinH / totalWeight, wCosH / totalWeight); |
| 174 | |
| 175 | // Boost the chroma of the result slightly for a more vivid base color |
| 176 | const boostedC = Math.min(wC * 1.3, 0.35); // cap so we don't get neon |
| 177 | |
| 178 | return oklchToSrgb(wL, boostedC, avgH); |
| 179 | } |
| 180 | |
| 181 | // ===== Theme palette generation ===== |
| 182 | |
| 183 | /** |
| 184 | * Adjusts Oklch lightness of a color to ensure sufficient contrast with a reference. |
| 185 | * @param {number} L Lightness (0–1) |
| 186 | * @param {number} C Chroma |
| 187 | * @param {number} h Hue (radians) |
| 188 | * @param {{r: number, g: number, b: number}} refRgb Reference color in sRGB |
| 189 | * @param {number} minContrast Minimum contrast ratio required |
| 190 | * @param {boolean} preferLight Whether to push lighter or darker |
| 191 | * @returns {{L: number, C: number, h: number}} Adjusted Oklch color |
| 192 | */ |
| 193 | function ensureContrast(L, C, h, refRgb, minContrast, preferLight) { |
| 194 | const direction = preferLight ? 0.02 : -0.02; |
| 195 | |
| 196 | for (let i = 0; i < 50; i++) { |
| 197 | const rgb = oklchToSrgb(L, C, h); |
| 198 | if (contrastRatio(rgb, refRgb) >= minContrast) { |
| 199 | return { L, C, h }; |
| 200 | } |
| 201 | L = Math.min(1, Math.max(0, L + direction)); |
| 202 | } |
| 203 | |
| 204 | return { L, C, h }; |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Formats an RGB color as an RGBA string. |
| 209 | * @param {{r: number, g: number, b: number}} rgb RGB color |
| 210 | * @param {number} [alpha=1] Alpha value |
| 211 | * @returns {string} RGBA color string |
| 212 | */ |
| 213 | function rgbaString(rgb, alpha = 1) { |
| 214 | return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Generates a complete theme color palette from a dominant background color. |
| 219 | * Uses color theory (complementary, analogous, triadic relationships) in Oklch space |
| 220 | * with accessibility contrast checking. |
| 221 | * |
| 222 | * @param {{r: number, g: number, b: number}} dominantRgb The dominant image color |
| 223 | * @returns {Partial<Theme>} Theme color properties ready to merge into a theme object |
| 224 | */ |
| 225 | export function generateThemePalette(dominantRgb) { |
| 226 | const base = srgbToOklch(dominantRgb.r, dominantRgb.g, dominantRgb.b); |
| 227 | |
| 228 | // Determine if the background is dark or light |
| 229 | const bgLuminance = relativeLuminance(dominantRgb.r, dominantRgb.g, dominantRgb.b); |
| 230 | const isDark = bgLuminance < 0.3; |
| 231 | |
| 232 | // --- Panel / tint colors (derived from base, with low alpha for transparency) --- |
| 233 | // Main blur tint: base color, darkened, semi-transparent |
| 234 | const blurTintL = isDark ? Math.max(base.L * 0.5, 0.08) : Math.min(base.L * 0.35, 0.25); |
| 235 | const blurTintC = base.C * 0.5; |
| 236 | const blurTintRgb = oklchToSrgb(blurTintL, blurTintC, base.h); |
| 237 | |
| 238 | const chatTintL = blurTintL * 0.9; |
| 239 | const chatTintRgb = oklchToSrgb(chatTintL, blurTintC * 0.8, base.h); |
| 240 | |
| 241 | // User/bot message tints: slight hue shifts |
| 242 | const userHueShift = 0.15; // ~9° shift |
| 243 | const botHueShift = -0.15; |
| 244 | const userTintRgb = oklchToSrgb(blurTintL, base.C * 0.4, base.h + userHueShift); |
| 245 | const botTintRgb = oklchToSrgb(blurTintL, base.C * 0.4, base.h + botHueShift); |
| 246 | |
| 247 | // --- Reference background for contrast checking --- |
| 248 | // Effective panel background (what the text appears on) |
| 249 | const panelBg = blurTintRgb; |
| 250 | const panelLuminance = relativeLuminance(panelBg.r, panelBg.g, panelBg.b); |
| 251 | const panelIsDark = panelLuminance < 0.3; |
| 252 | |
| 253 | // --- Text colors (ensure ≥ 3.0:1 contrast against panel background) --- |
| 254 | const minContrast = 3.5; |
| 255 | |
| 256 | // Hue shift angles for color theory relationships (in radians) |
| 257 | const ANALOGOUS_HUE_SHIFT = Math.PI / 3; // +60° for analogous colors |
| 258 | const COMPLEMENTARY_HUE_SHIFT = Math.PI; // +180° for complementary colors |
| 259 | const TRIADIC_HUE_SHIFT = (2 * Math.PI / 3); // +120° for triadic colors |
| 260 | |
| 261 | // Main text: near-white/near-black with a slight hue tint from the base |
| 262 | const mainTextC = Math.min(base.C * 0.15, 0.03); |
| 263 | const mainText = ensureContrast(panelIsDark ? 0.85 : 0.2, mainTextC, base.h, panelBg, minContrast, panelIsDark); |
| 264 | const mainTextRgb = oklchToSrgb(mainText.L, mainText.C, mainText.h); |
| 265 | |
| 266 | // Italics: analogous hue shift (+60°), slightly softer |
| 267 | const italicsC = Math.min(base.C * 0.5 + 0.02, 0.12); |
| 268 | const italics = ensureContrast(panelIsDark ? 0.78 : 0.3, italicsC, base.h + ANALOGOUS_HUE_SHIFT, panelBg, minContrast, panelIsDark); |
| 269 | const italicsRgb = oklchToSrgb(italics.L, italics.C, italics.h); |
| 270 | |
| 271 | // Underline: complementary hue (+180°), medium saturation |
| 272 | const underlineC = Math.min(base.C * 0.4 + 0.02, 0.10); |
| 273 | const underline = ensureContrast(panelIsDark ? 0.75 : 0.32, underlineC, base.h + COMPLEMENTARY_HUE_SHIFT, panelBg, minContrast, panelIsDark); |
| 274 | const underlineRgb = oklchToSrgb(underline.L, underline.C, underline.h); |
| 275 | |
| 276 | // Quotes: triadic hue shift (+120°), more saturated for distinctiveness |
| 277 | const quoteC = Math.min(base.C * 0.6 + 0.03, 0.14); |
| 278 | const quote = ensureContrast(panelIsDark ? 0.65 : 0.38, quoteC, base.h + TRIADIC_HUE_SHIFT, panelBg, minContrast, panelIsDark); |
| 279 | const quoteRgb = oklchToSrgb(quote.L, quote.C, quote.h); |
| 280 | |
| 281 | // --- Shadow & border --- |
| 282 | const shadowRgb = isDark ? { r: 0, g: 0, b: 0 } : { r: 40, g: 40, b: 40 }; |
| 283 | const borderL = isDark ? Math.max(base.L * 0.3, 0.05) : Math.min(base.L * 1.2, 0.6); |
| 284 | const borderRgb = oklchToSrgb(borderL, base.C * 0.3, base.h); |
| 285 | |
| 286 | return { |
| 287 | blur_tint_color: rgbaString(blurTintRgb, 0.95), |
| 288 | chat_tint_color: rgbaString(chatTintRgb, 0.6), |
| 289 | user_mes_blur_tint_color: rgbaString(userTintRgb, 0.7), |
| 290 | bot_mes_blur_tint_color: rgbaString(botTintRgb, 0.7), |
| 291 | main_text_color: rgbaString(mainTextRgb), |
| 292 | italics_text_color: rgbaString(italicsRgb), |
| 293 | underline_text_color: rgbaString(underlineRgb), |
| 294 | quote_text_color: rgbaString(quoteRgb), |
| 295 | shadow_color: rgbaString(shadowRgb, isDark ? 0.8 : 0.3), |
| 296 | shadow_width: isDark ? 2 : 1, |
| 297 | border_color: rgbaString(borderRgb, 0.7), |
| 298 | blur_strength: isDark ? 10 : 8, |
| 299 | }; |
| 300 | } |
| 301 | |
| 302 | /** |
| 303 | * Derives a theme name from a background image URL. |
| 304 | * @param {string} bgUrl The background image URL |
| 305 | * @returns {string} A cleaned-up name suitable for a theme name |
| 306 | */ |
| 307 | export function deriveBackgroundName(bgUrl) { |
| 308 | // Extract filename from URL path |
| 309 | let name = bgUrl.split('/').pop() || 'background'; |
| 310 | // Remove query strings |
| 311 | name = name.split('?')[0]; |
| 312 | // URL-decode |
| 313 | try { |
| 314 | name = decodeURIComponent(name); |
| 315 | } catch { /* use as-is */ } |
| 316 | // Remove file extension |
| 317 | name = name.replace(/\.[^.]+$/, ''); |
| 318 | // Replace underscores/dashes with spaces, trim |
| 319 | name = name.replace(/[_-]+/g, ' ').trim(); |
| 320 | // Limit length to 32 chars for theme name |
| 321 | return name.slice(0, 32) || 'Background'; |
| 322 | } |