/** * Theme Application Utilities * Applies generated palettes to CSS variables for Plane's theme system */ import { hexToOKLCH, oklchToCSS, getRelativeLuminance, getPerceptualBrightness } from "./color-conversion"; import type { OKLCH } from "./color-conversion"; import { ALPHA_MAPPING, EDITOR_COLORS_LIGHT, EDITOR_COLORS_DARK } 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 * and maps them to CSS variables * * @param brandColor - Brand accent color (hex with or without #) * @param neutralColor - Neutral/background color (hex with or without #) * @param mode - 'light' or 'dark' theme mode */ export function applyCustomTheme(brandColor: string, neutralColor: string, mode: "light" | "dark"): void { if (!brandColor || !neutralColor) { console.warn("applyCustomTheme: brandColor and neutralColor are required"); return; } const themeElement = document?.querySelector("html"); if (!themeElement) { console.warn("applyCustomTheme: html element not found"); return; } // Generate palettes directly in OKLCH color space const { brandPalette, neutralPalette } = generateThemePalettes(brandColor, neutralColor, mode); const neutralOKLCH = hexToOKLCH(neutralColor); // For dark mode, invert the palettes const activeBrandPalette = mode === "dark" ? invertPalette(brandPalette) : brandPalette; const activeNeutralPalette = mode === "dark" ? invertPalette(neutralPalette) : neutralPalette; // Get CSS variable mappings const neutralMapping = getNeutralMapping(activeNeutralPalette); const brandMapping = getBrandMapping(activeBrandPalette); // Apply base palette colors // This updates the source palette variables that semantic colors reference Object.entries(neutralMapping).forEach(([key, value]) => { themeElement.style.setProperty(`--color-neutral-${key}`, value); }); Object.entries(brandMapping).forEach(([key, value]) => { themeElement.style.setProperty(`--color-brand-${key}`, value); }); Object.entries(ALPHA_MAPPING).forEach(([key, value]) => { themeElement.style.setProperty(`--color-alpha-white-${key}`, oklchToCSS(neutralOKLCH, value * 100)); themeElement.style.setProperty(`--color-alpha-black-${key}`, oklchToCSS(neutralOKLCH, value * 100)); }); // 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)); // Apply editor color backgrounds based on mode const editorColors = mode === "dark" ? EDITOR_COLORS_DARK : EDITOR_COLORS_LIGHT; Object.entries(editorColors).forEach(([color, value]) => { themeElement.style.setProperty(`--editor-colors-${color}-background`, value); }); } /** * Clear custom theme CSS variables * Removes base palette color overrides */ export function clearCustomTheme(): void { const themeElement = document?.querySelector("html"); if (!themeElement) return; // Clear neutral base palette colors const neutralKeys = [ "white", "100", "200", "300", "400", "500", "600", "700", "800", "900", "1000", "1100", "1200", "black", ]; neutralKeys.forEach((key) => { themeElement.style.removeProperty(`--color-neutral-${key}`); }); // Clear brand base palette colors const brandKeys = ["100", "200", "300", "400", "500", "600", "700", "800", "900", "1000", "1100", "1200", "default"]; brandKeys.forEach((key) => { themeElement.style.removeProperty(`--color-brand-${key}`); }); Object.keys(ALPHA_MAPPING).forEach((key) => { themeElement.style.removeProperty(`--color-alpha-white-${key}`); themeElement.style.removeProperty(`--color-alpha-black-${key}`); }); themeElement.style.removeProperty(`--text-color-on-color`); themeElement.style.removeProperty(`--text-color-icon-on-color`); // Clear editor color background overrides Object.keys(EDITOR_COLORS_LIGHT).forEach((color) => { themeElement.style.removeProperty(`--editor-colors-${color}-background`); }); }