Files
Copilot f687bc95e9 Overhaul /bgcol: Oklch color space, full palette generation, save as new theme (#5162)
* Initial plan

* Overhaul /bgcol command: Oklab color space, dominant color extraction, full theme palette generation

- Create ThemeGenerator module in public/scripts/util/ with:
  - Oklab color space conversions (sRGB ↔ Oklab ↔ LCH)
  - Chroma-weighted dominant color extraction (vivid over muddy averages)
  - Color theory palette: complementary, analogous, triadic hue relationships
  - WCAG contrast ratio enforcement (≥3.5:1 for all text colors)
- Replace old setAvgBG with new implementation using ThemeGenerator
- Save generated theme as "Generated - <background name>" instead of overwriting
- Update command help string to reflect production-ready status

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* Extract hue shift constants for clarity (code review feedback)

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* Add name and bg arguments to /bgcol command

- Add `name` named argument to override the generated theme name
- Add `bg` named argument with enum provider listing available backgrounds
  from /api/backgrounds/all to override the current background

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* Improve types, add enum provider, reduce transparency

* Adjust alpha channel for better readability

* Refactor ThemeGenerator to work directly in Oklch, removing intermediate Oklab conversions

Replaced srgbToOklab/oklabToSrgb + oklabToLCH/lchToOklab with direct
srgbToOklch/oklchToSrgb functions. All color manipulations now use
{L, C, h} directly without converting through {a, b} intermediates.
The ensureContrast function now takes (L, C, h) parameters instead of
an Oklab object.

Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

* Add custom background URL check and enhance theme generation logic

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>
2026-02-15 16:39:55 +02:00

323 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @module ThemeGenerator
* Theme color palette generation from background images using Oklch color space
* and color theory for complementary/accessible text colors.
*/
// ===== sRGB <-> Linear RGB <-> Oklch conversions =====
/**
* Converts an sRGB component [0,255] to linear RGB [0,1].
* @param {number} c sRGB component (0255)
* @returns {number} Linear RGB value (01)
*/
function srgbToLinear(c) {
c /= 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/**
* Converts a linear RGB component [0,1] to sRGB [0,255].
* @param {number} c Linear RGB value (01)
* @returns {number} sRGB component (0255), clamped
*/
function linearToSrgb(c) {
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(Math.min(255, Math.max(0, v * 255)));
}
/**
* Converts sRGB {r,g,b} (0255 each) to Oklch {L, C, h}.
* @param {number} r Red (0255)
* @param {number} g Green (0255)
* @param {number} b Blue (0255)
* @returns {{L: number, C: number, h: number}} Oklch color (h in radians)
*/
function srgbToOklch(r, g, b) {
const lr = srgbToLinear(r);
const lg = srgbToLinear(g);
const lb = srgbToLinear(b);
const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
const ok_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
return {
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
C: Math.sqrt(a * a + ok_b * ok_b),
h: Math.atan2(ok_b, a),
};
}
/**
* Converts Oklch {L, C, h} to sRGB {r, g, b} (0255 each).
* @param {number} L Lightness (01)
* @param {number} C Chroma
* @param {number} h Hue (radians)
* @returns {{r: number, g: number, b: number}} sRGB color
*/
function oklchToSrgb(L, C, h) {
const a = C * Math.cos(h);
const b = C * Math.sin(h);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return {
r: linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
};
}
// ===== Relative luminance & contrast ratio (WCAG) =====
/**
* Calculates the relative luminance of an sRGB color (WCAG 2.x definition).
* @param {number} r Red (0255)
* @param {number} g Green (0255)
* @param {number} b Blue (0255)
* @returns {number} Relative luminance (01)
*/
function relativeLuminance(r, g, b) {
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
}
/**
* Calculates WCAG contrast ratio between two colors.
* @param {{r: number, g: number, b: number}} c1 First color
* @param {{r: number, g: number, b: number}} c2 Second color
* @returns {number} Contrast ratio (121)
*/
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1.r, c1.g, c1.b);
const l2 = relativeLuminance(c2.r, c2.g, c2.b);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// ===== Dominant color extraction =====
/**
* Extracts the dominant vivid color from an image element.
* Uses chroma-weighted averaging in Oklch space to prefer vivid colors
* over the muddy averages that simple mean-RGB produces.
* @param {HTMLImageElement} imgEl Image element to sample
* @returns {{r: number, g: number, b: number}} Dominant vivid RGB color
*/
export function extractDominantColor(imgEl) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
return { r: 128, g: 128, b: 128 };
}
// Sample at reduced resolution for performance
const maxDim = 150;
const scale = Math.min(1, maxDim / Math.max(imgEl.naturalWidth, imgEl.naturalHeight));
const width = canvas.width = Math.floor(imgEl.naturalWidth * scale);
const height = canvas.height = Math.floor(imgEl.naturalHeight * scale);
context.drawImage(imgEl, 0, 0, width, height);
let data;
try {
data = context.getImageData(0, 0, width, height).data;
} catch {
return { r: 128, g: 128, b: 128 };
}
// Collect pixel samples in Oklch space
const step = 4; // sample every 4th pixel for speed
/** @type {{L: number, C: number, h: number}[]} */
const pixels = [];
for (let i = 0; i < data.length; i += 4 * step) {
const pr = data[i], pg = data[i + 1], pb = data[i + 2], alpha = data[i + 3];
if (alpha < 128) continue; // skip transparent pixels
const lch = srgbToOklch(pr, pg, pb);
pixels.push(lch);
}
if (pixels.length === 0) {
return { r: 128, g: 128, b: 128 };
}
// Weighted average in Oklch, weighting by chroma^2 to prioritize vivid colors
// Average hue using circular mean (sin/cos) to handle wraparound
let totalWeight = 0;
let wL = 0, wC = 0, wSinH = 0, wCosH = 0;
for (const px of pixels) {
// Weight: chroma squared + small base so even gray images produce a result
const w = px.C * px.C + 0.001;
totalWeight += w;
wL += px.L * w;
wC += px.C * w;
wSinH += Math.sin(px.h) * w;
wCosH += Math.cos(px.h) * w;
}
wL /= totalWeight;
wC /= totalWeight;
const avgH = Math.atan2(wSinH / totalWeight, wCosH / totalWeight);
// Boost the chroma of the result slightly for a more vivid base color
const boostedC = Math.min(wC * 1.3, 0.35); // cap so we don't get neon
return oklchToSrgb(wL, boostedC, avgH);
}
// ===== Theme palette generation =====
/**
* Adjusts Oklch lightness of a color to ensure sufficient contrast with a reference.
* @param {number} L Lightness (01)
* @param {number} C Chroma
* @param {number} h Hue (radians)
* @param {{r: number, g: number, b: number}} refRgb Reference color in sRGB
* @param {number} minContrast Minimum contrast ratio required
* @param {boolean} preferLight Whether to push lighter or darker
* @returns {{L: number, C: number, h: number}} Adjusted Oklch color
*/
function ensureContrast(L, C, h, refRgb, minContrast, preferLight) {
const direction = preferLight ? 0.02 : -0.02;
for (let i = 0; i < 50; i++) {
const rgb = oklchToSrgb(L, C, h);
if (contrastRatio(rgb, refRgb) >= minContrast) {
return { L, C, h };
}
L = Math.min(1, Math.max(0, L + direction));
}
return { L, C, h };
}
/**
* Formats an RGB color as an RGBA string.
* @param {{r: number, g: number, b: number}} rgb RGB color
* @param {number} [alpha=1] Alpha value
* @returns {string} RGBA color string
*/
function rgbaString(rgb, alpha = 1) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
/**
* Generates a complete theme color palette from a dominant background color.
* Uses color theory (complementary, analogous, triadic relationships) in Oklch space
* with accessibility contrast checking.
*
* @param {{r: number, g: number, b: number}} dominantRgb The dominant image color
* @returns {Partial<Theme>} Theme color properties ready to merge into a theme object
*/
export function generateThemePalette(dominantRgb) {
const base = srgbToOklch(dominantRgb.r, dominantRgb.g, dominantRgb.b);
// Determine if the background is dark or light
const bgLuminance = relativeLuminance(dominantRgb.r, dominantRgb.g, dominantRgb.b);
const isDark = bgLuminance < 0.3;
// --- Panel / tint colors (derived from base, with low alpha for transparency) ---
// Main blur tint: base color, darkened, semi-transparent
const blurTintL = isDark ? Math.max(base.L * 0.5, 0.08) : Math.min(base.L * 0.35, 0.25);
const blurTintC = base.C * 0.5;
const blurTintRgb = oklchToSrgb(blurTintL, blurTintC, base.h);
const chatTintL = blurTintL * 0.9;
const chatTintRgb = oklchToSrgb(chatTintL, blurTintC * 0.8, base.h);
// User/bot message tints: slight hue shifts
const userHueShift = 0.15; // ~9° shift
const botHueShift = -0.15;
const userTintRgb = oklchToSrgb(blurTintL, base.C * 0.4, base.h + userHueShift);
const botTintRgb = oklchToSrgb(blurTintL, base.C * 0.4, base.h + botHueShift);
// --- Reference background for contrast checking ---
// Effective panel background (what the text appears on)
const panelBg = blurTintRgb;
const panelLuminance = relativeLuminance(panelBg.r, panelBg.g, panelBg.b);
const panelIsDark = panelLuminance < 0.3;
// --- Text colors (ensure ≥ 3.0:1 contrast against panel background) ---
const minContrast = 3.5;
// Hue shift angles for color theory relationships (in radians)
const ANALOGOUS_HUE_SHIFT = Math.PI / 3; // +60° for analogous colors
const COMPLEMENTARY_HUE_SHIFT = Math.PI; // +180° for complementary colors
const TRIADIC_HUE_SHIFT = (2 * Math.PI / 3); // +120° for triadic colors
// Main text: near-white/near-black with a slight hue tint from the base
const mainTextC = Math.min(base.C * 0.15, 0.03);
const mainText = ensureContrast(panelIsDark ? 0.85 : 0.2, mainTextC, base.h, panelBg, minContrast, panelIsDark);
const mainTextRgb = oklchToSrgb(mainText.L, mainText.C, mainText.h);
// Italics: analogous hue shift (+60°), slightly softer
const italicsC = Math.min(base.C * 0.5 + 0.02, 0.12);
const italics = ensureContrast(panelIsDark ? 0.78 : 0.3, italicsC, base.h + ANALOGOUS_HUE_SHIFT, panelBg, minContrast, panelIsDark);
const italicsRgb = oklchToSrgb(italics.L, italics.C, italics.h);
// Underline: complementary hue (+180°), medium saturation
const underlineC = Math.min(base.C * 0.4 + 0.02, 0.10);
const underline = ensureContrast(panelIsDark ? 0.75 : 0.32, underlineC, base.h + COMPLEMENTARY_HUE_SHIFT, panelBg, minContrast, panelIsDark);
const underlineRgb = oklchToSrgb(underline.L, underline.C, underline.h);
// Quotes: triadic hue shift (+120°), more saturated for distinctiveness
const quoteC = Math.min(base.C * 0.6 + 0.03, 0.14);
const quote = ensureContrast(panelIsDark ? 0.65 : 0.38, quoteC, base.h + TRIADIC_HUE_SHIFT, panelBg, minContrast, panelIsDark);
const quoteRgb = oklchToSrgb(quote.L, quote.C, quote.h);
// --- Shadow & border ---
const shadowRgb = isDark ? { r: 0, g: 0, b: 0 } : { r: 40, g: 40, b: 40 };
const borderL = isDark ? Math.max(base.L * 0.3, 0.05) : Math.min(base.L * 1.2, 0.6);
const borderRgb = oklchToSrgb(borderL, base.C * 0.3, base.h);
return {
blur_tint_color: rgbaString(blurTintRgb, 0.95),
chat_tint_color: rgbaString(chatTintRgb, 0.6),
user_mes_blur_tint_color: rgbaString(userTintRgb, 0.7),
bot_mes_blur_tint_color: rgbaString(botTintRgb, 0.7),
main_text_color: rgbaString(mainTextRgb),
italics_text_color: rgbaString(italicsRgb),
underline_text_color: rgbaString(underlineRgb),
quote_text_color: rgbaString(quoteRgb),
shadow_color: rgbaString(shadowRgb, isDark ? 0.8 : 0.3),
shadow_width: isDark ? 2 : 1,
border_color: rgbaString(borderRgb, 0.7),
blur_strength: isDark ? 10 : 8,
};
}
/**
* Derives a theme name from a background image URL.
* @param {string} bgUrl The background image URL
* @returns {string} A cleaned-up name suitable for a theme name
*/
export function deriveBackgroundName(bgUrl) {
// Extract filename from URL path
let name = bgUrl.split('/').pop() || 'background';
// Remove query strings
name = name.split('?')[0];
// URL-decode
try {
name = decodeURIComponent(name);
} catch { /* use as-is */ }
// Remove file extension
name = name.replace(/\.[^.]+$/, '');
// Replace underscores/dashes with spaces, trim
name = name.replace(/[_-]+/g, ' ').trim();
// Limit length to 32 chars for theme name
return name.slice(0, 32) || 'Background';
}