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;
}