Blame Raw
Cohee · e3f41666 · · 322 lines (12.1 KB)
1 contributor
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 */
14function 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 */
24function 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 */
36function 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 */
62function 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 */
90function 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 */
100function 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 */
117export 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 */
193function 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 */
213function 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 */
225export 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 */
307export 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}