Skip to content

Commit 8c3ebf7

Browse files
authored
Merge pull request #91234 from Expensify/fix-card-holder-name-contrast-91137-91139
2 parents cab9799 + d0883fc commit 8c3ebf7

8 files changed

Lines changed: 216 additions & 6 deletions

File tree

cspell.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,10 @@
416416
"airside",
417417
"alrt",
418418
"americanexpress",
419+
"americanexpressfd",
419420
"americanexpressfdx",
421+
"bankofamerica",
422+
"Amina",
420423
"androiddebugkey",
421424
"androidx",
422425
"apksigner",
@@ -926,6 +929,7 @@
926929
"webcredentials",
927930
"webrtc",
928931
"welldone",
932+
"wellsfargo",
929933
"widgetkit",
930934
"wordprocessingml",
931935
"worklet",

src/components/CardPreview.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {View} from 'react-native';
44
import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
55
import useOnyx from '@hooks/useOnyx';
66
import useThemeStyles from '@hooks/useThemeStyles';
7+
import {getCardFeedTextColor} from '@libs/CardUtils';
78
import variables from '@styles/variables';
9+
import CONST from '@src/CONST';
810
import ONYXKEYS from '@src/ONYXKEYS';
911
import type IconAsset from '@src/types/utils/IconAsset';
1012
import ImageSVG from './ImageSVG';
@@ -48,7 +50,7 @@ function CardPreview({overlayImage, overlayContainerStyle}: CardPreviewProps) {
4850
width={variables.cardPreviewWidth}
4951
/>
5052
<Text
51-
style={styles.walletCardHolder}
53+
style={[styles.walletCardHolder, {color: getCardFeedTextColor(CONST.EXPENSIFY_CARD.BANK)}]}
5254
numberOfLines={1}
5355
ellipsizeMode="tail"
5456
>

src/libs/CardArtworkColors.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import colors from '@styles/theme/colors';
2+
3+
/**
4+
* Background fill and overlay text color for each card's large artwork SVG.
5+
*
6+
* Card artwork never changes with the app theme, so overlay text must be anchored
7+
* to the artwork's actual background rather than to theme.textLight/textDark which
8+
* can flip in high-contrast mode.
9+
*
10+
* `background` is the dominant fill of the corresponding SVG file (see inline comments).
11+
* `text` is the design-system color that satisfies WCAG 2.1 AA contrast against it.
12+
*
13+
* If you update a card's artwork, update the `background` entry here to match and
14+
* re-verify that `text` still provides sufficient contrast. The test in CardUtilsTest.ts
15+
* ('CardArtworkColors drift detection') will fail if `background` diverges from the SVG.
16+
*
17+
* To find the background color: open the SVG, locate the first <rect> or background
18+
* <path>, and read its fill — either the inline fill="..." attribute or the CSS class.
19+
*
20+
* Keys are the runtime string values of CONST.COMPANY_CARD.FEED_BANK_NAME and
21+
* CONST.EXPENSIFY_CARD.BANK. Lookup uses longest-prefix matching (see getCardFeedColors),
22+
* so partial feed-name suffixes (e.g. "vcf123") resolve correctly, and more specific keys
23+
* always take precedence over shorter ones.
24+
*/
25+
26+
type CardArtworkColors = {background: string; text: string};
27+
28+
/* eslint-disable @typescript-eslint/naming-convention */
29+
const CARD_FEED_COLORS: Record<string, CardArtworkColors> = {
30+
// assets/images/expensify-card.svg (.st0 fill)
31+
'Expensify Card': {background: '#002e22', text: colors.white},
32+
33+
// assets/images/companyCards/large/card-visa-large.svg (.st1 fill)
34+
vcf: {background: '#003c73', text: colors.white},
35+
36+
// assets/images/companyCards/large/card-mastercard-large.svg (.st4 background rect)
37+
cdf: {background: '#780505', text: colors.white},
38+
39+
// assets/images/companyCards/large/card-amex-large.svg (.st3 fill)
40+
// All Amex feed variants share the same artwork.
41+
gl1025: {background: '#016fd0', text: colors.white},
42+
gl1205: {background: '#016fd0', text: colors.white},
43+
'oauth.americanexpressfdx.com': {background: '#016fd0', text: colors.white},
44+
'americanexpressfd.us': {background: '#016fd0', text: colors.white},
45+
46+
// assets/images/companyCards/large/card-bofa-large.svg (.st1 fill)
47+
'oauth.bankofamerica.com': {background: '#e31837', text: colors.white},
48+
49+
// assets/images/companyCards/large/card-capital_one-large.svg (.st1 fill)
50+
'oauth.capitalone.com': {background: '#022247', text: colors.white},
51+
52+
// assets/images/companyCards/large/card-chase-large.svg (.st1 fill)
53+
'oauth.chase.com': {background: '#0f5ba7', text: colors.white},
54+
55+
// assets/images/companyCards/large/card-citi-large.svg (.st0 fill)
56+
'oauth.citibank.com': {background: '#0281c4', text: colors.white},
57+
58+
// assets/images/companyCards/large/card-wellsfargo-large.svg (.st2 fill)
59+
'oauth.wellsfargo.com': {background: '#dd1e25', text: colors.white},
60+
61+
// assets/images/companyCards/large/card-brex-large.svg (.st1 fill)
62+
'oauth.brex.com': {background: '#15181d', text: colors.white},
63+
64+
// assets/images/companyCards/large/card-stripe-large.svg (.st3 fill)
65+
stripe: {background: '#635bff', text: colors.white},
66+
67+
// assets/images/companyCards/large/card-plaid-large.svg (fill="#fff" on background rect)
68+
plaid: {background: '#ffffff', text: colors.productLight900},
69+
};
70+
/* eslint-enable @typescript-eslint/naming-convention */
71+
72+
/** Colors for generic-light-large.svg — used when no feed entry matches. */
73+
const GENERIC_CARD_COLORS: CardArtworkColors = {background: '#a2a9a3', text: colors.productLight900};
74+
75+
export type {CardArtworkColors};
76+
export {CARD_FEED_COLORS, GENERIC_CARD_COLORS};

src/libs/CardUtils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,36 @@ import type {Connections} from '@src/types/onyx/Policy';
4646
import {isEmptyObject} from '@src/types/utils/EmptyObject';
4747
import type IconAsset from '@src/types/utils/IconAsset';
4848
import {isBankAccountPartiallySetup} from './BankAccountUtils';
49+
import {CARD_FEED_COLORS, GENERIC_CARD_COLORS} from './CardArtworkColors';
4950
import DateUtils from './DateUtils';
5051
import {filterObject} from './ObjectUtils';
5152
import {arePersonalDetailsMissing, getDisplayNameOrDefault} from './PersonalDetailsUtils';
5253
import StringUtils from './StringUtils';
5354

55+
/**
56+
* Returns the artwork colors (background fill + WCAG-compliant overlay text) for a given feed.
57+
* Uses prefix-matching to handle feed names that include a suffix (e.g. "vcf123").
58+
*/
59+
function getCardFeedColors(cardFeed: string | undefined): {background: string; text: string} {
60+
if (!cardFeed) {
61+
return GENERIC_CARD_COLORS;
62+
}
63+
const feedKey = Object.keys(CARD_FEED_COLORS)
64+
.sort((a, b) => b.length - a.length)
65+
.find((key) => cardFeed.startsWith(key));
66+
return feedKey !== undefined ? CARD_FEED_COLORS[feedKey] : GENERIC_CARD_COLORS;
67+
}
68+
69+
/** Returns the background hex color for the card image shown for a given feed. */
70+
function getCardFeedBackgroundColor(cardFeed: string | undefined): string {
71+
return getCardFeedColors(cardFeed).background;
72+
}
73+
74+
/** Returns the WCAG-compliant overlay text color for a given feed's card artwork. */
75+
function getCardFeedTextColor(cardFeed: string | undefined): string {
76+
return getCardFeedColors(cardFeed).text;
77+
}
78+
5479
const COMPANY_CARD_FEED_ICON_NAMES = [
5580
'VisaCompanyCardDetailLarge',
5681
'AmexCardCompanyCardDetailLarge',
@@ -1816,6 +1841,9 @@ function resolveTransactionCardFields<T extends Transaction>(transactions: T[],
18161841

18171842
export {
18181843
getAssignedCardSortKey,
1844+
getCardFeedBackgroundColor,
1845+
getCardFeedColors,
1846+
getCardFeedTextColor,
18191847
getDefaultExpensifyCardLimitType,
18201848
isExpensifyCard,
18211849
isUkEuExpensifyCard,

src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@ import useThemeIllustrations from '@hooks/useThemeIllustrations';
2525
import useThemeStyles from '@hooks/useThemeStyles';
2626
import {isUsingStagingApi} from '@libs/ApiUtils';
2727
import navigateToCardTransactions from '@libs/CardNavigationUtils';
28-
import {getCardFeedIcon, getCompanyCardFeed, getCompanyFeeds, getDefaultCardName, getDomainOrWorkspaceAccountID, getPlaidInstitutionIconUrl, maskCardNumber} from '@libs/CardUtils';
28+
import {
29+
getCardFeedIcon,
30+
getCardFeedTextColor,
31+
getCompanyCardFeed,
32+
getCompanyFeeds,
33+
getDefaultCardName,
34+
getDomainOrWorkspaceAccountID,
35+
getPlaidInstitutionIconUrl,
36+
maskCardNumber,
37+
} from '@libs/CardUtils';
2938
import {getLatestErrorField} from '@libs/ErrorUtils';
3039
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
3140
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
@@ -158,7 +167,7 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
158167
/>
159168
)}
160169
<Text
161-
style={styles.walletCardHolder}
170+
style={[styles.walletCardHolder, {color: getCardFeedTextColor(feedName)}]}
162171
numberOfLines={1}
163172
ellipsizeMode="tail"
164173
>

src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import useTheme from '@hooks/useTheme';
3535
import useThemeStyles from '@hooks/useThemeStyles';
3636
import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy';
3737
import navigateToCardTransactions from '@libs/CardNavigationUtils';
38-
import {getAllCardsForWorkspace, getCardHintText, getTranslationKeyForLimitType, isCardFrozen, maskCard} from '@libs/CardUtils';
38+
import {getAllCardsForWorkspace, getCardFeedTextColor, getCardHintText, getTranslationKeyForLimitType, isCardFrozen, maskCard} from '@libs/CardUtils';
3939
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
4040
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
4141
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
@@ -236,7 +236,7 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail
236236
width={variables.cardPreviewWidth}
237237
/>
238238
<Text
239-
style={styles.walletCardHolder}
239+
style={[styles.walletCardHolder, {color: getCardFeedTextColor(CONST.EXPENSIFY_CARD.BANK)}]}
240240
numberOfLines={1}
241241
ellipsizeMode="tail"
242242
>

src/styles/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5099,7 +5099,6 @@ const staticStyles = (theme: ThemeColors) =>
50995099
left: 16,
51005100
bottom: 16,
51015101
width: variables.cardNameWidth,
5102-
color: theme.textLight,
51035102
fontSize: variables.fontSizeSmall,
51045103
lineHeight: variables.lineHeightLarge,
51055104
},

tests/unit/CardUtilsTest.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import {buildFeedKeysWithAssignedCards, isExpensifyCardUkEuSupportedSelector} from '@selectors/Card';
2+
import * as fs from 'fs';
23
import lodashSortBy from 'lodash/sortBy';
4+
import * as path from 'path';
35
import type {OnyxCollection} from 'react-native-onyx';
46
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
57
import type {FeedKeysWithAssignedCards} from '@hooks/useFeedKeysWithAssignedCards';
68
import type IllustrationsType from '@styles/theme/illustrations/types';
79
import CONST from '@src/CONST';
810
import type {CombinedCardFeeds} from '@src/hooks/useCardFeeds';
911
import IntlStore from '@src/languages/IntlStore';
12+
import type * as CardArtworkColorsModule from '@src/libs/CardArtworkColors';
1013
import {
1114
doesCardFeedExist,
1215
feedHasCards,
@@ -4362,3 +4365,92 @@ describe('getConnectionBankAccountsForReconciliation', () => {
43624365
expect(getConnectionBankAccountsForReconciliation(connections, CONST.POLICY.CONNECTIONS.NAME.QBO)).toEqual([]);
43634366
});
43644367
});
4368+
4369+
/**
4370+
* Drift-detection: verifies that the background colors in src/libs/CardArtworkColors.ts
4371+
* still match what the SVG artwork files actually contain. If a card SVG is updated without
4372+
* updating CardArtworkColors.ts, this test will fail with a clear message pointing to the
4373+
* affected entry.
4374+
*/
4375+
describe('CardArtworkColors drift detection', () => {
4376+
const ROOT = path.resolve(__dirname, '../..');
4377+
4378+
/** Inline copy of the parser from scripts/generateCardColors.ts */
4379+
function normalizeHex(color: string): string {
4380+
const h = color.trim().toLowerCase();
4381+
if (/^#[0-9a-f]{3}$/.test(h)) {
4382+
return `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
4383+
}
4384+
return h;
4385+
}
4386+
4387+
function extractBackgroundFill(svgContent: string): string | null {
4388+
const styleMap: Record<string, string> = {};
4389+
const styleMatch = svgContent.match(/<style>([\s\S]*?)<\/style>/);
4390+
if (styleMatch) {
4391+
for (const m of styleMatch[1].matchAll(/\.([\w-]+)\s*\{([^}]+)\}/g)) {
4392+
const fillMatch = m[2].match(/\bfill:\s*([#\w(),.%]+)/);
4393+
if (fillMatch) {
4394+
styleMap[m[1]] = fillMatch[1].trim();
4395+
}
4396+
}
4397+
}
4398+
const withoutDefs = svgContent.replaceAll(/<defs>[\s\S]*?<\/defs>/g, '');
4399+
const tagRegex = /<(?:rect|path)\s[^>]*?\/?>/g;
4400+
let match: RegExpExecArray | null;
4401+
// eslint-disable-next-line no-cond-assign
4402+
while ((match = tagRegex.exec(withoutDefs)) !== null) {
4403+
const tag = match[0];
4404+
const fillAttr = tag.match(/\bfill="([^"]+)"/)?.[1];
4405+
const classAttr = tag.match(/\bclass="([^"]+)"/)?.[1];
4406+
if (fillAttr && fillAttr !== 'none') {
4407+
return normalizeHex(fillAttr);
4408+
}
4409+
if (classAttr) {
4410+
for (const cn of classAttr.split(/\s+/)) {
4411+
const resolved = styleMap[cn];
4412+
if (resolved && resolved !== 'none') {
4413+
return normalizeHex(resolved);
4414+
}
4415+
}
4416+
}
4417+
}
4418+
return null;
4419+
}
4420+
4421+
const FEED_ARTWORK: Array<{keys: string[]; svgPath: string}> = [
4422+
{keys: ['Expensify Card'], svgPath: 'assets/images/expensify-card.svg'},
4423+
{keys: ['vcf'], svgPath: 'assets/images/companyCards/large/card-visa-large.svg'},
4424+
{keys: ['cdf'], svgPath: 'assets/images/companyCards/large/card-mastercard-large.svg'},
4425+
{
4426+
keys: ['gl1025', 'gl1205', 'oauth.americanexpressfdx.com', 'americanexpressfd.us'],
4427+
svgPath: 'assets/images/companyCards/large/card-amex-large.svg',
4428+
},
4429+
{keys: ['oauth.bankofamerica.com'], svgPath: 'assets/images/companyCards/large/card-bofa-large.svg'},
4430+
{keys: ['oauth.capitalone.com'], svgPath: 'assets/images/companyCards/large/card-capital_one-large.svg'},
4431+
{keys: ['oauth.chase.com'], svgPath: 'assets/images/companyCards/large/card-chase-large.svg'},
4432+
{keys: ['oauth.citibank.com'], svgPath: 'assets/images/companyCards/large/card-citi-large.svg'},
4433+
{keys: ['oauth.wellsfargo.com'], svgPath: 'assets/images/companyCards/large/card-wellsfargo-large.svg'},
4434+
{keys: ['oauth.brex.com'], svgPath: 'assets/images/companyCards/large/card-brex-large.svg'},
4435+
{keys: ['stripe'], svgPath: 'assets/images/companyCards/large/card-stripe-large.svg'},
4436+
{keys: ['plaid'], svgPath: 'assets/images/companyCards/large/card-plaid-large.svg'},
4437+
];
4438+
4439+
const GENERIC_SVG_PATH = 'assets/images/companyCards/large/generic-light-large.svg';
4440+
4441+
it('GENERIC_CARD_COLORS.background matches generic-light-large.svg', () => {
4442+
const {GENERIC_CARD_COLORS} = jest.requireActual<typeof CardArtworkColorsModule>('@src/libs/CardArtworkColors');
4443+
const svg = fs.readFileSync(path.join(ROOT, GENERIC_SVG_PATH), 'utf-8');
4444+
const actual = extractBackgroundFill(svg);
4445+
expect(actual).not.toBeNull();
4446+
expect(GENERIC_CARD_COLORS.background).toBe(actual);
4447+
});
4448+
4449+
it.each(FEED_ARTWORK.flatMap(({keys, svgPath}) => keys.map((key) => ({key, svgPath}))))('CARD_FEED_COLORS[$key].background matches $svgPath', ({key, svgPath}) => {
4450+
const {CARD_FEED_COLORS} = jest.requireActual<typeof CardArtworkColorsModule>('@src/libs/CardArtworkColors');
4451+
const svg = fs.readFileSync(path.join(ROOT, svgPath), 'utf-8');
4452+
const actual = extractBackgroundFill(svg);
4453+
expect(actual).not.toBeNull();
4454+
expect(CARD_FEED_COLORS[key].background).toBe(actual);
4455+
});
4456+
});

0 commit comments

Comments
 (0)