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>

f687bc95e9c1777928129bc93dfaeb946e90551e

Copilot <198982749+Copilot@users.noreply.github.com>

Signed
6 files changed, +412 -195Showing whitespace changes
public/global.d.ts+2 -1
@@ -1,6 +1,6 @@
11import libs from './lib';
22import getContext from './scripts/st-context';
33import { power_user, getThemeObject } from './scripts/power-user';
44import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi';
55import { oai_settings } from './scripts/openai';
66import { textgenerationwebui_settings } from './scripts/textgen-settings';
@@ -21,6 +21,7 @@ declare global {
2121 type MessageTimestamp = string | number | Date;
2222 type Character = import('./scripts/char-data').v1CharData;
2323 type ChatMessageExtra = BaseMessageExtra & Partial<ReasoningMessageExtra> & Record<string, any>;
24+ type Theme = ReturnType<typeof getThemeObject>;
2425
2526 interface Group {
2627 id: string;
public/scripts/backgrounds.js+16 -1
@@ -254,7 +254,22 @@ async function onChatChanged() {
254254 highlightSelectedBackground();
255255}
256256
257-function getBackgroundPath(fileUrl) {
257+/**
258+ * Checks if a given URL corresponds to a custom background in the current chat's metadata.
259+ * @param {string} fileUrl - The URL to check against the chat's custom backgrounds.
260+ * @returns {boolean} True if the URL corresponds to a custom background, false otherwise.
261+ */
262+export function isCustomBackgroundUrl(fileUrl) {
263+ const customBackgrounds = chat_metadata[LIST_METADATA_KEY] || [];
264+ return customBackgrounds.some(bg => bg === fileUrl || generateUrlParameter(bg, true) === fileUrl);
265+}
266+
267+/**
268+ * Gets the client path for a background image, encoding the file name for safe URL usage.
269+ * @param {string} fileUrl File name or URL of the background image
270+ * @returns {string} Client path for the system backgroun
271+ */
272+export function getBackgroundPath(fileUrl) {
258273 return `backgrounds/${encodeURIComponent(fileUrl)}`;
259274}
260275
public/scripts/power-user.js+67 -190
@@ -62,10 +62,12 @@ import { POPUP_TYPE, callGenericPopup, fixToastrForDialogs } from './popup.js';
6262import { loadSystemPrompts } from './sysprompt.js';
6363import { fuzzySearchCategories } from './filters.js';
6464import { accountStorage } from './util/AccountStorage.js';
65+import { extractDominantColor, generateThemePalette, deriveBackgroundName } from './util/ThemeGenerator.js';
6566import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js';
6667import { bindModelTemplates } from './chat-templates.js';
6768import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';
6869import { t } from './i18n.js';
70+import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';
6971
7072export const toastPositionClasses = [
7173 'toast-top-left',
@@ -2528,9 +2530,8 @@ async function saveTheme(name = undefined, theme = undefined) {
25282530/**
25292531 * Gets a snapshot of the current theme settings.
25302532 * @param {string} name Name of the theme
2531- * @returns {object} Theme object
25322533 */
25332534export function getThemeObject(name) {
25342535 return {
25352536 name,
25362537 blur_strength: power_user.blur_strength,
@@ -2578,7 +2579,7 @@ function getThemeObject(name) {
25782579/**
25792580 * Applies imported theme properties to the theme object.
25802581 * @param {object} parsed Parsed object to get the theme from.
25812582 * @returns {objectTheme} Theme assigned to the parsed object.
25822583 */
25832584function getNewTheme(parsed) {
25842585 const theme = getThemeObject(parsed.name);
@@ -2885,208 +2886,64 @@ function doResetPanels() {
28852886 return '';
28862887}
28872888
28882889async function setAvgBG(args) {
2889- const bgimg = new Image();
2890+ const nameOverride = args?.name ? String(args.name).trim() : '';
2890- bgimg.src = $('#bg1')
2891+ const bgOverride = args?.bg ? String(args.bg).trim() : '';
2891- .css('background-image')
2892+ const force = isTrueBoolean(args?.force?.toString());
2892- .replace(/^url\(['"]?/, '')
2893- .replace(/['"]?\)$/, '');
28942893
2895- /* const charAvatar = new Image()
2894+ let bgUrl;
2896- charAvatar.src = $("#avatar_load_preview")
2897- .attr('src')
2898- .replace(/^url\(['"]?/, '')
2899- .replace(/['"]?\)$/, '');
29002895
2901- const userAvatar = new Image()
2896+ if (bgOverride) {
2902- userAvatar.src = $("#user_avatar_block .avatar.selected img")
2897+ // Use the specified background file
2903- .attr('src')
2898+ const isCustom = isCustomBackgroundUrl(bgOverride);
2899+ bgUrl = isCustom ? bgOverride : getBackgroundPath(bgOverride);
2900+ } else {
2901+ // Use the currently active background
2902+ bgUrl = $('#bg1')
2903+ .css('background-image')
29042904 .replace(/^url\(['"]?/, '')
29052905 .replace(/['"]?\)$/, ''); */
2906-
2907-
2908- bgimg.onload = function () {
2909- var rgb = getAverageRGB(bgimg);
2910- //console.log(`average color of the bg is:`)
2911- //console.log(rgb);
2912- $('#blur-tint-color-picker').attr('color', 'rgb(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ')');
2913-
2914- const backgroundColorString = $('#blur-tint-color-picker').attr('color')
2915- .replace('rgba', '')
2916- .replace('rgb', '')
2917- .replace('(', '[')
2918- .replace(')', ']'); //[50, 120, 200, 1]; // Example background color
2919- const backgroundColorArray = JSON.parse(backgroundColorString); //[200, 200, 200, 1]
2920- console.log(backgroundColorArray);
2921- $('#main-text-color-picker').attr('color', getReadableTextColor(backgroundColorArray));
2922- console.log($('#main-text-color-picker').attr('color')); // Output: 'rgba(0, 47, 126, 1)'
2923- };
2924-
2925- /* charAvatar.onload = function () {
2926- var rgb = getAverageRGB(charAvatar);
2927- //console.log(`average color of the AI avatar is:`);
2928- //console.log(rgb);
2929- $("#bot-mes-blur-tint-color-picker").attr('color', 'rgb(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ')');
2930- }
2931-
2932- userAvatar.onload = function () {
2933- var rgb = getAverageRGB(userAvatar);
2934- //console.log(`average color of the user avatar is:`);
2935- //console.log(rgb);
2936- $("#user-mes-blur-tint-color-picker").attr('color', 'rgb(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ')');
2937- } */
2938-
2939- function getAverageRGB(imgEl) {
2940- var blockSize = 5, // only visit every 5 pixels
2941- defaultRGB = { r: 0, g: 0, b: 0 }, // for non-supporting envs
2942- canvas = document.createElement('canvas'),
2943- context = canvas.getContext && canvas.getContext('2d'),
2944- data, width, height,
2945- i = -4,
2946- length,
2947- rgb = { r: 0, g: 0, b: 0 },
2948- count = 0;
2949-
2950- if (!context) {
2951- return defaultRGB;
2952- }
2953-
2954- height = canvas.height = imgEl.naturalHeight || imgEl.offsetHeight || imgEl.height;
2955- width = canvas.width = imgEl.naturalWidth || imgEl.offsetWidth || imgEl.width;
2956- context.drawImage(imgEl, 0, 0);
2957-
2958- try {
2959- data = context.getImageData(0, 0, width, height);
2960- } catch (e) {
2961- /* security error, img on diff domain */alert('x');
2962- return defaultRGB;
2963- }
2964-
2965- length = data.data.length;
2966- while ((i += blockSize * 4) < length) {
2967- ++count;
2968- rgb.r += data.data[i];
2969- rgb.g += data.data[i + 1];
2970- rgb.b += data.data[i + 2];
2971- }
2972-
2973- // ~~ used to floor values
2974- rgb.r = ~~(rgb.r / count);
2975- rgb.g = ~~(rgb.g / count);
2976- rgb.b = ~~(rgb.b / count);
2977-
2978- return rgb;
2979- }
2980-
2981- /**
2982- * Converts an HSL color value to RGB.
2983- * @param {number} h Hue value
2984- * @param {number} s Saturation value
2985- * @param {number} l Luminance value
2986- * @return {Array} The RGB representation
2987- */
2988- function hslToRgb(h, s, l) {
2989- const hueToRgb = (p, q, t) => {
2990- if (t < 0) t += 1;
2991- if (t > 1) t -= 1;
2992- if (t < 1 / 6) return p + (q - p) * 6 * t;
2993- if (t < 1 / 2) return q;
2994- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
2995- return p;
2996- };
2997-
2998- if (s === 0) {
2999- return [l, l, l];
30002906 }
30012907
3002- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
2908+ if (!bgUrl || bgUrl === 'none') {
3003- const p = 2 * l - q;
2909+ toastr.warning('No background image set.');
3004- const r = hueToRgb(p, q, h + 1 / 3);
2910+ return '';
3005- const g = hueToRgb(p, q, h);
3006- const b = hueToRgb(p, q, h - 1 / 3);
3007-
3008- return [r * 255, g * 255, b * 255];
30092911 }
30102912
30112913 //this versionBuild keepstheme BGname andfrom mainbackground textfilename inor sameuse hueoverride
3012- /* function getReadableTextColor(rgb) {
2914+ const bgName = deriveBackgroundName(bgUrl);
3013- const [r, g, b] = rgb;
2915+ const themeName = nameOverride || `bgcol - ${bgName}`;
3014-
3015- // Convert RGB to HSL
3016- const rgbToHsl = (r, g, b) => {
3017- const max = Math.max(r, g, b);
3018- const min = Math.min(r, g, b);
3019- const d = max - min;
3020- const l = (max + min) / 2;
3021-
3022- if (d === 0) return [0, 0, l];
30232916
3024- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
2917+ // Check if a theme with the same name already exists
3025- const h = (() => {
2918+ if (themes.some(t => t.name === themeName) && !force) {
3026- switch (max) {
2919+ toastr.warning('Pass "force=true" to overwrite.', `A theme named "${themeName}" already exists.`);
3027- case r:
2920+ return '';
3028- return (g - b) / d + (g < b ? 6 : 0);
3029- case g:
3030- return (b - r) / d + 2;
3031- case b:
3032- return (r - g) / d + 4;
30332921 }
3034- })() / 6;
3035-
3036- return [h, s, l];
3037- };
3038- const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255);
3039-
3040- // Calculate appropriate text color based on background color
3041- const targetLuminance = l > 0.5 ? 0.2 : 0.8;
3042- const targetSaturation = s > 0.5 ? s - 0.2 : s + 0.2;
3043- const [rNew, gNew, bNew] = hslToRgb(h, targetSaturation, targetLuminance);
30442922
3045- // Return the text color in RGBA format
2923+ const bgimg = new Image();
3046- return `rgba(${rNew.toFixed(0)}, ${gNew.toFixed(0)}, ${bNew.toFixed(0)}, 1)`;
2924+ bgimg.crossOrigin = 'anonymous';
3047- }*/
2925+ bgimg.src = bgUrl;
3048-
3049- //this version makes main text complimentary color to BG color
3050- function getReadableTextColor(rgb) {
3051- const [r, g, b] = rgb;
3052-
3053- // Convert RGB to HSL
3054- const rgbToHsl = (r, g, b) => {
3055- const max = Math.max(r, g, b);
3056- const min = Math.min(r, g, b);
3057- const d = max - min;
3058- const l = (max + min) / 2;
30592926
3060- if (d === 0) return [0, 0, l];
2927+ await new Promise((resolve, reject) => {
2928+ bgimg.onload = resolve;
2929+ bgimg.onerror = () => reject(new Error('Failed to load background image'));
2930+ });
30612931
3062- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
2932+ // Extract dominant vivid color using Oklch-weighted sampling
30632933 const hdominantRgb = (extractDominantColor(bgimg) => {;
3064- switch (max) {
3065- case r:
3066- return (g - b) / d + (g < b ? 6 : 0);
3067- case g:
3068- return (b - r) / d + 2;
3069- case b:
3070- return (r - g) / d + 4;
3071- }
3072- })() / 6;
30732934
3074- return [h, s, l];
2935+ // Generate a full theme palette from the dominant color
3075- };
2936+ const palette = generateThemePalette(dominantRgb);
3076- const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255);
30772937
3078- // Calculate complementary color based on background color
2938+ // Create theme object from current settings, then override colors
3079- const complementaryHue = (h + 0.5) % 1;
2939+ const theme = getThemeObject(themeName);
3080- const complementarySaturation = s > 0.5 ? s - 0.6 : s + 0.6;
2940+ Object.assign(theme, palette);
3081- const complementaryLuminance = l > 0.5 ? 0.2 : 0.8;
30822941
30832942 // Convert complementarySave coloras backa tonew RGBtheme
3084- const [rNew, gNew, bNew] = hslToRgb(complementaryHue, complementarySaturation, complementaryLuminance);
2943+ await saveTheme(themeName, theme);
3085-
2944+ applyTheme(themeName);
3086- // Return the text color in RGBA format
3087- return `rgba(${rNew.toFixed(0)}, ${gNew.toFixed(0)}, ${bNew.toFixed(0)}, 1)`;
3088- }
30892945
2946+ toastr.success(`Theme "${themeName}" generated and applied.`);
30902947 return '';
30912948}
30922949
@@ -4335,7 +4192,27 @@ jQuery(() => {
43354192 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
43364193 name: 'bgcol',
43374194 callback: setAvgBG,
4338- helpString: '– WIP test of auto-bg avg coloring',
4195+ namedArgumentList: [
4196+ SlashCommandNamedArgument.fromProps({
4197+ name: 'force',
4198+ description: 'force generation even if a theme with the same name already exists',
4199+ typeList: [ARGUMENT_TYPE.BOOLEAN],
4200+ defaultValue: 'false',
4201+ enumList: commonEnumProviders.boolean('trueFalse')(),
4202+ }),
4203+ SlashCommandNamedArgument.fromProps({
4204+ name: 'name',
4205+ description: 'override the generated theme name',
4206+ typeList: [ARGUMENT_TYPE.STRING],
4207+ }),
4208+ SlashCommandNamedArgument.fromProps({
4209+ name: 'bg',
4210+ description: 'background image filename to use instead of the current one',
4211+ typeList: [ARGUMENT_TYPE.STRING],
4212+ enumProvider: commonEnumProviders.backgrounds,
4213+ }),
4214+ ],
4215+ helpString: 'Generates a new theme based on a dominant color of the specified background image. Saves as "bgcol - background name".',
43394216 }));
43404217 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
43414218 name: 'theme',
public/scripts/slash-commands.js+1 -3
@@ -677,9 +677,7 @@ export function initDefaultSlashCommands() {
677677 SlashCommandArgument.fromProps({
678678 description: t`background filename`,
679679 typeList: [ARGUMENT_TYPE.STRING],
680- enumProvider: () => [...document.querySelectorAll('.bg_example')]
680+ enumProvider: commonEnumProviders.backgrounds,
681- .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))
682- .filter(it => it.value?.length),
683681 }),
684682 ],
685683 helpString: `
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+4 -0
@@ -335,4 +335,8 @@ export const commonEnumProviders = {
335335 new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant),
336336 new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system),
337337 ],
338+
339+ backgrounds: () => Array.from(document.querySelectorAll('.bg_example'))
340+ .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))
341+ .filter(it => it.value?.length),
338342};
public/scripts/util/ThemeGenerator.js+322 -0
@@ -0,0 +1,322 @@
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+}