bb-plane-fork/packages/utils/src/theme/theme-application.ts

187 lines
6.6 KiB
TypeScript

/**
* 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`);
});
}