diff --git a/packages/ui/src/color-picker/color-picker.tsx b/packages/ui/src/color-picker/color-picker.tsx new file mode 100644 index 00000000000..73616a992d7 --- /dev/null +++ b/packages/ui/src/color-picker/color-picker.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +interface ColorPickerProps { + value: string; + onChange: (color: string) => void; + className?: string; +} + +export const ColorPicker: React.FC = (props) => { + const { value, onChange, className = "" } = props; + // refs + const inputRef = React.useRef(null); + + // handlers + const handleOnClick = () => { + inputRef.current?.click(); + }; + + return ( +
+
+ ); +}; diff --git a/packages/ui/src/color-picker/index.ts b/packages/ui/src/color-picker/index.ts new file mode 100644 index 00000000000..6bad1d67ec9 --- /dev/null +++ b/packages/ui/src/color-picker/index.ts @@ -0,0 +1 @@ +export * from "./color-picker"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 19edba780c8..40d88520423 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -31,3 +31,4 @@ export * from "./card"; export * from "./tag"; export * from "./tabs"; export * from "./calendar"; +export * from "./color-picker"; diff --git a/web/helpers/theme.tsx b/web/helpers/theme.tsx new file mode 100644 index 00000000000..9d0f1885839 --- /dev/null +++ b/web/helpers/theme.tsx @@ -0,0 +1,100 @@ +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 ee31d357273..5d931b8de46 100644 --- a/web/package.json +++ b/web/package.json @@ -39,7 +39,9 @@ "@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 52a74dccbab..131d0bcc1ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3262,6 +3262,11 @@ "@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" @@ -4861,6 +4866,11 @@ 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" @@ -10722,16 +10732,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== @@ -10824,14 +10825,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== @@ -12076,16 +12070,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==