diff --git a/src/plugins/clientTheme/README.md b/src/plugins/clientTheme/README.md
index 4b40148c5..e2723f8bc 100644
--- a/src/plugins/clientTheme/README.md
+++ b/src/plugins/clientTheme/README.md
@@ -1,4 +1,4 @@
-# Classic Client Theme
+# Client Theme
Revival of the old client theme experiment (The one that came before the sucky one that we actually got)
diff --git a/src/plugins/clientTheme/clientTheme.css b/src/plugins/clientTheme/clientTheme.css
index 795b5457e..49cc3e156 100644
--- a/src/plugins/clientTheme/clientTheme.css
+++ b/src/plugins/clientTheme/clientTheme.css
@@ -19,16 +19,8 @@
border: thin solid var(--background-modifier-accent) !important;
}
-.vc-clientTheme-warning-text {
- color: var(--text-danger);
-}
-
-.vc-clientTheme-contrast-warning {
- background-color: var(--background-primary);
- padding: 0.5rem;
- border-radius: .5rem;
+.vc-clientTheme-buttons-container {
+ margin-top: 16px;
display: flex;
- flex-direction: row;
- justify-content: space-between;
- align-items: center;
+ gap: 4px;
}
diff --git a/src/plugins/clientTheme/components/Settings.tsx b/src/plugins/clientTheme/components/Settings.tsx
new file mode 100644
index 000000000..f38380fa7
--- /dev/null
+++ b/src/plugins/clientTheme/components/Settings.tsx
@@ -0,0 +1,104 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { classNameFactory } from "@api/Styles";
+import { ErrorCard } from "@components/ErrorCard";
+import { Margins } from "@utils/margins";
+import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
+import { Button, Forms, ThemeStore, useStateFromStores } from "@webpack/common";
+
+import { settings } from "..";
+import { relativeLuminance } from "../utils/colorUtils";
+import { createOrUpdateThemeColorVars } from "../utils/styleUtils";
+
+const ColorPicker = findComponentByCodeLazy("#{intl::USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR}", ".BACKGROUND_PRIMARY)");
+const saveClientTheme = findByCodeLazy('type:"UNSYNCED_USER_SETTINGS_UPDATE', '"system"===');
+const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore");
+
+const cl = classNameFactory("vc-clientTheme-");
+
+const colorPresets = [
+ "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",
+ "#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42",
+ "#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d",
+ "#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb",
+];
+
+function onPickColor(color: number) {
+ const hexColor = color.toString(16).padStart(6, "0");
+
+ settings.store.color = hexColor;
+ createOrUpdateThemeColorVars(hexColor);
+}
+
+function setDiscordTheme(theme: string) {
+ saveClientTheme({ theme });
+}
+
+export function ThemeSettingsComponent() {
+ const currentTheme = useStateFromStores([ThemeStore], () => ThemeStore.theme);
+ const isLightTheme = currentTheme === "light";
+ const oppositeTheme = isLightTheme ? "Dark" : "Light";
+
+ const nitroThemeEnabled = useStateFromStores([NitroThemeStore], () => NitroThemeStore.gradientPreset != null);
+
+ const selectedLuminance = relativeLuminance(settings.store.color);
+
+ let contrastWarning = false;
+ let 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 (
+
+
+
+ Theme Color
+ Add a color to your Discord client theme
+
+
+
+ {(contrastWarning || nitroThemeEnabled) && (<>
+
+ Your theme won't look good!
+
+ {contrastWarning && {">"} Selected color won't contrast well with text}
+ {nitroThemeEnabled && {">"} Nitro themes aren't supported}
+
+
+ {(contrastWarning && fixableContrast) && }
+ {(nitroThemeEnabled) && }
+
+
+ >)}
+
+ );
+}
+
+export function ResetThemeColorComponent() {
+ return (
+
+ );
+}
diff --git a/src/plugins/clientTheme/index.tsx b/src/plugins/clientTheme/index.tsx
index 2b77d00de..984318e29 100644
--- a/src/plugins/clientTheme/index.tsx
+++ b/src/plugins/clientTheme/index.tsx
@@ -7,104 +7,21 @@
import "./clientTheme.css";
import { definePluginSettings } from "@api/Settings";
-import { classNameFactory } from "@api/Styles";
import { Devs } from "@utils/constants";
-import { Margins } from "@utils/margins";
-import { classes } from "@utils/misc";
import definePlugin, { OptionType, StartAt } from "@utils/types";
-import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
-import { Button, Forms, ThemeStore, useStateFromStores } from "@webpack/common";
-const cl = classNameFactory("vc-clientTheme-");
+import { ResetThemeColorComponent, ThemeSettingsComponent } from "./components/Settings";
+import { disableClientTheme, startClientTheme } from "./utils/styleUtils";
-const ColorPicker = findComponentByCodeLazy("#{intl::USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR}", ".BACKGROUND_PRIMARY)");
-
-const colorPresets = [
- "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",
- "#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42",
- "#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d",
- "#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb",
-];
-
-function onPickColor(color: number) {
- const hexColor = color.toString(16).padStart(6, "0");
-
- settings.store.color = hexColor;
- updateColorVars(hexColor);
-}
-
-const saveClientTheme = findByCodeLazy('type:"UNSYNCED_USER_SETTINGS_UPDATE', '"system"===');
-
-function setTheme(theme: string) {
- saveClientTheme({ theme });
-}
-
-const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore");
-
-function ThemeSettings() {
- 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 (
-
-
-
- Theme Color
- Add a color to your Discord client theme
-
-
-
- {(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) &&
}
-
- >)}
-
- );
-}
-
-const settings = definePluginSettings({
+export const settings = definePluginSettings({
color: {
type: OptionType.COMPONENT,
default: "313338",
- component: ThemeSettings
+ component: ThemeSettingsComponent
},
resetColor: {
type: OptionType.COMPONENT,
- component: () => (
-
- )
+ component: ResetThemeColorComponent
}
});
@@ -115,185 +32,6 @@ export default definePlugin({
settings,
startAt: StartAt.DOMContentLoaded,
- async start() {
- updateColorVars(settings.store.color);
-
- const styles = await getStyles();
- generateColorOffsets(styles);
- generateLightModeFixes(styles);
- },
-
- stop() {
- document.getElementById("clientThemeVars")?.remove();
- document.getElementById("clientThemeOffsets")?.remove();
- document.getElementById("clientThemeLightModeFixes")?.remove();
- }
+ start: () => startClientTheme(settings.store.color),
+ stop: disableClientTheme
});
-
-const visualRefreshVariableRegex = /(--neutral-\d{1,3}-hsl):.*?(\S*)%;/g;
-const oldVariableRegex = /(--primary-\d{3}-hsl):.*?(\S*)%;/g;
-const lightVariableRegex = /^--primary-[1-5]\d{2}-hsl/g;
-const darkVariableRegex = /^--primary-[5-9]\d{2}-hsl/g;
-
-// 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 | null, centerVariable: string): string {
- return Object.entries(variableLightness).filter(([key]) => regex == null || key.search(regex) > -1)
- .map(([key, lightness]) => {
- 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");
-}
-
-function generateColorOffsets(styles) {
- const oldVariableLightness = {} as Record;
- const visualRefreshVariableLightness = {} as Record;
-
- // Get lightness values of --primary variables
- for (const [, variable, lightness] of styles.matchAll(oldVariableRegex)) {
- oldVariableLightness[variable] = parseFloat(lightness);
- }
-
- for (const [, variable, lightness] of styles.matchAll(visualRefreshVariableRegex)) {
- visualRefreshVariableLightness[variable] = parseFloat(lightness);
- }
-
- createStyleSheet("clientThemeOffsets", [
- `.theme-light {\n ${genThemeSpecificOffsets(oldVariableLightness, lightVariableRegex, "--primary-345-hsl")} \n}`,
- `.theme-dark {\n ${genThemeSpecificOffsets(oldVariableLightness, darkVariableRegex, "--primary-600-hsl")} \n}`,
- `.visual-refresh.theme-light {\n ${genThemeSpecificOffsets(visualRefreshVariableLightness, null, "--neutral-2-hsl")} \n}`,
- `.visual-refresh.theme-dark {\n ${genThemeSpecificOffsets(visualRefreshVariableLightness, null, "--neutral-69-hsl")} \n}`,
- ].join("\n\n"));
-}
-
-function generateLightModeFixes(styles: string) {
- 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) {
- return arr.map(mapFunc).filter(Boolean);
-}
-
-function updateColorVars(color: string) {
- const { hue, saturation, lightness } = hexToHSL(color);
-
- let style = document.getElementById("clientThemeVars");
- if (!style)
- style = createStyleSheet("clientThemeVars");
-
- style.textContent = `:root {
- --theme-h: ${hue};
- --theme-s: ${saturation}%;
- --theme-l: ${lightness}%;
- }`;
-}
-
-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
- 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;
-
- // RGB => HSL
- const cMax = Math.max(r, g, b);
- const cMin = Math.min(r, g, b);
- const delta = cMax - cMin;
-
- let hue: number, saturation: number, lightness: number;
-
- lightness = (cMax + cMin) / 2;
-
- if (delta === 0) {
- // If r=g=b then the only thing that matters is lightness
- hue = 0;
- saturation = 0;
- } else {
- // Magic
- saturation = delta / (1 - Math.abs(2 * lightness - 1));
-
- if (cMax === r)
- hue = ((g - b) / delta) % 6;
- else if (cMax === g)
- hue = (b - r) / delta + 2;
- else
- hue = (r - g) / delta + 4;
- hue *= 60;
- if (hue < 0)
- hue += 360;
- }
-
- // Move saturation and lightness from 0-1 to 0-100
- saturation *= 100;
- lightness *= 100;
-
- return { hue, saturation, lightness };
-}
-
-// 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 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);
-
- return r * 0.2126 + g * 0.7152 + b * 0.0722;
-}
diff --git a/src/plugins/clientTheme/utils/colorUtils.ts b/src/plugins/clientTheme/utils/colorUtils.ts
new file mode 100644
index 000000000..88d94714e
--- /dev/null
+++ b/src/plugins/clientTheme/utils/colorUtils.ts
@@ -0,0 +1,65 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// https://css-tricks.com/converting-color-spaces-in-javascript/
+export function hexToHSL(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;
+
+ // RGB => HSL
+ const cMax = Math.max(r, g, b);
+ const cMin = Math.min(r, g, b);
+ const delta = cMax - cMin;
+
+ let hue: number;
+ let saturation: number;
+ let lightness: number;
+
+ lightness = (cMax + cMin) / 2;
+
+ if (delta === 0) {
+ // If r=g=b then the only thing that matters is lightness
+ hue = 0;
+ saturation = 0;
+ } else {
+ // Magic
+ saturation = delta / (1 - Math.abs(2 * lightness - 1));
+
+ if (cMax === r) {
+ hue = ((g - b) / delta) % 6;
+ } else if (cMax === g) {
+ hue = (b - r) / delta + 2;
+ } else {
+ hue = (r - g) / delta + 4;
+ }
+
+ hue *= 60;
+ if (hue < 0) {
+ hue += 360;
+ }
+ }
+
+ // Move saturation and lightness from 0-1 to 0-100
+ saturation *= 100;
+ lightness *= 100;
+
+ return { hue, saturation, lightness };
+}
+
+// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
+export function relativeLuminance(hexCode: string) {
+ const normalize = (x: number) => (
+ x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
+ );
+
+ 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);
+
+ return r * 0.2126 + g * 0.7152 + b * 0.0722;
+}
diff --git a/src/plugins/clientTheme/utils/styleUtils.ts b/src/plugins/clientTheme/utils/styleUtils.ts
new file mode 100644
index 000000000..bc6169d4a
--- /dev/null
+++ b/src/plugins/clientTheme/utils/styleUtils.ts
@@ -0,0 +1,90 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { hexToHSL } from "./colorUtils";
+
+const VARS_STYLE_ID = "vc-clientTheme-vars";
+const OVERRIDES_STYLE_ID = "vc-clientTheme-overrides";
+
+export function createOrUpdateThemeColorVars(color: string) {
+ const { hue, saturation, lightness } = hexToHSL(color);
+
+ createOrUpdateStyle(VARS_STYLE_ID, `:root {
+ --theme-h: ${hue};
+ --theme-s: ${saturation}%;
+ --theme-l: ${lightness}%;
+ }`);
+}
+
+export async function startClientTheme(color: string) {
+ createOrUpdateThemeColorVars(color);
+ createColorsOverrides(await getDiscordStyles());
+}
+
+export function disableClientTheme() {
+ document.getElementById(VARS_STYLE_ID)?.remove();
+ document.getElementById(OVERRIDES_STYLE_ID)?.remove();
+}
+
+function getOrCreateStyle(styleId: string) {
+ const existingStyle = document.getElementById(styleId);
+ if (existingStyle) {
+ return existingStyle as HTMLStyleElement;
+ }
+
+ const newStyle = document.createElement("style");
+ newStyle.id = styleId;
+
+ return document.head.appendChild(newStyle);
+}
+
+function createOrUpdateStyle(styleId: string, css: string) {
+ const style = getOrCreateStyle(styleId);
+ style.textContent = css;
+}
+
+/**
+ * @returns A string containing all the CSS styles from the Discord client.
+ */
+async function getDiscordStyles(): Promise {
+ const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]');
+
+ const cssTexts = await Promise.all(Array.from(styleLinkNodes, async node => {
+ if (!node.href)
+ return null;
+
+ return fetch(node.href).then(res => res.text());
+ }));
+
+ return cssTexts.filter(Boolean).join("\n");
+}
+
+const VISUAL_REFRESH_COLORS_VARIABLES_REGEX = /(--neutral-\d{1,3}?-hsl):.+?([\d.]+?)%;/g;
+
+function createColorsOverrides(styles: string) {
+ const visualRefreshColorsLightness = {} as Record;
+
+ for (const [, colorVariableName, lightness] of styles.matchAll(VISUAL_REFRESH_COLORS_VARIABLES_REGEX)) {
+ visualRefreshColorsLightness[colorVariableName] = parseFloat(lightness);
+ }
+
+ const lightThemeBaseLightness = visualRefreshColorsLightness["--neutral-2-hsl"];
+ const darkThemeBaseLightness = visualRefreshColorsLightness["--neutral-69-hsl"];
+
+ createOrUpdateStyle(OVERRIDES_STYLE_ID, [
+ `.visual-refresh.theme-light {\n ${generateNewColorVars(visualRefreshColorsLightness, lightThemeBaseLightness)} \n}`,
+ `.visual-refresh.theme-dark {\n ${generateNewColorVars(visualRefreshColorsLightness, darkThemeBaseLightness)} \n}`,
+ ].join("\n\n"));
+}
+
+function generateNewColorVars(colorsLightess: Record, baseLightness: number) {
+ return Object.entries(colorsLightess).map(([colorVariableName, lightness]) => {
+ const lightnessOffset = lightness - baseLightness;
+ const plusOrMinus = lightnessOffset >= 0 ? "+" : "-";
+
+ return `${colorVariableName}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`;
+ }).join("\n");
+}