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 | import libs from './lib'; | 1 | import libs from './lib'; |
| 2 | import getContext from './scripts/st-context'; | 2 | import getContext from './scripts/st-context'; |
| 3 | import { power_user } from './scripts/power-user'; | 3 | import { power_user, getThemeObject } from './scripts/power-user'; |
| 4 | import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; | 4 | import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; |
| 5 | import { oai_settings } from './scripts/openai'; | 5 | import { oai_settings } from './scripts/openai'; |
| 6 | import { textgenerationwebui_settings } from './scripts/textgen-settings'; | 6 | import { textgenerationwebui_settings } from './scripts/textgen-settings'; |
| @@ -21,6 +21,7 @@ declare global { | |||
| 21 | type MessageTimestamp = string | number | Date; | 21 | type MessageTimestamp = string | number | Date; |
| 22 | type Character = import('./scripts/char-data').v1CharData; | 22 | type Character = import('./scripts/char-data').v1CharData; |
| 23 | type ChatMessageExtra = BaseMessageExtra & Partial<ReasoningMessageExtra> & Record<string, any>; | 23 | type ChatMessageExtra = BaseMessageExtra & Partial<ReasoningMessageExtra> & Record<string, any>; |
| 24 | type Theme = ReturnType<typeof getThemeObject>; | ||
| 24 | 25 | ||
| 25 | interface Group { | 26 | interface Group { |
| 26 | id: string; | 27 | id: string; |
| @@ -254,7 +254,22 @@ async function onChatChanged() { | |||
| 254 | highlightSelectedBackground(); | 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 | return `backgrounds/${encodeURIComponent(fileUrl)}`; | 273 | return `backgrounds/${encodeURIComponent(fileUrl)}`; |
| 259 | } | 274 | } |
| 260 | 275 | ||
| @@ -62,10 +62,12 @@ import { POPUP_TYPE, callGenericPopup, fixToastrForDialogs } from './popup.js'; | |||
| 62 | import { loadSystemPrompts } from './sysprompt.js'; | 62 | import { loadSystemPrompts } from './sysprompt.js'; |
| 63 | import { fuzzySearchCategories } from './filters.js'; | 63 | import { fuzzySearchCategories } from './filters.js'; |
| 64 | import { accountStorage } from './util/AccountStorage.js'; | 64 | import { accountStorage } from './util/AccountStorage.js'; |
| 65 | import { extractDominantColor, generateThemePalette, deriveBackgroundName } from './util/ThemeGenerator.js'; | ||
| 65 | import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; | 66 | import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; |
| 66 | import { bindModelTemplates } from './chat-templates.js'; | 67 | import { bindModelTemplates } from './chat-templates.js'; |
| 67 | import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; | 68 | import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; |
| 68 | import { t } from './i18n.js'; | 69 | import { t } from './i18n.js'; |
| 70 | import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js'; | ||
| 69 | 71 | ||
| 70 | export const toastPositionClasses = [ | 72 | export const toastPositionClasses = [ |
| 71 | 'toast-top-left', | 73 | 'toast-top-left', |
| @@ -2528,9 +2530,8 @@ async function saveTheme(name = undefined, theme = undefined) { | |||
| 2528 | /** | 2530 | /** |
| 2529 | * Gets a snapshot of the current theme settings. | 2531 | * Gets a snapshot of the current theme settings. |
| 2530 | * @param {string} name Name of the theme | 2532 | * @param {string} name Name of the theme |
| 2531 | * @returns {object} Theme object | ||
| 2532 | */ | 2533 | */ |
| 2533 | function getThemeObject(name) { | 2534 | export function getThemeObject(name) { |
| 2534 | return { | 2535 | return { |
| 2535 | name, | 2536 | name, |
| 2536 | blur_strength: power_user.blur_strength, | 2537 | blur_strength: power_user.blur_strength, |
| @@ -2578,7 +2579,7 @@ function getThemeObject(name) { | |||
| 2578 | /** | 2579 | /** |
| 2579 | * Applies imported theme properties to the theme object. | 2580 | * Applies imported theme properties to the theme object. |
| 2580 | * @param {object} parsed Parsed object to get the theme from. | 2581 | * @param {object} parsed Parsed object to get the theme from. |
| 2581 | * @returns {object} Theme assigned to the parsed object. | 2582 | * @returns {Theme} Theme assigned to the parsed object. |
| 2582 | */ | 2583 | */ |
| 2583 | function getNewTheme(parsed) { | 2584 | function getNewTheme(parsed) { |
| 2584 | const theme = getThemeObject(parsed.name); | 2585 | const theme = getThemeObject(parsed.name); |
| @@ -2885,208 +2886,64 @@ function doResetPanels() { | |||
| 2885 | return ''; | 2886 | return ''; |
| 2886 | } | 2887 | } |
| 2887 | 2888 | ||
| 2888 | function setAvgBG() { | 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 | // Calculate complementary color based on background color | 2932 | // Extract dominant vivid color using Oklch-weighted sampling |
| 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 | return ''; | 2947 | return ''; |
| 3091 | } | 2948 | } |
| 3092 | 2949 | ||
| @@ -4335,7 +4192,27 @@ jQuery(() => { | |||
| 4335 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 4192 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 4336 | name: 'bgcol', | 4193 | name: 'bgcol', |
| 4337 | callback: setAvgBG, | 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 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 4217 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 4341 | name: 'theme', | 4218 | name: 'theme', |
| @@ -677,9 +677,7 @@ export function initDefaultSlashCommands() { | |||
| 677 | SlashCommandArgument.fromProps({ | 677 | SlashCommandArgument.fromProps({ |
| 678 | description: t`background filename`, | 678 | description: t`background filename`, |
| 679 | typeList: [ARGUMENT_TYPE.STRING], | 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 | helpString: ` | 683 | helpString: ` |
| @@ -335,4 +335,8 @@ export const commonEnumProviders = { | |||
| 335 | new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), | 335 | new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), |
| 336 | new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), | 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 | } | ||