Fix cardholder name contrast on Expensify and company card images#91234
Fix cardholder name contrast on Expensify and company card images#91234roryabraham wants to merge 21 commits into
Conversation
Use fixed overlay colors for card art instead of theme.textLight so names stay visible in high contrast and on light company card feeds. Co-authored-by: Cursor <cursoragent@cursor.com>
Use WCAG luminance to select text color based on the card's actual background color. Card artwork never changes with app theme, so theme.textLight is wrong — it flips to dark in high-contrast mode, making text invisible on dark-background cards (#91137). Plaid and generic company cards have light backgrounds where the default white text is also invisible (#91139). New utilities in CardUtils: - getRelativeLuminance: WCAG 2.1 relative luminance from hex color - getCardHolderTextColor: returns colors.white or colors.productLight900 based on WCAG crossover luminance (0.179) - getCardFeedBackgroundColor: background hex for each known card feed from the SVG artwork walletCardHolder loses its color property; each callsite passes a computed {color} style override so Expensify Card pages always get white text and company card pages get a color appropriate to their feed's card art. Co-authored-by: Cursor <cursoragent@cursor.com>
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
Co-authored-by: Cursor <cursoragent@cursor.com>
Move getCardFeedBackgroundColor and getCardHolderTextColor from @styles/utils/card into @libs/CardUtils (which already had the unused colors import ready for them), and update all import sites to use @libs/CardUtils instead. Delete the now-empty @styles/utils/card module. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…contrast-91137-91139
Co-authored-by: Cursor <cursoragent@cursor.com>
…contrast-91137-91139
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the hardcoded CARD_FEED_BACKGROUND_COLORS in CardUtils.ts with a dedicated CardArtworkColors.ts file. Each entry is annotated with the SVG file it was sourced from, making the color-artwork relationship explicit rather than implicit. Renames CardUtils/index.ts back to CardUtils.ts since there is no longer a subdirectory needed. Adds a drift-detection describe block to CardUtilsTest.ts that parses each card SVG at test time and asserts that the committed constants match. If someone updates card artwork without updating CardArtworkColors.ts, CI will fail with a clear message pointing to the affected entry. Co-authored-by: Cursor <cursoragent@cursor.com>
…rgo to cspell Co-authored-by: Cursor <cursoragent@cursor.com>
This comment has been minimized.
This comment has been minimized.
|
A preview of your ExpensifyHelp changes have been deployed to https://a217d755.helpdot.pages.dev ⚡️ |
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
@mananjadhav Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
@ZhenjaHorbach Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
The previous threshold (0.179) is the crossover for pure black vs white. Our dark text is colors.productLight900 (#002E22, L≈0.021), not black, so the true crossover is higher (~0.222). This caused mid-luminance backgrounds like Citibank (#0281c4, L≈0.197) to get dark text even though white gives better contrast (4.25:1 vs 3.49:1). Replace the magic constant with a derived value computed from the actual dark color's luminance using the WCAG crossover formula, so the threshold stays correct if the design token changes. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
The video seems good to me. I am no engineer, but candidly the solution feels over-engineered. I thought for the white background cards, we're simply using our text-dark color (from light mode). And for the cards that have a BG, we're simply using white for the text? |
|
Also not an engineer but I kinda feel the same as Shawn. The screenshots look good though 🤷♂️ |
Removes ColorUtils.ts and all runtime WCAG luminance calculations.
CardArtworkColors.ts now exports CARD_FEED_COLORS — a single map of
feed key → {background, text} — where the text color is expressed
directly alongside the background color it was chosen to contrast with.
CardUtils exposes getCardFeedTextColor() and the existing
getCardFeedBackgroundColor() both delegate to a shared getCardFeedColors()
lookup, so call sites that only need the text color no longer need to
call two functions.
Co-authored-by: Cursor <cursoragent@cursor.com>
That is effectively what we do here - look at the color of the card, and choose the light or dark text color from our theme based on whichever results in higher contrast. However, when I started this implementation I assumed we could read the color directly from the SVG at runtime, so adding more colors would "just work". However, it turns out that by the time we reach runtime, the SVG is gone. And React Native doesn't yet support the window.getComputedStyle API to observe the card background color directly. So we ended up with a hardcoded list of card colors anyways, so we can just hardcode the text color as well. |
joekaufmanexpensify
left a comment
There was a problem hiding this comment.
Good for product
|
Cool, that works for me. The simpler the better IMO! |
| if (!cardFeed) { | ||
| return GENERIC_CARD_COLORS; | ||
| } | ||
| const feedKey = Object.keys(CARD_FEED_COLORS).find((key) => cardFeed.startsWith(key)); |
There was a problem hiding this comment.
I am not sure if it's a problem today, but in this scenario shorter key would match first. For example vcf and vcf.test, etc. Would that be aproblem?
There was a problem hiding this comment.
updated to sort by longest first
mananjadhav
left a comment
There was a problem hiding this comment.
Overall looks good. Left one comment.
Sorts keys by length descending before prefix-matching so that more specific keys (e.g. vcf.something) always win over shorter ones (e.g. vcf), regardless of insertion order in CARD_FEED_COLORS. Co-authored-by: Cursor <cursoragent@cursor.com>
Reviewer Checklist
Screenshots/VideosMacOS: Chrome / Safariweb-card-holder-name.mov |
mananjadhav
left a comment
There was a problem hiding this comment.
@roryabraham Can you please resolve the conflicts?
|
@youssef-lr Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
conflicts resolved |




Explanation of Change
Cardholder names on card images used
theme.textLightto determine text color. This breaks in two ways:textLightto dark (#002E22). The Expensify Card has a dark green (#002e22) background, so the name becomes invisible — same color as the card.textLightis white (#FFFFFF). Company cards on light feeds (e.g. Plaid, which has a white card face) render invisible white text on a white background.Fix: compute text color from the card's actual SVG background using WCAG 2.1 relative luminance. Card artwork never changes with the app theme, so
theme.textLightis semantically wrong here.New file
src/styles/utils/card.ts:getRelativeLuminance(hex)— WCAG 2.1 formulagetCardHolderTextColor(bgHex)— returnscolors.whiteorcolors.productLight900based on WCAG crossover luminance (0.179)getCardFeedBackgroundColor(feedName)— background hex for every known card SVG, derived directly from the SVG artworkwalletCardHolderloses itscolorproperty; each callsite passes a computed{color}override.Fixed Issues
$ #91137
$ #91139
Tests
Setup — inject a mock Expensify Card via the browser console (only needed if you don't have a real card assigned; skip if you already have one in your wallet):
https://dev.new.expensify.com:8082/Reproduce #91137 (Expensify Card, high contrast mode):
/settings/wallet/card#002E22on dark green#002e22background)Reproduce #91139 (company card with light background, normal mode):
Offline tests
N/A — display-only style change, no API calls involved.
QA Steps
Retest the linked issues:
Expensify Card - User name is not visible on the card image in high contrast mode #91137
Company cards - User name is white on white card image #91139
Verify that no errors appear in the JS console
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
MacOS: Chrome