diff --git a/package.json b/package.json index 6372dd5f5b..3df41dab33 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "lodash.isequal": "^4.5.0", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", + "mix-css-color": "^0.2.0", "multiformats": "^13.1.0", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", @@ -200,6 +201,7 @@ "tippy.js": "^6.3.7", "tlds": "^1.234.0", "tldts": "^6.1.46", + "wcag-contrast": "^3.0.0", "zeego": "^1.6.2", "zod": "^3.20.2" }, @@ -227,6 +229,7 @@ "@types/react-dom": "^18.2.18", "@types/react-responsive": "^8.0.5", "@types/react-test-renderer": "^17.0.1", + "@types/wcag-contrast": "^3.0.3", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "babel-jest": "^29.7.0", diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 6d7e50e480..49fa013421 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -8,13 +8,14 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from '#/lib/routes/types' import {toShortUrl} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' -import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf' +import {atoms as a, flatten, native, TextStyleProp, web} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {useInteractionState} from '#/components/hooks/useInteractionState' import {InlineLinkText, LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {TagMenu, useTagMenuControl} from '#/components/TagMenu' import {Text, TextProps} from '#/components/Typography' +import {useDynamicColor} from './hooks/useDynamicColor' const WORD_WRAP = {wordWrap: 1} @@ -29,6 +30,7 @@ export type RichTextProps = TextStyleProp & onLinkPress?: LinkProps['onPress'] interactiveStyle?: TextStyle emojiMultiplier?: number + dynamicColor?: boolean } export function RichText({ @@ -43,6 +45,7 @@ export function RichText({ onLinkPress, interactiveStyle, emojiMultiplier = 1.85, + dynamicColor = false, }: RichTextProps) { const richText = React.useMemo( () => @@ -50,10 +53,13 @@ export function RichText({ [value], ) + const interactiveColor = useDynamicColor({enabled: dynamicColor}) + const flattenedStyle = flatten(style) const plainStyles = [a.leading_snug, flattenedStyle] const interactiveStyles = [ a.leading_snug, + interactiveColor, flatten(interactiveStyle), flattenedStyle, ] @@ -182,7 +188,6 @@ function RichTextTag({ selectable?: boolean authorHandle?: string } & TextStyleProp) { - const t = useTheme() const {_} = useLingui() const control = useTagMenuControl() const { @@ -224,22 +229,17 @@ function RichTextTag({ {...web({ onMouseEnter: onHoverIn, onMouseLeave: onHoverOut, + onFocus: onFocus, + onBlur: onBlur, })} - // @ts-ignore - onFocus={onFocus} - onBlur={onBlur} style={[ - web({ - cursor: 'pointer', - }), - {color: t.palette.primary_500}, - (hovered || focused) && { - ...web({ + web({cursor: 'pointer'}), + web( + (hovered || focused) && { outline: 0, textDecorationLine: 'underline', - textDecorationColor: t.palette.primary_500, - }), - }, + }, + ), style, ]}> {text} diff --git a/src/components/hooks/useDynamicColor.tsx b/src/components/hooks/useDynamicColor.tsx new file mode 100644 index 0000000000..d88bb16da7 --- /dev/null +++ b/src/components/hooks/useDynamicColor.tsx @@ -0,0 +1,61 @@ +import {useMemo} from 'react' +import {StyleProp, TextStyle} from 'react-native' +import mixColors from 'mix-css-color' +import {rgb as compareContrast} from 'wcag-contrast' + +import {useTheme} from '#/alf' + +export function useDynamicColor({enabled} = {enabled: false}) { + const t = useTheme() + + const color = enabled + ? getDynamicColor(t.name !== 'light', t.palette.primary_500) + : t.palette.primary_500 + + return useMemo( + () => + ({ + color, + textDecorationColor: color, + } satisfies StyleProp), + [color], + ) +} + +// the goal is to return a blue color that should be an acceptable constrast with +// the background color. the default blue is rgb(16, 131, 254) +// we want to color mix it with the background color +// and flip to whiteish if it's going to be unacceptable low contrast +const BASE_COLOR_LIGHT = 'rgb(16, 131, 254)' +const BASE_COLOR_DARK = 'rgb(32, 139, 254)' +function getDynamicColor(dark: boolean, themeColor: string) { + const baseColor = dark ? BASE_COLOR_DARK : BASE_COLOR_LIGHT + const blendedColor = mixColors(baseColor, themeColor, 75) + debugColorContrast( + blendedColor, + // for debug, we simulate the linear gradient on the profile + mixColors(themeColor, '#ffffff', 30), + ) + let [h, s, l] = blendedColor.hsla + return `hsl(${h}, ${s}%, ${l * 1.1}%)` +} + +function debugColorContrast( + foreground: ReturnType, + background: ReturnType, +) { + const score = getContrastScore(foreground, background) + console.log( + `%c ${score > 4.5 ? 'PASS' : 'FAIL'}: ${Math.trunc(score * 10) / 10} `, + `color: ${foreground.hex}; background: ${background.hex}`, + ) +} + +function getContrastScore( + foreground: ReturnType, + background: ReturnType, +) { + const [r1, g1, b1] = foreground.rgba + const [r2, g2, b2] = background.rgba + return compareContrast([r1, g1, b1], [r2, g2, b2]) +} diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index b905b9e562..d1b968d217 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -264,6 +264,7 @@ let ProfileHeaderLabeler = ({ value={descriptionRT} enableTags authorHandle={profile.handle} + dynamicColor /> ) : undefined} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 97a125b8e8..31cc48ab99 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -257,6 +257,7 @@ let ProfileHeaderStandard = ({ value={descriptionRT} enableTags authorHandle={profile.handle} + dynamicColor /> ) : undefined} diff --git a/yarn.lock b/yarn.lock index 474ef2f8ba..019809dac8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7204,6 +7204,11 @@ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== +"@types/wcag-contrast@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/wcag-contrast/-/wcag-contrast-3.0.3.tgz#0b0339ab210b859eeee555b3fef58d293216905c" + integrity sha512-oprevfwJSLfpQK4KaWsRKJuNoebV76+xhmbXiWJGy+FkS34LpCgCMNIwRXWTb8xmmSxUE2ycFOYE7uyRVRm3LA== + "@types/ws@^8.5.5": version "8.5.5" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb" @@ -8871,7 +8876,7 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@^1.0.0, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -10314,6 +10319,11 @@ eslint@^8.19.0: strip-ansi "^6.0.1" text-table "^0.2.0" +esm@^3.0.84: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -11707,6 +11717,11 @@ hermes-profile-transformer@^0.0.6: dependencies: source-map "^0.7.3" +hex-rgb@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/hex-rgb/-/hex-rgb-4.3.0.tgz#af5e974e83bb2fefe44d55182b004ec818c07776" + integrity sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw== + history@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" @@ -14296,6 +14311,14 @@ minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" +mix-css-color@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/mix-css-color/-/mix-css-color-0.2.0.tgz#413a2346effcb36a815b1d09bcbac12bcf3aac63" + integrity sha512-mZugANySFPE21tjELbQddhC6HAZNzqp7gDxmW8fJFURSWtJ0nuXU26dyrb/1AR6ZYxdEAtW2bbWT9QnRtI6Jzg== + dependencies: + parse-css-color "^0.1.2" + pure-color "^1.3.0" + mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" @@ -14931,6 +14954,14 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-css-color@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/parse-css-color/-/parse-css-color-0.1.2.tgz#9d178f2d2b5eb7245c0deb22ea35046b79ff3f83" + integrity sha512-z7v/tf0edGsnlm9VONQtH+u/YVrdUqZXrSBzqM13scef8Abl2VyZfYsZaJoyb/AyY4SIxtoJChSQ4MURHfY3Sg== + dependencies: + color-name "^1.1.4" + hex-rgb "^4.1.0" + parse-json@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" @@ -15934,6 +15965,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +pure-color@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" + integrity sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA== + pure-rand@^6.0.0: version "6.0.2" resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.2.tgz#a9c2ddcae9b68d736a8163036f088a2781c8b306" @@ -16655,6 +16691,13 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== +relative-luminance@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/relative-luminance/-/relative-luminance-2.0.1.tgz#2babddf3a5a59673227d6f02e0f68e13989e3d13" + integrity sha512-wFuITNthJilFPwkK7gNJcULxXBcfFZvZORsvdvxeOdO44wCeZnuQkf3nFFzOR/dpJNxYsdRZJLsepWbyKhnMww== + dependencies: + esm "^3.0.84" + remove-trailing-slash@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz#be2285a59f39c74d1bce4f825950061915e3780d" @@ -18763,6 +18806,13 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +wcag-contrast@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/wcag-contrast/-/wcag-contrast-3.0.0.tgz#51c2df43f15162993006454b3362844205889efb" + integrity sha512-RWbpg/S7FOXDCwqC2oFhN/vh8dHzj0OS6dpyOSDHyQFSmqmR+lAUStV/ziTT1GzDqL9wol+nZQB4vCi5yEak+w== + dependencies: + relative-luminance "^2.0.0" + wcwidth@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"