From f687bc95e9c1777928129bc93dfaeb946e90551e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:39:55 +0200 Subject: [PATCH] Overhaul /bgcol: Oklch color space, full palette generation, save as new theme (#5162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 - " 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> --- public/global.d.ts | 3 +- public/scripts/backgrounds.js | 17 +- public/scripts/power-user.js | 269 ++++----------- public/scripts/slash-commands.js | 4 +- .../SlashCommandCommonEnumsProvider.js | 4 + public/scripts/util/ThemeGenerator.js | 322 ++++++++++++++++++ 6 files changed, 418 insertions(+), 201 deletions(-) create mode 100644 public/scripts/util/ThemeGenerator.js diff --git a/public/global.d.ts b/public/global.d.ts index 78b15f48a..99ed8e872 100644 --- a/public/global.d.ts +++ b/public/global.d.ts @@ -1,6 +1,6 @@ import libs from './lib'; import getContext from './scripts/st-context'; -import { power_user } from './scripts/power-user'; +import { power_user, getThemeObject } from './scripts/power-user'; import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; import { oai_settings } from './scripts/openai'; import { textgenerationwebui_settings } from './scripts/textgen-settings'; @@ -21,6 +21,7 @@ declare global { type MessageTimestamp = string | number | Date; type Character = import('./scripts/char-data').v1CharData; type ChatMessageExtra = BaseMessageExtra & Partial & Record; + type Theme = ReturnType; interface Group { id: string; diff --git a/public/scripts/backgrounds.js b/public/scripts/backgrounds.js index 9919da3c5..085e7f24f 100644 --- a/public/scripts/backgrounds.js +++ b/public/scripts/backgrounds.js @@ -254,7 +254,22 @@ async function onChatChanged() { highlightSelectedBackground(); } -function getBackgroundPath(fileUrl) { +/** + * Checks if a given URL corresponds to a custom background in the current chat's metadata. + * @param {string} fileUrl - The URL to check against the chat's custom backgrounds. + * @returns {boolean} True if the URL corresponds to a custom background, false otherwise. + */ +export function isCustomBackgroundUrl(fileUrl) { + const customBackgrounds = chat_metadata[LIST_METADATA_KEY] || []; + return customBackgrounds.some(bg => bg === fileUrl || generateUrlParameter(bg, true) === fileUrl); +} + +/** + * Gets the client path for a background image, encoding the file name for safe URL usage. + * @param {string} fileUrl File name or URL of the background image + * @returns {string} Client path for the system backgroun + */ +export function getBackgroundPath(fileUrl) { return `backgrounds/${encodeURIComponent(fileUrl)}`; } diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index 778c62e56..efd289e7d 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -62,10 +62,12 @@ import { POPUP_TYPE, callGenericPopup, fixToastrForDialogs } from './popup.js'; import { loadSystemPrompts } from './sysprompt.js'; import { fuzzySearchCategories } from './filters.js'; import { accountStorage } from './util/AccountStorage.js'; +import { extractDominantColor, generateThemePalette, deriveBackgroundName } from './util/ThemeGenerator.js'; import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; import { bindModelTemplates } from './chat-templates.js'; import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; import { t } from './i18n.js'; +import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js'; export const toastPositionClasses = [ 'toast-top-left', @@ -2528,9 +2530,8 @@ async function saveTheme(name = undefined, theme = undefined) { /** * Gets a snapshot of the current theme settings. * @param {string} name Name of the theme - * @returns {object} Theme object */ -function getThemeObject(name) { +export function getThemeObject(name) { return { name, blur_strength: power_user.blur_strength, @@ -2578,7 +2579,7 @@ function getThemeObject(name) { /** * Applies imported theme properties to the theme object. * @param {object} parsed Parsed object to get the theme from. - * @returns {object} Theme assigned to the parsed object. + * @returns {Theme} Theme assigned to the parsed object. */ function getNewTheme(parsed) { const theme = getThemeObject(parsed.name); @@ -2885,208 +2886,64 @@ function doResetPanels() { return ''; } -function setAvgBG() { - const bgimg = new Image(); - bgimg.src = $('#bg1') - .css('background-image') - .replace(/^url\(['"]?/, '') - .replace(/['"]?\)$/, ''); +async function setAvgBG(args) { + const nameOverride = args?.name ? String(args.name).trim() : ''; + const bgOverride = args?.bg ? String(args.bg).trim() : ''; + const force = isTrueBoolean(args?.force?.toString()); - /* const charAvatar = new Image() - charAvatar.src = $("#avatar_load_preview") - .attr('src') + let bgUrl; + + if (bgOverride) { + // Use the specified background file + const isCustom = isCustomBackgroundUrl(bgOverride); + bgUrl = isCustom ? bgOverride : getBackgroundPath(bgOverride); + } else { + // Use the currently active background + bgUrl = $('#bg1') + .css('background-image') .replace(/^url\(['"]?/, '') .replace(/['"]?\)$/, ''); - - const userAvatar = new Image() - userAvatar.src = $("#user_avatar_block .avatar.selected img") - .attr('src') - .replace(/^url\(['"]?/, '') - .replace(/['"]?\)$/, ''); */ - - - bgimg.onload = function () { - var rgb = getAverageRGB(bgimg); - //console.log(`average color of the bg is:`) - //console.log(rgb); - $('#blur-tint-color-picker').attr('color', 'rgb(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ')'); - - const backgroundColorString = $('#blur-tint-color-picker').attr('color') - .replace('rgba', '') - .replace('rgb', '') - .replace('(', '[') - .replace(')', ']'); //[50, 120, 200, 1]; // Example background color - const backgroundColorArray = JSON.parse(backgroundColorString); //[200, 200, 200, 1] - console.log(backgroundColorArray); - $('#main-text-color-picker').attr('color', getReadableTextColor(backgroundColorArray)); - console.log($('#main-text-color-picker').attr('color')); // Output: 'rgba(0, 47, 126, 1)' - }; - - /* charAvatar.onload = function () { - var rgb = getAverageRGB(charAvatar); - //console.log(`average color of the AI avatar is:`); - //console.log(rgb); - $("#bot-mes-blur-tint-color-picker").attr('color', 'rgb(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ')'); - } - - userAvatar.onload = function () { - var rgb = getAverageRGB(userAvatar); - //console.log(`average color of the user avatar is:`); - //console.log(rgb); - $("#user-mes-blur-tint-color-picker").attr('color', 'rgb(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ')'); - } */ - - function getAverageRGB(imgEl) { - var blockSize = 5, // only visit every 5 pixels - defaultRGB = { r: 0, g: 0, b: 0 }, // for non-supporting envs - canvas = document.createElement('canvas'), - context = canvas.getContext && canvas.getContext('2d'), - data, width, height, - i = -4, - length, - rgb = { r: 0, g: 0, b: 0 }, - count = 0; - - if (!context) { - return defaultRGB; - } - - height = canvas.height = imgEl.naturalHeight || imgEl.offsetHeight || imgEl.height; - width = canvas.width = imgEl.naturalWidth || imgEl.offsetWidth || imgEl.width; - context.drawImage(imgEl, 0, 0); - - try { - data = context.getImageData(0, 0, width, height); - } catch (e) { - /* security error, img on diff domain */alert('x'); - return defaultRGB; - } - - length = data.data.length; - while ((i += blockSize * 4) < length) { - ++count; - rgb.r += data.data[i]; - rgb.g += data.data[i + 1]; - rgb.b += data.data[i + 2]; - } - - // ~~ used to floor values - rgb.r = ~~(rgb.r / count); - rgb.g = ~~(rgb.g / count); - rgb.b = ~~(rgb.b / count); - - return rgb; } - /** - * Converts an HSL color value to RGB. - * @param {number} h Hue value - * @param {number} s Saturation value - * @param {number} l Luminance value - * @return {Array} The RGB representation - */ - function hslToRgb(h, s, l) { - const hueToRgb = (p, q, t) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - - if (s === 0) { - return [l, l, l]; - } - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - const r = hueToRgb(p, q, h + 1 / 3); - const g = hueToRgb(p, q, h); - const b = hueToRgb(p, q, h - 1 / 3); - - return [r * 255, g * 255, b * 255]; + if (!bgUrl || bgUrl === 'none') { + toastr.warning('No background image set.'); + return ''; } - //this version keeps BG and main text in same hue - /* function getReadableTextColor(rgb) { - const [r, g, b] = rgb; + // Build theme name from background filename or use override + const bgName = deriveBackgroundName(bgUrl); + const themeName = nameOverride || `bgcol - ${bgName}`; - // Convert RGB to HSL - const rgbToHsl = (r, g, b) => { - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const d = max - min; - const l = (max + min) / 2; - - if (d === 0) return [0, 0, l]; - - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - const h = (() => { - switch (max) { - case r: - return (g - b) / d + (g < b ? 6 : 0); - case g: - return (b - r) / d + 2; - case b: - return (r - g) / d + 4; - } - })() / 6; - - return [h, s, l]; - }; - const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255); - - // Calculate appropriate text color based on background color - const targetLuminance = l > 0.5 ? 0.2 : 0.8; - const targetSaturation = s > 0.5 ? s - 0.2 : s + 0.2; - const [rNew, gNew, bNew] = hslToRgb(h, targetSaturation, targetLuminance); - - // Return the text color in RGBA format - return `rgba(${rNew.toFixed(0)}, ${gNew.toFixed(0)}, ${bNew.toFixed(0)}, 1)`; - }*/ - - //this version makes main text complimentary color to BG color - function getReadableTextColor(rgb) { - const [r, g, b] = rgb; - - // Convert RGB to HSL - const rgbToHsl = (r, g, b) => { - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const d = max - min; - const l = (max + min) / 2; - - if (d === 0) return [0, 0, l]; - - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - const h = (() => { - switch (max) { - case r: - return (g - b) / d + (g < b ? 6 : 0); - case g: - return (b - r) / d + 2; - case b: - return (r - g) / d + 4; - } - })() / 6; - - return [h, s, l]; - }; - const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255); - - // Calculate complementary color based on background color - const complementaryHue = (h + 0.5) % 1; - const complementarySaturation = s > 0.5 ? s - 0.6 : s + 0.6; - const complementaryLuminance = l > 0.5 ? 0.2 : 0.8; - - // Convert complementary color back to RGB - const [rNew, gNew, bNew] = hslToRgb(complementaryHue, complementarySaturation, complementaryLuminance); - - // Return the text color in RGBA format - return `rgba(${rNew.toFixed(0)}, ${gNew.toFixed(0)}, ${bNew.toFixed(0)}, 1)`; + // Check if a theme with the same name already exists + if (themes.some(t => t.name === themeName) && !force) { + toastr.warning('Pass "force=true" to overwrite.', `A theme named "${themeName}" already exists.`); + return ''; } + const bgimg = new Image(); + bgimg.crossOrigin = 'anonymous'; + bgimg.src = bgUrl; + + await new Promise((resolve, reject) => { + bgimg.onload = resolve; + bgimg.onerror = () => reject(new Error('Failed to load background image')); + }); + + // Extract dominant vivid color using Oklch-weighted sampling + const dominantRgb = extractDominantColor(bgimg); + + // Generate a full theme palette from the dominant color + const palette = generateThemePalette(dominantRgb); + + // Create theme object from current settings, then override colors + const theme = getThemeObject(themeName); + Object.assign(theme, palette); + + // Save as a new theme + await saveTheme(themeName, theme); + applyTheme(themeName); + + toastr.success(`Theme "${themeName}" generated and applied.`); return ''; } @@ -4335,7 +4192,27 @@ jQuery(() => { SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'bgcol', callback: setAvgBG, - helpString: '– WIP test of auto-bg avg coloring', + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'force', + description: 'force generation even if a theme with the same name already exists', + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumList: commonEnumProviders.boolean('trueFalse')(), + }), + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'override the generated theme name', + typeList: [ARGUMENT_TYPE.STRING], + }), + SlashCommandNamedArgument.fromProps({ + name: 'bg', + description: 'background image filename to use instead of the current one', + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.backgrounds, + }), + ], + helpString: 'Generates a new theme based on a dominant color of the specified background image. Saves as "bgcol - background name".', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'theme', diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 1e1ca0bf7..1e8e7b9fe 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -677,9 +677,7 @@ export function initDefaultSlashCommands() { SlashCommandArgument.fromProps({ description: t`background filename`, typeList: [ARGUMENT_TYPE.STRING], - enumProvider: () => [...document.querySelectorAll('.bg_example')] - .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) - .filter(it => it.value?.length), + enumProvider: commonEnumProviders.backgrounds, }), ], helpString: ` diff --git a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js index 0c010820b..8c73c31e3 100644 --- a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js +++ b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js @@ -335,4 +335,8 @@ export const commonEnumProviders = { new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), ], + + backgrounds: () => Array.from(document.querySelectorAll('.bg_example')) + .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) + .filter(it => it.value?.length), }; diff --git a/public/scripts/util/ThemeGenerator.js b/public/scripts/util/ThemeGenerator.js new file mode 100644 index 000000000..9e993ed10 --- /dev/null +++ b/public/scripts/util/ThemeGenerator.js @@ -0,0 +1,322 @@ +/** + * @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 (0–255) + * @returns {number} Linear RGB value (0–1) + */ +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 (0–1) + * @returns {number} sRGB component (0–255), 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} (0–255 each) to Oklch {L, C, h}. + * @param {number} r Red (0–255) + * @param {number} g Green (0–255) + * @param {number} b Blue (0–255) + * @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} (0–255 each). + * @param {number} L Lightness (0–1) + * @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 (0–255) + * @param {number} g Green (0–255) + * @param {number} b Blue (0–255) + * @returns {number} Relative luminance (0–1) + */ +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 (1–21) + */ +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 (0–1) + * @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 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'; +}