Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/utils/src/theme/color-conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 9 additions & 1 deletion packages/utils/src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,6 +30,8 @@ export {
isGrayscale,
oklchToCSS,
parseOKLCH,
getRelativeLuminance,
getPerceptualBrightness,
// rgbToHex,
type OKLCH,
type RGB,
Expand Down
84 changes: 74 additions & 10 deletions packages/utils/src/theme/theme-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Dec 17, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 21 incorrectly states that the WCAG method is "Most accurate for accessibility" when comparing darkness detection methods. However, for determining whether a color is dark or light, OKLCH lightness is actually more perceptually uniform than WCAG relative luminance. WCAG relative luminance is specifically designed for calculating contrast ratios between two colors, not for determining if a single color is perceptually dark or light. Consider updating this comment to clarify that WCAG is "Standard for contrast ratio calculations" rather than "Most accurate for accessibility".

Copilot uses AI. Check for mistakes.
* - '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;
Comment on lines +34 to +35

Copilot AI Dec 17, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The darkness detection threshold was changed from 0.2 (old code) to 0.5 (new code with WCAG method). While 0.5 is more standard for WCAG luminance, this is a significant change that could affect many existing color combinations. Colors with lightness between 0.2 and 0.5 will now be treated as "dark" when they were previously treated as "light", potentially changing the contrast behavior throughout the application. Consider whether this threshold change was intentional and if existing themes need to be validated.

Copilot uses AI. Check for mistakes.
}
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
Comment on lines +54 to +56

Copilot AI Dec 17, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states "Returns white text for dark backgrounds, black text for light backgrounds" but doesn't mention that iconColor is inverted. This is misleading since the actual implementation returns the opposite color for icons. The documentation should either be updated to clarify this inversion, or (more likely) the iconColor logic should be fixed to match textColor.

Copilot uses AI. Check for mistakes.
*
* @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,

Copilot AI Dec 17, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iconColor logic is inverted. When the brand color background is dark, the icon should be white/light for proper contrast. When the brand color background is light, the icon should be black/dark.

Current logic returns:

  • Dark background → black icon (poor contrast ❌)
  • Light background → white icon (poor contrast ❌)

Expected logic should return:

  • Dark background → white icon (good contrast ✓)
  • Light background → black icon (good contrast ✓)

The textColor logic is correct, but iconColor should follow the same pattern since both need to contrast with the colored background.

Copilot uses AI. Check for mistakes.
};
}
Comment on lines +54 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix inverted icon color logic for proper contrast.

The icon color logic on line 75 is inverted. Icons should have the same color as text when displayed on a colored background to ensure proper contrast and accessibility.

Current behavior:

  • Dark background → white text, black icons ❌ (poor contrast)
  • Light background → black text, white icons ❌ (poor contrast)

Expected behavior:

  • Dark background → white text, white icons
  • Light background → black text, black icons

Apply this diff to fix the icon color:

   return {
     textColor: isDark ? white : black,
-    iconColor: isDark ? black : white,
+    iconColor: isDark ? white : black,
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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,
};
}
/**
* 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 ? white : black,
};
}
🤖 Prompt for AI Agents
In packages/utils/src/theme/theme-application.ts around lines 54 to 77, the icon
color selection is inverted relative to the text color causing poor contrast;
change the returned iconColor to match textColor by using the same conditional
(iconColor: isDark ? white : black) instead of the current inverted expression
so icons use white on dark backgrounds and black on light backgrounds.


/**
* Apply custom theme using 2-color palette system
* Generates full palettes from brand and neutral colors
Expand All @@ -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;
Expand All @@ -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));
}

/**
Expand Down
Loading