@ -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 { find ComponentByCodeLazy } from "@webpack" ;
import { Button , Forms } from "@webpack/common" ;
import { find ByPropsLazy, find ComponentByCodeLazy, findStor eLazy } 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 (
< div className = "client-theme-settings" >
@ -48,15 +74,18 @@ function ThemeSettings() {
suggestedColors = { colorPresets }
/ >
< / div >
{ lightnessWarning || lightModeWarning
? < div >
< Forms.FormDivider className = { classes ( Margins . top8 , Margins . bottom8 ) } / >
< Forms.FormText className = "client-theme-warning" > Your theme won ' t look good : < / Forms.FormText >
{ lightnessWarning && < Forms.FormText className = "client-theme-warning" > Selected color is very light < / Forms.FormText > }
{ lightModeWarning && < Forms.FormText className = "client-theme-warning" > Light mode isn ' t supported < / Forms.FormText > }
{ ( contrastWarning || nitroThemeEnabled ) && ( < >
< Forms.FormDivider className = { classes ( Margins . top8 , Margins . bottom8 ) } / >
< div className = { ` client-theme-contrast-warning ${ contrastWarning ? ( isLightTheme ? "theme-dark" : "theme-light" ) : "" } ` } >
< div className = "client-theme-warning" >
< 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 >
{ ( 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 >
: null
}
< / > ) }
< / div >
) ;
}
@ -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 ;
async function generateColorOffsets() {
const styleLinkNodes = document . querySelectorAll ( 'link[rel="stylesheet"]' ) ;
const variableLightness = { } as Record < string , number > ;
// 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 ) ;
}
}
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 ;
// 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 < string , number > , 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 < string , number > ;
// 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 < 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
@ -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 ;
const cMax = Math . max ( r , g , b ) ;
const cMin = Math . min ( r , g , b ) ;
// 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 lightness = 100 * ( ( cMax + cMin ) / 2 ) ;
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 lightness ;
return r * 0.2126 + g * 0.7152 + b * 0.0722 ;
}