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>
This commit is contained in:
Copilot
2026-02-15 16:39:55 +02:00
committed by GitHub
parent 46ea79bab5
commit f687bc95e9
6 changed files with 418 additions and 201 deletions
+2 -1
View File
@@ -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<ReasoningMessageExtra> & Record<string, any>;
type Theme = ReturnType<typeof getThemeObject>;
interface Group {
id: string;
+16 -1
View File
@@ -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)}`;
}
+73 -196
View File
@@ -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',
+1 -3
View File
@@ -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: `
@@ -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),
};
+322
View File
@@ -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 (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';
}