Skip to content

Commit 61db42b

Browse files
[WEB-5614] chore: custom theme on colour improvement #8356
1 parent 1f06b67 commit 61db42b

File tree

3 files changed

+115
-11
lines changed

3 files changed

+115
-11
lines changed

packages/utils/src/theme/color-conversion.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,35 @@ export function isGrayscale(hex: string): boolean {
136136
return false;
137137
}
138138
}
139+
140+
/**
141+
* Calculate relative luminance using WCAG standard
142+
* Returns a value between 0 (black) and 1 (white)
143+
* Based on: https://www.w3.org/TR/WCAG20/#relativeluminancedef
144+
*/
145+
export function getRelativeLuminance(hex: string): number {
146+
try {
147+
const cleanHex = hex.replace("#", "");
148+
const color = chroma(`#${cleanHex}`);
149+
return color.luminance();
150+
} catch (error) {
151+
console.error("Error calculating luminance:", error);
152+
return 0.5; // Safe default
153+
}
154+
}
155+
156+
/**
157+
* Calculate perceptual brightness using weighted RGB formula
158+
* Returns a value between 0 (dark) and 255 (bright)
159+
* Uses ITU-R BT.709 coefficients for better perceptual accuracy
160+
*/
161+
export function getPerceptualBrightness(hex: string): number {
162+
try {
163+
const { r, g, b } = hexToRgb(hex);
164+
// ITU-R BT.709 coefficients
165+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
166+
} catch (error) {
167+
console.error("Error calculating brightness:", error);
168+
return 128; // Safe default (mid-gray)
169+
}
170+
}

packages/utils/src/theme/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ export {
1313
} from "./palette-generator";
1414

1515
// Theme application
16-
export { applyCustomTheme, clearCustomTheme } from "./theme-application";
16+
export {
17+
applyCustomTheme,
18+
clearCustomTheme,
19+
isColorDark,
20+
getOnColorTextColors,
21+
type DarknessDetectionMethod,
22+
} from "./theme-application";
1723

1824
// Color conversion utilities
1925
export {
@@ -24,6 +30,8 @@ export {
2430
isGrayscale,
2531
oklchToCSS,
2632
parseOKLCH,
33+
getRelativeLuminance,
34+
getPerceptualBrightness,
2735
// rgbToHex,
2836
type OKLCH,
2937
type RGB,

packages/utils/src/theme/theme-application.ts

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,79 @@
33
* Applies generated palettes to CSS variables for Plane's theme system
44
*/
55

6-
import { hexToOKLCH, oklchToCSS } from "./color-conversion";
6+
import { hexToOKLCH, oklchToCSS, getRelativeLuminance, getPerceptualBrightness } from "./color-conversion";
7+
import type { OKLCH } from "./color-conversion";
78
import { ALPHA_MAPPING } from "./constants";
89
import { generateThemePalettes } from "./palette-generator";
910
import { getBrandMapping, getNeutralMapping, invertPalette } from "./theme-inversion";
1011

12+
/**
13+
* Color darkness detection methods
14+
*/
15+
export type DarknessDetectionMethod = "wcag" | "oklch" | "perceptual";
16+
17+
/**
18+
* Determine if a color is dark using various methods
19+
*
20+
* Methods:
21+
* - 'wcag': Uses WCAG relative luminance (0-1 scale, threshold 0.5) - Most accurate for accessibility
22+
* - 'oklch': Uses OKLCH lightness (0-1 scale, threshold 0.5) - Good for perceptual uniformity
23+
* - 'perceptual': Uses weighted RGB brightness (0-255 scale, threshold 128) - Simple and fast
24+
*
25+
* @param brandColor - Brand color in hex format
26+
* @param method - Detection method to use (default: 'wcag')
27+
* @returns true if the color is dark, false if light
28+
*/
29+
export function isColorDark(brandColor: string, method: DarknessDetectionMethod = "wcag"): boolean {
30+
switch (method) {
31+
case "wcag": {
32+
// WCAG relative luminance: 0 (black) to 1 (white)
33+
// Threshold of 0.5 means colors darker than 50% gray are considered dark
34+
const luminance = getRelativeLuminance(brandColor);
35+
return luminance < 0.5;
36+
}
37+
case "oklch": {
38+
// OKLCH lightness: 0 (black) to 1 (white)
39+
// Threshold of 0.5 provides good balance for most colors
40+
const oklch = hexToOKLCH(brandColor);
41+
return oklch.l < 0.5;
42+
}
43+
case "perceptual": {
44+
// Perceptual brightness: 0 (black) to 255 (white)
45+
// Threshold of 128 is the midpoint
46+
const brightness = getPerceptualBrightness(brandColor);
47+
return brightness < 128;
48+
}
49+
default:
50+
return getRelativeLuminance(brandColor) < 0.5;
51+
}
52+
}
53+
54+
/**
55+
* Get contrasting text colors for use on a colored background
56+
* Returns white text for dark backgrounds, black text for light backgrounds
57+
*
58+
* @param brandColor - Brand color in hex format
59+
* @param method - Detection method to use (default: 'wcag')
60+
* @returns Object with text and icon colors in OKLCH format
61+
*/
62+
export function getOnColorTextColors(
63+
brandColor: string,
64+
method: DarknessDetectionMethod = "wcag"
65+
): {
66+
textColor: OKLCH;
67+
iconColor: OKLCH;
68+
} {
69+
const isDark = isColorDark(brandColor, method);
70+
const white: OKLCH = { l: 1, c: 0, h: 0 };
71+
const black: OKLCH = { l: 0, c: 0, h: 0 };
72+
73+
return {
74+
textColor: isDark ? white : black,
75+
iconColor: isDark ? black : white,
76+
};
77+
}
78+
1179
/**
1280
* Apply custom theme using 2-color palette system
1381
* Generates full palettes from brand and neutral colors
@@ -32,7 +100,6 @@ export function applyCustomTheme(brandColor: string, neutralColor: string, mode:
32100
// Generate palettes directly in OKLCH color space
33101
const { brandPalette, neutralPalette } = generateThemePalettes(brandColor, neutralColor, mode);
34102
const neutralOKLCH = hexToOKLCH(neutralColor);
35-
const brandOKLCH = hexToOKLCH(brandColor);
36103

37104
// For dark mode, invert the palettes
38105
const activeBrandPalette = mode === "dark" ? invertPalette(brandPalette) : brandPalette;
@@ -57,14 +124,11 @@ export function applyCustomTheme(brandColor: string, neutralColor: string, mode:
57124
themeElement.style.setProperty(`--color-alpha-black-${key}`, oklchToCSS(neutralOKLCH, value * 100));
58125
});
59126

60-
const isBrandColorDark = brandOKLCH.l < 0.2;
61-
const whiteInOKLCH = { l: 1, c: 0, h: 0 };
62-
const blackInOKLCH = { l: 0, c: 0, h: 0 };
63-
themeElement.style.setProperty(`--text-color-on-color`, oklchToCSS(isBrandColorDark ? whiteInOKLCH : blackInOKLCH));
64-
themeElement.style.setProperty(
65-
`--text-color-icon-on-color`,
66-
oklchToCSS(isBrandColorDark ? blackInOKLCH : whiteInOKLCH)
67-
);
127+
// Apply contrasting text colors for use on colored backgrounds
128+
// Uses WCAG relative luminance for accurate contrast determination
129+
const { textColor, iconColor } = getOnColorTextColors(brandColor, "wcag");
130+
themeElement.style.setProperty(`--text-color-on-color`, oklchToCSS(textColor));
131+
themeElement.style.setProperty(`--text-color-icon-on-color`, oklchToCSS(iconColor));
68132
}
69133

70134
/**

0 commit comments

Comments
 (0)