diff --git a/web/helpers/color.helper.ts b/web/helpers/color.helper.ts index a1f915a1e..14b157a7a 100644 --- a/web/helpers/color.helper.ts +++ b/web/helpers/color.helper.ts @@ -17,3 +17,128 @@ export const rgbToHex = (rgb: TRgb): string => { return `#${hexR}${hexG}${hexB}`; }; + +/** + * Calculate relative luminance of a color according to WCAG + * @param {Object} rgb - RGB color object with r, g, b properties + * @returns {number} Relative luminance value + */ +export function getLuminance({ r, g, b }: { r: number; g: number; b: number }) { + // Convert RGB to sRGB + const sR = r / 255; + const sG = g / 255; + const sB = b / 255; + + // Convert sRGB to linear RGB with gamma correction + const R = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4); + const G = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4); + const B = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4); + + // Calculate luminance + return 0.2126 * R + 0.7152 * G + 0.0722 * B; +} + +/** + * Calculate contrast ratio between two colors + * @param {Object} rgb1 - First RGB color object + * @param {Object} rgb2 - Second RGB color object + * @returns {number} Contrast ratio between the colors + */ +export function getContrastRatio(rgb1: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number }) { + const luminance1 = getLuminance(rgb1); + const luminance2 = getLuminance(rgb2); + + const lighter = Math.max(luminance1, luminance2); + const darker = Math.min(luminance1, luminance2); + + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Lighten a color by a specified amount + * @param {Object} rgb - RGB color object + * @param {number} amount - Amount to lighten (0-1) + * @returns {Object} Lightened RGB color + */ +export function lightenColor(rgb: { r: number; g: number; b: number }, amount: number) { + return { + r: rgb.r + (255 - rgb.r) * amount, + g: rgb.g + (255 - rgb.g) * amount, + b: rgb.b + (255 - rgb.b) * amount, + }; +} + +/** + * Darken a color by a specified amount + * @param {Object} rgb - RGB color object + * @param {number} amount - Amount to darken (0-1) + * @returns {Object} Darkened RGB color + */ +export function darkenColor(rgb: { r: number; g: number; b: number }, amount: number) { + return { + r: rgb.r * (1 - amount), + g: rgb.g * (1 - amount), + b: rgb.b * (1 - amount), + }; +} + +/** + * Generate appropriate foreground and background colors based on input color + * @param {string} color - Input color in hex format + * @returns {Object} Object containing foreground and background colors in hex format + */ +export function generateIconColors(color: string) { + // Parse input color + const rgbColor = hexToRgb(color); + const luminance = getLuminance(rgbColor); + + // Initialize output colors + let foregroundColor = rgbColor; + + // Constants for color adjustment + const MIN_CONTRAST_RATIO = 3.0; // Minimum acceptable contrast ratio + + // For light colors, use as foreground and darken for background + if (luminance > 0.5) { + // Make sure the foreground color is dark enough for visibility + let adjustedForeground = foregroundColor; + const whiteContrast = getContrastRatio(foregroundColor, { r: 255, g: 255, b: 255 }); + + if (whiteContrast < MIN_CONTRAST_RATIO) { + // Darken the foreground color until it has enough contrast + let darkenAmount = 0.1; + while (darkenAmount <= 0.9) { + adjustedForeground = darkenColor(foregroundColor, darkenAmount); + if (getContrastRatio(adjustedForeground, { r: 255, g: 255, b: 255 }) >= MIN_CONTRAST_RATIO) { + break; + } + darkenAmount += 0.1; + } + foregroundColor = adjustedForeground; + } + } + // For dark colors, use as foreground and lighten for background + else { + // Make sure the foreground color is light enough for visibility + let adjustedForeground = foregroundColor; + const blackContrast = getContrastRatio(foregroundColor, { r: 0, g: 0, b: 0 }); + + if (blackContrast < MIN_CONTRAST_RATIO) { + // Lighten the foreground color until it has enough contrast + let lightenAmount = 0.1; + while (lightenAmount <= 0.9) { + adjustedForeground = lightenColor(foregroundColor, lightenAmount); + if (getContrastRatio(adjustedForeground, { r: 0, g: 0, b: 0 }) >= MIN_CONTRAST_RATIO) { + break; + } + lightenAmount += 0.1; + } + foregroundColor = adjustedForeground; + } + } + + return { + foreground: rgbToHex({ r: foregroundColor.r, g: foregroundColor.g, b: foregroundColor.b }), + background: `rgba(${foregroundColor.r}, ${foregroundColor.g}, ${foregroundColor.b}, 0.25)`, + }; +} diff --git a/web/helpers/theme.tsx b/web/helpers/theme.tsx deleted file mode 100644 index 9d0f18858..000000000 --- a/web/helpers/theme.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import chroma from "chroma-js"; - -interface HSLColor { - h: number; // hue (0-360) - s: number; // saturation (0-100) - l: number; // lightness (0-100) -} - -interface ColorAdjustmentOptions { - targetContrast?: number; // Minimum contrast ratio (4.5 for WCAG AAA, 3 for WCAG AA) - preserveHue?: boolean; // Whether to maintain the original hue - maxTries?: number; // Maximum attempts to find accessible colors -} - -// Helper function to ensure color contrast compliance -const ensureAccessibleColors = ( - foreground: string, - background: string, - options: ColorAdjustmentOptions = {} -): { foreground: string; background: string } => { - const { - targetContrast = 4.5, // WCAG AAA by default - preserveHue = true, - maxTries = 10, - } = options; - - try { - const fg = chroma(foreground); - const bg = chroma(background); - let contrast = chroma.contrast(fg, bg); - - // If contrast is already good, return original colors - if (contrast >= targetContrast) { - return { foreground, background }; - } - - // Adjust colors to meet contrast requirements - let adjustedFg = fg; - let adjustedBg = bg; - let tries = 0; - - while (contrast < targetContrast && tries < maxTries) { - if (fg.luminance() > bg.luminance()) { - // Make foreground lighter and background darker - adjustedFg = preserveHue ? fg.luminance(Math.min(fg.luminance() + 0.1, 0.9)) : fg.brighten(0.5); - adjustedBg = preserveHue ? bg.luminance(Math.max(bg.luminance() - 0.1, 0.1)) : bg.darken(0.5); - } else { - // Make foreground darker and background lighter - adjustedFg = preserveHue ? fg.luminance(Math.max(fg.luminance() - 0.1, 0.1)) : fg.darken(0.5); - adjustedBg = preserveHue ? bg.luminance(Math.min(bg.luminance() + 0.1, 0.9)) : bg.brighten(0.5); - } - - contrast = chroma.contrast(adjustedFg, adjustedBg); - tries++; - } - - return { - foreground: adjustedFg.css(), - background: adjustedBg.css(), - }; - } catch (error) { - console.warn("Color adjustment failed:", error); - return { foreground, background }; - } -}; - -// background color -export const createBackgroundColor = (hsl: HSLColor, resolvedTheme: "light" | "dark" = "light"): string => { - const baseColor = chroma.hsl(hsl.h, hsl.s / 100, hsl.l / 100); - - // Set base opacity according to theme - const baseOpacity = resolvedTheme === "dark" ? 0.25 : 0.15; - - // Create semi-transparent background - let backgroundColor = baseColor.alpha(baseOpacity); - - if (hsl.l > 90) { - backgroundColor = baseColor.darken(1).alpha(resolvedTheme === "dark" ? 0.3 : 0.2); - } else if (hsl.l > 70) { - backgroundColor = baseColor.darken(0.5).alpha(resolvedTheme === "dark" ? 0.28 : 0.18); - } else if (hsl.l < 30) { - backgroundColor = baseColor.brighten(0.5).alpha(resolvedTheme === "dark" ? 0.22 : 0.12); - } - - return backgroundColor.css(); -}; - -// foreground color -export const getIconColor = (hsl: HSLColor): string => { - const baseColor = chroma.hsl(hsl.h, hsl.s / 100, hsl.l / 100); - const backgroundColor = createBackgroundColor(hsl); - - // Adjust colors for accessibility - const { foreground } = ensureAccessibleColors(baseColor.css(), backgroundColor, { - targetContrast: 3, // WCAG AA for UI components - preserveHue: true, - }); - - return foreground; -}; diff --git a/web/package.json b/web/package.json index f1691d5c3..d5b3770ba 100644 --- a/web/package.json +++ b/web/package.json @@ -39,9 +39,7 @@ "@plane/utils": "*", "@popperjs/core": "^2.11.8", "@react-pdf/renderer": "^3.4.5", - "@types/chroma-js": "^3.1.1", "axios": "^1.8.3", - "chroma-js": "^3.1.2", "clsx": "^2.0.0", "cmdk": "^1.0.0", "comlink": "^4.4.1", diff --git a/yarn.lock b/yarn.lock index dad01095a..8d8ab65e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3162,11 +3162,6 @@ "@types/connect" "*" "@types/node" "*" -"@types/chroma-js@^3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@types/chroma-js/-/chroma-js-3.1.1.tgz#92cac57fb32d642ce156dbc4c052b5e3a3a25db1" - integrity sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA== - "@types/compression@^1.7.5": version "1.7.5" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz#0f80efef6eb031be57b12221c4ba6bc3577808f7" @@ -4766,11 +4761,6 @@ chownr@^1.1.1: resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== -chroma-js@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-3.1.2.tgz#cfb807045182228574eae5380587cdb830e985d6" - integrity sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg== - chromatic@^11.4.0: version "11.25.2" resolved "https://registry.npmjs.org/chromatic/-/chromatic-11.25.2.tgz#cb93dc1332d8f6b70d97a3ef126bc6d03429d396" @@ -10549,16 +10539,7 @@ streamx@^2.15.0, streamx@^2.21.0: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10651,14 +10632,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11876,16 +11850,7 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==