diff --git a/packages/utils/src/theme/color-conversion.ts b/packages/utils/src/theme/color-conversion.ts index 0d0cec1b0..5acd34cdf 100644 --- a/packages/utils/src/theme/color-conversion.ts +++ b/packages/utils/src/theme/color-conversion.ts @@ -136,3 +136,35 @@ export function isGrayscale(hex: string): boolean { return false; } } + +/** + * Calculate relative luminance using WCAG standard + * Returns a value between 0 (black) and 1 (white) + * Based on: https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ +export function getRelativeLuminance(hex: string): number { + try { + const cleanHex = hex.replace("#", ""); + const color = chroma(`#${cleanHex}`); + return color.luminance(); + } catch (error) { + console.error("Error calculating luminance:", error); + return 0.5; // Safe default + } +} + +/** + * Calculate perceptual brightness using weighted RGB formula + * Returns a value between 0 (dark) and 255 (bright) + * Uses ITU-R BT.709 coefficients for better perceptual accuracy + */ +export function getPerceptualBrightness(hex: string): number { + try { + const { r, g, b } = hexToRgb(hex); + // ITU-R BT.709 coefficients + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } catch (error) { + console.error("Error calculating brightness:", error); + return 128; // Safe default (mid-gray) + } +} diff --git a/packages/utils/src/theme/index.ts b/packages/utils/src/theme/index.ts index 61d7ba9d9..a8d54a098 100644 --- a/packages/utils/src/theme/index.ts +++ b/packages/utils/src/theme/index.ts @@ -13,7 +13,13 @@ export { } from "./palette-generator"; // Theme application -export { applyCustomTheme, clearCustomTheme } from "./theme-application"; +export { + applyCustomTheme, + clearCustomTheme, + isColorDark, + getOnColorTextColors, + type DarknessDetectionMethod, +} from "./theme-application"; // Color conversion utilities export { @@ -24,6 +30,8 @@ export { isGrayscale, oklchToCSS, parseOKLCH, + getRelativeLuminance, + getPerceptualBrightness, // rgbToHex, type OKLCH, type RGB, diff --git a/packages/utils/src/theme/theme-application.ts b/packages/utils/src/theme/theme-application.ts index b54d33c59..66041007a 100644 --- a/packages/utils/src/theme/theme-application.ts +++ b/packages/utils/src/theme/theme-application.ts @@ -3,11 +3,79 @@ * Applies generated palettes to CSS variables for Plane's theme system */ -import { hexToOKLCH, oklchToCSS } from "./color-conversion"; +import { hexToOKLCH, oklchToCSS, getRelativeLuminance, getPerceptualBrightness } from "./color-conversion"; +import type { OKLCH } from "./color-conversion"; import { ALPHA_MAPPING } from "./constants"; import { generateThemePalettes } from "./palette-generator"; import { getBrandMapping, getNeutralMapping, invertPalette } from "./theme-inversion"; +/** + * Color darkness detection methods + */ +export type DarknessDetectionMethod = "wcag" | "oklch" | "perceptual"; + +/** + * Determine if a color is dark using various methods + * + * Methods: + * - 'wcag': Uses WCAG relative luminance (0-1 scale, threshold 0.5) - Most accurate for accessibility + * - 'oklch': Uses OKLCH lightness (0-1 scale, threshold 0.5) - Good for perceptual uniformity + * - 'perceptual': Uses weighted RGB brightness (0-255 scale, threshold 128) - Simple and fast + * + * @param brandColor - Brand color in hex format + * @param method - Detection method to use (default: 'wcag') + * @returns true if the color is dark, false if light + */ +export function isColorDark(brandColor: string, method: DarknessDetectionMethod = "wcag"): boolean { + switch (method) { + case "wcag": { + // WCAG relative luminance: 0 (black) to 1 (white) + // Threshold of 0.5 means colors darker than 50% gray are considered dark + const luminance = getRelativeLuminance(brandColor); + return luminance < 0.5; + } + case "oklch": { + // OKLCH lightness: 0 (black) to 1 (white) + // Threshold of 0.5 provides good balance for most colors + const oklch = hexToOKLCH(brandColor); + return oklch.l < 0.5; + } + case "perceptual": { + // Perceptual brightness: 0 (black) to 255 (white) + // Threshold of 128 is the midpoint + const brightness = getPerceptualBrightness(brandColor); + return brightness < 128; + } + default: + return getRelativeLuminance(brandColor) < 0.5; + } +} + +/** + * Get contrasting text colors for use on a colored background + * Returns white text for dark backgrounds, black text for light backgrounds + * + * @param brandColor - Brand color in hex format + * @param method - Detection method to use (default: 'wcag') + * @returns Object with text and icon colors in OKLCH format + */ +export function getOnColorTextColors( + brandColor: string, + method: DarknessDetectionMethod = "wcag" +): { + textColor: OKLCH; + iconColor: OKLCH; +} { + const isDark = isColorDark(brandColor, method); + const white: OKLCH = { l: 1, c: 0, h: 0 }; + const black: OKLCH = { l: 0, c: 0, h: 0 }; + + return { + textColor: isDark ? white : black, + iconColor: isDark ? black : white, + }; +} + /** * Apply custom theme using 2-color palette system * Generates full palettes from brand and neutral colors @@ -32,7 +100,6 @@ export function applyCustomTheme(brandColor: string, neutralColor: string, mode: // Generate palettes directly in OKLCH color space const { brandPalette, neutralPalette } = generateThemePalettes(brandColor, neutralColor, mode); const neutralOKLCH = hexToOKLCH(neutralColor); - const brandOKLCH = hexToOKLCH(brandColor); // For dark mode, invert the palettes const activeBrandPalette = mode === "dark" ? invertPalette(brandPalette) : brandPalette; @@ -57,14 +124,11 @@ export function applyCustomTheme(brandColor: string, neutralColor: string, mode: themeElement.style.setProperty(`--color-alpha-black-${key}`, oklchToCSS(neutralOKLCH, value * 100)); }); - const isBrandColorDark = brandOKLCH.l < 0.2; - const whiteInOKLCH = { l: 1, c: 0, h: 0 }; - const blackInOKLCH = { l: 0, c: 0, h: 0 }; - themeElement.style.setProperty(`--text-color-on-color`, oklchToCSS(isBrandColorDark ? whiteInOKLCH : blackInOKLCH)); - themeElement.style.setProperty( - `--text-color-icon-on-color`, - oklchToCSS(isBrandColorDark ? blackInOKLCH : whiteInOKLCH) - ); + // Apply contrasting text colors for use on colored backgrounds + // Uses WCAG relative luminance for accurate contrast determination + const { textColor, iconColor } = getOnColorTextColors(brandColor, "wcag"); + themeElement.style.setProperty(`--text-color-on-color`, oklchToCSS(textColor)); + themeElement.style.setProperty(`--text-color-icon-on-color`, oklchToCSS(iconColor)); } /**