mirror of
https://github.com/Equicord/Equicord.git
synced 2025-05-11 18:05:37 +02:00
ClientTheme: Fix startup freeze & clean-up plugin (#3413)
Co-authored-by: Vendicated <vendicated@riseup.net>
This commit is contained in:
parent
235bdee061
commit
b0b616d92a
6 changed files with 270 additions and 281 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
104
src/plugins/clientTheme/components/Settings.tsx
Normal file
104
src/plugins/clientTheme/components/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<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({
|
||||
export const settings = definePluginSettings({
|
||||
color: {
|
||||
type: OptionType.COMPONENT,
|
||||
default: "313338",
|
||||
component: ThemeSettings
|
||||
component: ThemeSettingsComponent
|
||||
},
|
||||
resetColor: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => (
|
||||
<Button onClick={() => onPickColor(0x313338)}>
|
||||
Reset Theme Color
|
||||
</Button>
|
||||
)
|
||||
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<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;
|
||||
}
|
||||
|
|
65
src/plugins/clientTheme/utils/colorUtils.ts
Normal file
65
src/plugins/clientTheme/utils/colorUtils.ts
Normal 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;
|
||||
}
|
90
src/plugins/clientTheme/utils/styleUtils.ts
Normal file
90
src/plugins/clientTheme/utils/styleUtils.ts
Normal 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");
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue