diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index 2808494a..8abaaba4 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -21,9 +21,11 @@ import { classNameFactory } from "@api/Styles"; import { Flex } from "@components/Flex"; import { DeleteIcon } from "@components/Icons"; import { Link } from "@components/Link"; +import PluginModal from "@components/PluginSettings/PluginModal"; import { openInviteModal } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; +import { openModal } from "@utils/modal"; import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; import { findByPropsLazy, findLazy } from "@webpack"; @@ -248,6 +250,21 @@ function ThemesTab() { > Edit QuickCSS + + {Vencord.Settings.plugins.ClientTheme.enabled && ( + + )} diff --git a/src/plugins/clientTheme/clientTheme.css b/src/plugins/clientTheme/clientTheme.css index 023f88bd..64aefdcf 100644 --- a/src/plugins/clientTheme/clientTheme.css +++ b/src/plugins/clientTheme/clientTheme.css @@ -19,6 +19,16 @@ border: thin solid var(--background-modifier-accent) !important; } -.client-theme-warning { +.client-theme-warning * { color: var(--text-danger); } + +.client-theme-contrast-warning { + background-color: var(--background-primary); + padding: 0.5rem; + border-radius: .5rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} diff --git a/src/plugins/clientTheme/index.tsx b/src/plugins/clientTheme/index.tsx index d7592996..5d8cd4dc 100644 --- a/src/plugins/clientTheme/index.tsx +++ b/src/plugins/clientTheme/index.tsx @@ -8,19 +8,19 @@ import "./clientTheme.css"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; -import { getTheme, Theme } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import definePlugin, { OptionType, StartAt } from "@utils/types"; -import { findComponentByCodeLazy } from "@webpack"; -import { Button, Forms } from "@webpack/common"; +import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { Button, Forms, lodash as _, useStateFromStores } from "@webpack/common"; const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)"); const colorPresets = [ "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D", "#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42", - "#3C2E42", "#422938" + "#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d", + "#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb", ]; function onPickColor(color: number) { @@ -30,9 +30,35 @@ function onPickColor(color: number) { updateColorVars(hexColor); } +const { saveClientTheme } = findByPropsLazy("saveClientTheme"); + +function setTheme(theme: string) { + saveClientTheme({ theme }); +} + +const ThemeStore = findStoreLazy("ThemeStore"); +const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore"); + function ThemeSettings() { - const lightnessWarning = hexToLightness(settings.store.color) > 45; - const lightModeWarning = getTheme() === Theme.Light; + const theme = useStateFromStores([ThemeStore], () => ThemeStore.theme); + const isLightTheme = theme === "light"; + const oppositeTheme = isLightTheme ? "dark" : "light"; + + const nitroTheme = useStateFromStores([NitroThemeStore], () => NitroThemeStore.gradientPreset); + const nitroThemeEnabled = nitroTheme !== undefined; + + const selectedLuminance = relativeLuminance(settings.store.color); + + let contrastWarning = false, fixableContrast = true; + if ((isLightTheme && selectedLuminance < 0.26) || !isLightTheme && selectedLuminance > 0.12) + contrastWarning = true; + if (selectedLuminance < 0.26 && selectedLuminance > 0.12) + fixableContrast = false; + // light mode with values greater than 65 leads to background colors getting crushed together and poor text contrast for muted channels + if (isLightTheme && selectedLuminance > 0.65) { + contrastWarning = true; + fixableContrast = false; + } return (
@@ -48,15 +74,18 @@ function ThemeSettings() { suggestedColors={colorPresets} />
- {lightnessWarning || lightModeWarning - ?
- - Your theme won't look good: - {lightnessWarning && Selected color is very light} - {lightModeWarning && Light mode isn't supported} + {(contrastWarning || nitroThemeEnabled) && (<> + +
+
+ Warning, your theme won't look good: + {contrastWarning && Selected color won't contrast well with text} + {nitroThemeEnabled && Nitro themes aren't supported} +
+ {(contrastWarning && fixableContrast) && } + {(nitroThemeEnabled) && }
- : null - } + )}
); } @@ -87,9 +116,12 @@ export default definePlugin({ settings, startAt: StartAt.DOMContentLoaded, - start() { + async start() { updateColorVars(settings.store.color); - generateColorOffsets(); + + const styles = await getStyles(); + generateColorOffsets(styles); + generateLightModeFixes(styles); }, stop() { @@ -98,56 +130,86 @@ export default definePlugin({ } }); -const variableRegex = /(--primary-[5-9]\d{2}-hsl):.*?(\S*)%;/g; +const variableRegex = /(--primary-\d{3}-hsl):.*?(\S*)%;/g; +const lightVariableRegex = /^--primary-[1-5]\d{2}-hsl/g; +const darkVariableRegex = /^--primary-[5-9]\d{2}-hsl/g; -async function generateColorOffsets() { - - const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]'); - const variableLightness = {} as Record; - - // Search all stylesheets for color variables - for (const styleLinkNode of styleLinkNodes) { - const cssLink = styleLinkNode.getAttribute("href"); - if (!cssLink) continue; - - const res = await fetch(cssLink); - const cssString = await res.text(); - - // Get lightness values of --primary variables >=500 - let variableMatch = variableRegex.exec(cssString); - while (variableMatch !== null) { - const [, variable, lightness] = variableMatch; - variableLightness[variable] = parseFloat(lightness); - variableMatch = variableRegex.exec(cssString); - } - } - - // Generate offsets - const lightnessOffsets = Object.entries(variableLightness) +// generates variables per theme by: +// - matching regex (so we can limit what variables are included in light/dark theme, otherwise text becomes unreadable) +// - offset from specified center (light/dark theme get different offsets because light uses 100 for background-primary, while dark uses 600) +function genThemeSpecificOffsets(variableLightness: Record, regex: RegExp, centerVariable: string): string { + return Object.entries(variableLightness).filter(([key]) => key.search(regex) > -1) .map(([key, lightness]) => { - const lightnessOffset = lightness - variableLightness["--primary-600-hsl"]; + const lightnessOffset = lightness - variableLightness[centerVariable]; const plusOrMinus = lightnessOffset >= 0 ? "+" : "-"; return `${key}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`; }) .join("\n"); +} - const style = document.createElement("style"); - style.setAttribute("id", "clientThemeOffsets"); - style.textContent = `:root:root { - ${lightnessOffsets} - }`; - document.head.appendChild(style); + +function generateColorOffsets(styles) { + const variableLightness = {} as Record; + + // Get lightness values of --primary variables + let variableMatch = variableRegex.exec(styles); + while (variableMatch !== null) { + const [, variable, lightness] = variableMatch; + variableLightness[variable] = parseFloat(lightness); + variableMatch = variableRegex.exec(styles); + } + + createStyleSheet("clientThemeOffsets", [ + `.theme-light {\n ${genThemeSpecificOffsets(variableLightness, lightVariableRegex, "--primary-345-hsl")} \n}`, + `.theme-dark {\n ${genThemeSpecificOffsets(variableLightness, darkVariableRegex, "--primary-600-hsl")} \n}`, + ].join("\n\n")); +} + +function generateLightModeFixes(styles) { + const groupLightUsesW500Regex = /\.theme-light[^{]*\{[^}]*var\(--white-500\)[^}]*}/gm; + // get light capturing groups that mention --white-500 + const relevantStyles = [...styles.matchAll(groupLightUsesW500Regex)].flat(); + + const groupBackgroundRegex = /^([^{]*)\{background:var\(--white-500\)/m; + const groupBackgroundColorRegex = /^([^{]*)\{background-color:var\(--white-500\)/m; + // find all capturing groups that assign background or background-color directly to w500 + const backgroundGroups = mapReject(relevantStyles, entry => captureOne(entry, groupBackgroundRegex)).join(",\n"); + const backgroundColorGroups = mapReject(relevantStyles, entry => captureOne(entry, groupBackgroundColorRegex)).join(",\n"); + // create css to reassign them to --primary-100 + const reassignBackgrounds = `${backgroundGroups} {\n background: var(--primary-100) \n}`; + const reassignBackgroundColors = `${backgroundColorGroups} {\n background-color: var(--primary-100) \n}`; + + const groupBgVarRegex = /\.theme-light\{([^}]*--[^:}]*(?:background|bg)[^:}]*:var\(--white-500\)[^}]*)\}/m; + const bgVarRegex = /^(--[^:]*(?:background|bg)[^:]*):var\(--white-500\)/m; + // get all global variables used for backgrounds + const lightVars = mapReject(relevantStyles, style => captureOne(style, groupBgVarRegex)) // get the insides of capture groups that have at least one background var with w500 + .map(str => str.split(";")).flat(); // captureGroupInsides[] -> cssRule[] + const lightBgVars = mapReject(lightVars, variable => captureOne(variable, bgVarRegex)); // remove vars that aren't for backgrounds or w500 + // create css to reassign every var + const reassignVariables = `.theme-light {\n ${lightBgVars.map(variable => `${variable}: var(--primary-100);`).join("\n")} \n}`; + + createStyleSheet("clientThemeLightModeFixes", [ + reassignBackgrounds, + reassignBackgroundColors, + reassignVariables, + ].join("\n\n")); +} + +function captureOne(str, regex) { + const result = str.match(regex); + return (result === null) ? null : result[1]; +} + +function mapReject(arr, mapFunc, rejectFunc = _.isNull) { + return _.reject(arr.map(mapFunc), rejectFunc); } function updateColorVars(color: string) { const { hue, saturation, lightness } = hexToHSL(color); let style = document.getElementById("clientThemeVars"); - if (!style) { - style = document.createElement("style"); - style.setAttribute("id", "clientThemeVars"); - document.head.appendChild(style); - } + if (!style) + style = createStyleSheet("clientThemeVars"); style.textContent = `:root { --theme-h: ${hue}; @@ -156,6 +218,28 @@ function updateColorVars(color: string) { }`; } +function createStyleSheet(id, content = "") { + const style = document.createElement("style"); + style.setAttribute("id", id); + style.textContent = content.split("\n").map(line => line.trim()).join("\n"); + document.body.appendChild(style); + return style; +} + +// returns all of discord's native styles in a single string +async function getStyles(): Promise { + let out = ""; + const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]'); + for (const styleLinkNode of styleLinkNodes) { + const cssLink = styleLinkNode.getAttribute("href"); + if (!cssLink) continue; + + const res = await fetch(cssLink); + out += await res.text(); + } + return out; +} + // https://css-tricks.com/converting-color-spaces-in-javascript/ function hexToHSL(hexCode: string) { // Hex => RGB normalized to 0-1 @@ -198,17 +282,14 @@ function hexToHSL(hexCode: string) { return { hue, saturation, lightness }; } -// Minimized math just for lightness, lowers lag when changing colors -function hexToLightness(hexCode: string) { - // Hex => RGB normalized to 0-1 - const r = parseInt(hexCode.substring(0, 2), 16) / 255; - const g = parseInt(hexCode.substring(2, 4), 16) / 255; - const b = parseInt(hexCode.substring(4, 6), 16) / 255; +// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +function relativeLuminance(hexCode: string) { + const normalize = (x: number) => + x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; - const cMax = Math.max(r, g, b); - const cMin = Math.min(r, g, b); + const r = normalize(parseInt(hexCode.substring(0, 2), 16) / 255); + const g = normalize(parseInt(hexCode.substring(2, 4), 16) / 255); + const b = normalize(parseInt(hexCode.substring(4, 6), 16) / 255); - const lightness = 100 * ((cMax + cMin) / 2); - - return lightness; + return r * 0.2126 + g * 0.7152 + b * 0.0722; }