ClientTheme: Fix startup freeze & clean-up plugin (#3413)

Co-authored-by: Vendicated <vendicated@riseup.net>
This commit is contained in:
Nuckyz 2025-05-04 13:55:52 -03:00
parent 235bdee061
commit b0b616d92a
No known key found for this signature in database
GPG key ID: 440BF8296E1C4AD9
6 changed files with 270 additions and 281 deletions

View file

@ -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) Revival of the old client theme experiment (The one that came before the sucky one that we actually got)

View file

@ -19,16 +19,8 @@
border: thin solid var(--background-modifier-accent) !important; border: thin solid var(--background-modifier-accent) !important;
} }
.vc-clientTheme-warning-text { .vc-clientTheme-buttons-container {
color: var(--text-danger); margin-top: 16px;
}
.vc-clientTheme-contrast-warning {
background-color: var(--background-primary);
padding: 0.5rem;
border-radius: .5rem;
display: flex; display: flex;
flex-direction: row; gap: 4px;
justify-content: space-between;
align-items: center;
} }

View file

@ -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 (
<div className={cl("settings")}>
<div className={cl("container")}>
<div className={cl("settings-labels")}>
<Forms.FormTitle tag="h3">Theme Color</Forms.FormTitle>
<Forms.FormText>Add a color to your Discord client theme</Forms.FormText>
</div>
<ColorPicker
color={parseInt(settings.store.color, 16)}
onChange={onPickColor}
showEyeDropper={false}
suggestedColors={colorPresets}
/>
</div>
{(contrastWarning || nitroThemeEnabled) && (<>
<ErrorCard className={Margins.top8}>
<Forms.FormTitle tag="h2">Your theme won't look good!</Forms.FormTitle>
{contrastWarning && <Forms.FormText>{">"} Selected color won't contrast well with text</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText>{">"} Nitro themes aren't supported</Forms.FormText>}
<div className={cl("buttons-container")}>
{(contrastWarning && fixableContrast) && <Button onClick={() => setDiscordTheme(oppositeTheme)} color={Button.Colors.RED}>Switch to {oppositeTheme} mode</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setDiscordTheme(currentTheme)} color={Button.Colors.RED}>Disable Nitro Theme</Button>}
</div>
</ErrorCard>
</>)}
</div>
);
}
export function ResetThemeColorComponent() {
return (
<Button onClick={() => onPickColor(0x313338)}>
Reset Theme Color
</Button>
);
}

View file

@ -7,104 +7,21 @@
import "./clientTheme.css"; import "./clientTheme.css";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import definePlugin, { OptionType, StartAt } from "@utils/types"; 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)"); export const settings = definePluginSettings({
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 (
<div className={cl("settings")}>
<div className={cl("container")}>
<div className={cl("settings-labels")}>
<Forms.FormTitle tag="h3">Theme Color</Forms.FormTitle>
<Forms.FormText>Add a color to your Discord client theme</Forms.FormText>
</div>
<ColorPicker
color={parseInt(settings.store.color, 16)}
onChange={onPickColor}
showEyeDropper={false}
suggestedColors={colorPresets}
/>
</div>
{(contrastWarning || nitroThemeEnabled) && (<>
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} />
<div className={`client-theme-contrast-warning ${contrastWarning ? (isLightTheme ? "theme-dark" : "theme-light") : ""}`}>
<div className={cl("warning")}>
<Forms.FormText className={cl("warning-text")}>Warning, your theme won't look good:</Forms.FormText>
{contrastWarning && <Forms.FormText className={cl("warning-text")}>Selected color won't contrast well with text</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText className={cl("warning-text")}>Nitro themes aren't supported</Forms.FormText>}
</div>
{(contrastWarning && fixableContrast) && <Button onClick={() => setTheme(oppositeTheme)} color={Button.Colors.RED}>Switch to {oppositeTheme} mode</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setTheme(theme)} color={Button.Colors.RED}>Disable Nitro Theme</Button>}
</div>
</>)}
</div>
);
}
const settings = definePluginSettings({
color: { color: {
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
default: "313338", default: "313338",
component: ThemeSettings component: ThemeSettingsComponent
}, },
resetColor: { resetColor: {
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
component: () => ( component: ResetThemeColorComponent
<Button onClick={() => onPickColor(0x313338)}>
Reset Theme Color
</Button>
)
} }
}); });
@ -115,185 +32,6 @@ export default definePlugin({
settings, settings,
startAt: StartAt.DOMContentLoaded, startAt: StartAt.DOMContentLoaded,
async start() { start: () => startClientTheme(settings.store.color),
updateColorVars(settings.store.color); stop: disableClientTheme
const styles = await getStyles();
generateColorOffsets(styles);
generateLightModeFixes(styles);
},
stop() {
document.getElementById("clientThemeVars")?.remove();
document.getElementById("clientThemeOffsets")?.remove();
document.getElementById("clientThemeLightModeFixes")?.remove();
}
}); });
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<string, number>, 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<string, number>;
const visualRefreshVariableLightness = {} as Record<string, number>;
// 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<string> {
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;
}

View file

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

View file

@ -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<string> {
const styleLinkNodes = document.querySelectorAll<HTMLLinkElement>('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<string, number>;
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<string, number>, 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");
}