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>
Signed| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import libs from './lib'; |
| 2 | 2 | import getContext from './scripts/st-context'; |
| 3 | 3 | import { power_user, getThemeObject } from './scripts/power-user'; |
| 4 | 4 | import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; |
| 5 | 5 | import { oai_settings } from './scripts/openai'; |
| 6 | 6 | import { textgenerationwebui_settings } from './scripts/textgen-settings'; |
| @@ -21,6 +21,7 @@ declare global { | ||
| 21 | 21 | type MessageTimestamp = string | number | Date; |
| 22 | 22 | type Character = import('./scripts/char-data').v1CharData; |
| 23 | 23 | type ChatMessageExtra = BaseMessageExtra & Partial<ReasoningMessageExtra> & Record<string, any>; |
| 24 | + type Theme = ReturnType<typeof getThemeObject>; | |
| 24 | 25 | |
| 25 | 26 | interface Group { |
| 26 | 27 | id: string; |
| @@ -254,7 +254,22 @@ async function onChatChanged() { | ||
| 254 | 254 | highlightSelectedBackground(); |
| 255 | 255 | } |
| 256 | 256 | |
| 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) { | |
| 258 | 273 | return `backgrounds/${encodeURIComponent(fileUrl)}`; |
| 259 | 274 | } |
| 260 | 275 | |
| @@ -62,10 +62,12 @@ import { POPUP_TYPE, callGenericPopup, fixToastrForDialogs } from './popup.js'; | ||
| 62 | 62 | import { loadSystemPrompts } from './sysprompt.js'; |
| 63 | 63 | import { fuzzySearchCategories } from './filters.js'; |
| 64 | 64 | import { accountStorage } from './util/AccountStorage.js'; |
| 65 | +import { extractDominantColor, generateThemePalette, deriveBackgroundName } from './util/ThemeGenerator.js'; | |
| 65 | 66 | import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; |
| 66 | 67 | import { bindModelTemplates } from './chat-templates.js'; |
| 67 | 68 | import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; |
| 68 | 69 | import { t } from './i18n.js'; |
| 70 | +import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js'; | |
| 69 | 71 | |
| 70 | 72 | export const toastPositionClasses = [ |
| 71 | 73 | 'toast-top-left', |
| @@ -2528,9 +2530,8 @@ async function saveTheme(name = undefined, theme = undefined) { | ||
| 2528 | 2530 | /** |
| 2529 | 2531 | * Gets a snapshot of the current theme settings. |
| 2530 | 2532 | * @param {string} name Name of the theme |
| 2531 | - * @returns {object} Theme object | |
| 2532 | 2533 | */ |
| 2533 | 2534 | export function getThemeObject(name) { |
| 2534 | 2535 | return { |
| 2535 | 2536 | name, |
| 2536 | 2537 | blur_strength: power_user.blur_strength, |
| @@ -2578,7 +2579,7 @@ function getThemeObject(name) { | ||
| 2578 | 2579 | /** |
| 2579 | 2580 | * Applies imported theme properties to the theme object. |
| 2580 | 2581 | * @param {object} parsed Parsed object to get the theme from. |
| 2581 | 2582 | * @returns {objectTheme} Theme assigned to the parsed object. |
| 2582 | 2583 | */ |
| 2583 | 2584 | function getNewTheme(parsed) { |
| 2584 | 2585 | const theme = getThemeObject(parsed.name); |
| @@ -2885,208 +2886,64 @@ function doResetPanels() { | ||
| 2885 | 2886 | return ''; |
| 2886 | 2887 | } |
| 2887 | 2888 | |
| 2888 | 2889 | async 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(/['"]?\)$/, ''); | |
| 2894 | - | |
| 2895 | - /* const charAvatar = new Image() | |
| 2896 | - charAvatar.src = $("#avatar_load_preview") | |
| 2897 | - .attr('src') | |
| 2898 | - .replace(/^url\(['"]?/, '') | |
| 2899 | - .replace(/['"]?\)$/, ''); | |
| 2900 | - | |
| 2901 | - const userAvatar = new Image() | |
| 2902 | - userAvatar.src = $("#user_avatar_block .avatar.selected img") | |
| 2903 | - .attr('src') | |
| 2904 | - .replace(/^url\(['"]?/, '') | |
| 2905 | - .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 | 2893 | |
| 2954 | - height = canvas.height = imgEl.naturalHeight || imgEl.offsetHeight || imgEl.height; | |
| 2894 | + let bgUrl; | |
| 2955 | - width = canvas.width = imgEl.naturalWidth || imgEl.offsetWidth || imgEl.width; | |
| 2956 | - context.drawImage(imgEl, 0, 0); | |
| 2957 | 2895 | |
| 2958 | - try { | |
| 2896 | + if (bgOverride) { | |
| 2959 | - data = context.getImageData(0, 0, width, height); | |
| 2897 | + // Use the specified background file | |
| 2960 | - } catch (e) { | |
| 2898 | + const isCustom = isCustomBackgroundUrl(bgOverride); | |
| 2961 | - /* security error, img on diff domain */alert('x'); | |
| 2899 | + bgUrl = isCustom ? bgOverride : getBackgroundPath(bgOverride); | |
| 2962 | - return defaultRGB; | |
| 2900 | + } else { | |
| 2963 | - } | |
| 2901 | + // Use the currently active background | |
| 2964 | - | |
| 2902 | + bgUrl = $('#bg1') | |
| 2965 | - length = data.data.length; | |
| 2903 | + .css('background-image') | |
| 2966 | - while ((i += blockSize * 4) < length) { | |
| 2904 | + .replace(/^url\(['"]?/, '') | |
| 2967 | - ++count; | |
| 2905 | + .replace(/['"]?\)$/, ''); | |
| 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 | 2906 | } |
| 2980 | 2907 | |
| 2981 | - /** | |
| 2908 | + if (!bgUrl || bgUrl === 'none') { | |
| 2982 | - * Converts an HSL color value to RGB. | |
| 2909 | + toastr.warning('No background image set.'); | |
| 2983 | - * @param {number} h Hue value | |
| 2910 | + return ''; | |
| 2984 | - * @param {number} s Saturation value | |
| 2911 | + } | |
| 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]; | |
| 3000 | - } | |
| 3001 | 2912 | |
| 3002 | - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; | |
| 2913 | + // Build theme name from background filename or use override | |
| 3003 | - const p = 2 * l - q; | |
| 2914 | + const bgName = deriveBackgroundName(bgUrl); | |
| 3004 | - const r = hueToRgb(p, q, h + 1 / 3); | |
| 2915 | + const themeName = nameOverride || `bgcol - ${bgName}`; | |
| 3005 | - const g = hueToRgb(p, q, h); | |
| 3006 | - const b = hueToRgb(p, q, h - 1 / 3); | |
| 3007 | 2916 | |
| 3008 | - return [r * 255, g * 255, b * 255]; | |
| 2917 | + // Check if a theme with the same name already exists | |
| 2918 | + if (themes.some(t => t.name === themeName) && !force) { | |
| 2919 | + toastr.warning('Pass "force=true" to overwrite.', `A theme named "${themeName}" already exists.`); | |
| 2920 | + return ''; | |
| 3009 | 2921 | } |
| 3010 | 2922 | |
| 3011 | - //this version keeps BG and main text in same hue | |
| 2923 | + const bgimg = new Image(); | |
| 3012 | - /* function getReadableTextColor(rgb) { | |
| 2924 | + bgimg.crossOrigin = 'anonymous'; | |
| 3013 | - const [r, g, b] = rgb; | |
| 2925 | + bgimg.src = bgUrl; | |
| 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]; | |
| 3023 | - | |
| 3024 | - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); | |
| 3025 | - const h = (() => { | |
| 3026 | - switch (max) { | |
| 3027 | - case r: | |
| 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; | |
| 3033 | - } | |
| 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); | |
| 3044 | - | |
| 3045 | - // Return the text color in RGBA format | |
| 3046 | - return `rgba(${rNew.toFixed(0)}, ${gNew.toFixed(0)}, ${bNew.toFixed(0)}, 1)`; | |
| 3047 | - }*/ | |
| 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; | |
| 3059 | - | |
| 3060 | - if (d === 0) return [0, 0, l]; | |
| 3061 | - | |
| 3062 | - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); | |
| 3063 | - const h = (() => { | |
| 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; | |
| 3073 | 2926 | |
| 3074 | - return [h, s, l]; | |
| 2927 | + await new Promise((resolve, reject) => { | |
| 3075 | - }; | |
| 2928 | + bgimg.onload = resolve; | |
| 3076 | - const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255); | |
| 2929 | + bgimg.onerror = () => reject(new Error('Failed to load background image')); | |
| 2930 | + }); | |
| 3077 | 2931 | |
| 3078 | 2932 | // CalculateExtract complementarydominant colorvivid basedcolor onusing backgroundOklch-weighted colorsampling |
| 3079 | - const complementaryHue = (h + 0.5) % 1; | |
| 2933 | + const dominantRgb = extractDominantColor(bgimg); | |
| 3080 | - const complementarySaturation = s > 0.5 ? s - 0.6 : s + 0.6; | |
| 3081 | - const complementaryLuminance = l > 0.5 ? 0.2 : 0.8; | |
| 3082 | 2934 | |
| 3083 | - // Convert complementary color back to RGB | |
| 2935 | + // Generate a full theme palette from the dominant color | |
| 3084 | - const [rNew, gNew, bNew] = hslToRgb(complementaryHue, complementarySaturation, complementaryLuminance); | |
| 2936 | + const palette = generateThemePalette(dominantRgb); | |
| 3085 | 2937 | |
| 3086 | - // Return the text color in RGBA format | |
| 2938 | + // Create theme object from current settings, then override colors | |
| 3087 | - return `rgba(${rNew.toFixed(0)}, ${gNew.toFixed(0)}, ${bNew.toFixed(0)}, 1)`; | |
| 2939 | + const theme = getThemeObject(themeName); | |
| 3088 | - } | |
| 2940 | + Object.assign(theme, palette); | |
| 2941 | + | |
| 2942 | + // Save as a new theme | |
| 2943 | + await saveTheme(themeName, theme); | |
| 2944 | + applyTheme(themeName); | |
| 3089 | 2945 | |
| 2946 | + toastr.success(`Theme "${themeName}" generated and applied.`); | |
| 3090 | 2947 | return ''; |
| 3091 | 2948 | } |
| 3092 | 2949 | |
| @@ -4335,7 +4192,27 @@ jQuery(() => { | ||
| 4335 | 4192 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 4336 | 4193 | name: 'bgcol', |
| 4337 | 4194 | 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".', | |
| 4339 | 4216 | })); |
| 4340 | 4217 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 4341 | 4218 | name: 'theme', |
| @@ -677,9 +677,7 @@ export function initDefaultSlashCommands() { | ||
| 677 | 677 | SlashCommandArgument.fromProps({ |
| 678 | 678 | description: t`background filename`, |
| 679 | 679 | 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), | |
| 683 | 681 | }), |
| 684 | 682 | ], |
| 685 | 683 | helpString: ` |
| @@ -335,4 +335,8 @@ export const commonEnumProviders = { | ||
| 335 | 335 | new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), |
| 336 | 336 | new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), |
| 337 | 337 | ], |
| 338 | + | |
| 339 | + backgrounds: () => Array.from(document.querySelectorAll('.bg_example')) | |
| 340 | + .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) | |
| 341 | + .filter(it => it.value?.length), | |
| 338 | 342 | }; |
| @@ -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 | +} | |