ClientTheme light mode support, shortcut buttons (#2010)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com> Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
parent
1a982ae9aa
commit
f14001b531
3 changed files with 173 additions and 65 deletions
|
@ -21,9 +21,11 @@ import { classNameFactory } from "@api/Styles";
|
||||||
import { Flex } from "@components/Flex";
|
import { Flex } from "@components/Flex";
|
||||||
import { DeleteIcon } from "@components/Icons";
|
import { DeleteIcon } from "@components/Icons";
|
||||||
import { Link } from "@components/Link";
|
import { Link } from "@components/Link";
|
||||||
|
import PluginModal from "@components/PluginSettings/PluginModal";
|
||||||
import { openInviteModal } from "@utils/discord";
|
import { openInviteModal } from "@utils/discord";
|
||||||
import { Margins } from "@utils/margins";
|
import { Margins } from "@utils/margins";
|
||||||
import { classes } from "@utils/misc";
|
import { classes } from "@utils/misc";
|
||||||
|
import { openModal } from "@utils/modal";
|
||||||
import { showItemInFolder } from "@utils/native";
|
import { showItemInFolder } from "@utils/native";
|
||||||
import { useAwaiter } from "@utils/react";
|
import { useAwaiter } from "@utils/react";
|
||||||
import { findByPropsLazy, findLazy } from "@webpack";
|
import { findByPropsLazy, findLazy } from "@webpack";
|
||||||
|
@ -248,6 +250,21 @@ function ThemesTab() {
|
||||||
>
|
>
|
||||||
Edit QuickCSS
|
Edit QuickCSS
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{Vencord.Settings.plugins.ClientTheme.enabled && (
|
||||||
|
<Button
|
||||||
|
onClick={() => openModal(modalProps => (
|
||||||
|
<PluginModal
|
||||||
|
{...modalProps}
|
||||||
|
plugin={Vencord.Plugins.plugins.ClientTheme}
|
||||||
|
onRestartNeeded={() => { }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
size={Button.Sizes.SMALL}
|
||||||
|
>
|
||||||
|
Edit ClientTheme
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,16 @@
|
||||||
border: thin solid var(--background-modifier-accent) !important;
|
border: thin solid var(--background-modifier-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-theme-warning {
|
.client-theme-warning * {
|
||||||
color: var(--text-danger);
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -8,19 +8,19 @@ import "./clientTheme.css";
|
||||||
|
|
||||||
import { definePluginSettings } from "@api/Settings";
|
import { definePluginSettings } from "@api/Settings";
|
||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import { getTheme, Theme } from "@utils/discord";
|
|
||||||
import { Margins } from "@utils/margins";
|
import { Margins } from "@utils/margins";
|
||||||
import { classes } from "@utils/misc";
|
import { classes } from "@utils/misc";
|
||||||
import definePlugin, { OptionType, StartAt } from "@utils/types";
|
import definePlugin, { OptionType, StartAt } from "@utils/types";
|
||||||
import { findComponentByCodeLazy } from "@webpack";
|
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
|
||||||
import { Button, Forms } from "@webpack/common";
|
import { Button, Forms, lodash as _, useStateFromStores } from "@webpack/common";
|
||||||
|
|
||||||
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
|
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
|
||||||
|
|
||||||
const colorPresets = [
|
const colorPresets = [
|
||||||
"#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",
|
"#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",
|
||||||
"#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42",
|
"#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42",
|
||||||
"#3C2E42", "#422938"
|
"#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d",
|
||||||
|
"#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb",
|
||||||
];
|
];
|
||||||
|
|
||||||
function onPickColor(color: number) {
|
function onPickColor(color: number) {
|
||||||
|
@ -30,9 +30,35 @@ function onPickColor(color: number) {
|
||||||
updateColorVars(hexColor);
|
updateColorVars(hexColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { saveClientTheme } = findByPropsLazy("saveClientTheme");
|
||||||
|
|
||||||
|
function setTheme(theme: string) {
|
||||||
|
saveClientTheme({ theme });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeStore = findStoreLazy("ThemeStore");
|
||||||
|
const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore");
|
||||||
|
|
||||||
function ThemeSettings() {
|
function ThemeSettings() {
|
||||||
const lightnessWarning = hexToLightness(settings.store.color) > 45;
|
const theme = useStateFromStores([ThemeStore], () => ThemeStore.theme);
|
||||||
const lightModeWarning = getTheme() === Theme.Light;
|
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 (
|
return (
|
||||||
<div className="client-theme-settings">
|
<div className="client-theme-settings">
|
||||||
|
@ -48,15 +74,18 @@ function ThemeSettings() {
|
||||||
suggestedColors={colorPresets}
|
suggestedColors={colorPresets}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{lightnessWarning || lightModeWarning
|
{(contrastWarning || nitroThemeEnabled) && (<>
|
||||||
? <div>
|
|
||||||
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} />
|
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} />
|
||||||
<Forms.FormText className="client-theme-warning">Your theme won't look good:</Forms.FormText>
|
<div className={`client-theme-contrast-warning ${contrastWarning ? (isLightTheme ? "theme-dark" : "theme-light") : ""}`}>
|
||||||
{lightnessWarning && <Forms.FormText className="client-theme-warning">Selected color is very light</Forms.FormText>}
|
<div className="client-theme-warning">
|
||||||
{lightModeWarning && <Forms.FormText className="client-theme-warning">Light mode isn't supported</Forms.FormText>}
|
<Forms.FormText>Warning, your theme won't look good:</Forms.FormText>
|
||||||
|
{contrastWarning && <Forms.FormText>Selected color won't contrast well with text</Forms.FormText>}
|
||||||
|
{nitroThemeEnabled && <Forms.FormText>Nitro themes aren't supported</Forms.FormText>}
|
||||||
</div>
|
</div>
|
||||||
: null
|
{(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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -87,9 +116,12 @@ export default definePlugin({
|
||||||
settings,
|
settings,
|
||||||
|
|
||||||
startAt: StartAt.DOMContentLoaded,
|
startAt: StartAt.DOMContentLoaded,
|
||||||
start() {
|
async start() {
|
||||||
updateColorVars(settings.store.color);
|
updateColorVars(settings.store.color);
|
||||||
generateColorOffsets();
|
|
||||||
|
const styles = await getStyles();
|
||||||
|
generateColorOffsets(styles);
|
||||||
|
generateLightModeFixes(styles);
|
||||||
},
|
},
|
||||||
|
|
||||||
stop() {
|
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() {
|
// generates variables per theme by:
|
||||||
|
// - matching regex (so we can limit what variables are included in light/dark theme, otherwise text becomes unreadable)
|
||||||
const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]');
|
// - offset from specified center (light/dark theme get different offsets because light uses 100 for background-primary, while dark uses 600)
|
||||||
const variableLightness = {} as Record<string, number>;
|
function genThemeSpecificOffsets(variableLightness: Record<string, number>, regex: RegExp, centerVariable: string): string {
|
||||||
|
return Object.entries(variableLightness).filter(([key]) => key.search(regex) > -1)
|
||||||
// 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)
|
|
||||||
.map(([key, lightness]) => {
|
.map(([key, lightness]) => {
|
||||||
const lightnessOffset = lightness - variableLightness["--primary-600-hsl"];
|
const lightnessOffset = lightness - variableLightness[centerVariable];
|
||||||
const plusOrMinus = lightnessOffset >= 0 ? "+" : "-";
|
const plusOrMinus = lightnessOffset >= 0 ? "+" : "-";
|
||||||
return `${key}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`;
|
return `${key}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.setAttribute("id", "clientThemeOffsets");
|
function generateColorOffsets(styles) {
|
||||||
style.textContent = `:root:root {
|
const variableLightness = {} as Record<string, number>;
|
||||||
${lightnessOffsets}
|
|
||||||
}`;
|
// Get lightness values of --primary variables
|
||||||
document.head.appendChild(style);
|
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) {
|
function updateColorVars(color: string) {
|
||||||
const { hue, saturation, lightness } = hexToHSL(color);
|
const { hue, saturation, lightness } = hexToHSL(color);
|
||||||
|
|
||||||
let style = document.getElementById("clientThemeVars");
|
let style = document.getElementById("clientThemeVars");
|
||||||
if (!style) {
|
if (!style)
|
||||||
style = document.createElement("style");
|
style = createStyleSheet("clientThemeVars");
|
||||||
style.setAttribute("id", "clientThemeVars");
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
style.textContent = `:root {
|
style.textContent = `:root {
|
||||||
--theme-h: ${hue};
|
--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<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/
|
// https://css-tricks.com/converting-color-spaces-in-javascript/
|
||||||
function hexToHSL(hexCode: string) {
|
function hexToHSL(hexCode: string) {
|
||||||
// Hex => RGB normalized to 0-1
|
// Hex => RGB normalized to 0-1
|
||||||
|
@ -198,17 +282,14 @@ function hexToHSL(hexCode: string) {
|
||||||
return { hue, saturation, lightness };
|
return { hue, saturation, lightness };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimized math just for lightness, lowers lag when changing colors
|
// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||||
function hexToLightness(hexCode: string) {
|
function relativeLuminance(hexCode: string) {
|
||||||
// Hex => RGB normalized to 0-1
|
const normalize = (x: number) =>
|
||||||
const r = parseInt(hexCode.substring(0, 2), 16) / 255;
|
x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||||
const g = parseInt(hexCode.substring(2, 4), 16) / 255;
|
|
||||||
const b = parseInt(hexCode.substring(4, 6), 16) / 255;
|
|
||||||
|
|
||||||
const cMax = Math.max(r, g, b);
|
const r = normalize(parseInt(hexCode.substring(0, 2), 16) / 255);
|
||||||
const cMin = Math.min(r, g, b);
|
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 r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||||
|
|
||||||
return lightness;
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue