diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index ea71a9d5..7037b5a9 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -41,6 +41,7 @@ import { CryptoCurrency, DEFAULT_DONATION_RATE, DEFAULT_MINIMUM_DONATION_AMOUNT, + darkMode, } from '../../util'; import AltpaymentWidget from './AltpaymentWidget' import { @@ -322,6 +323,7 @@ export const Widget: React.FunctionComponent = props => { const [isAboveMinimumAltpaymentAmount, setIsAboveMinimumAltpaymentAmount] = useState(null) const theme = useTheme(props.theme, isValidXecAddress(to)) + const isDarkMode = useMemo(() => darkMode(theme.palette.tertiary), [theme.palette.tertiary]) const [thisAmount, setThisAmount] = useState(props.amount) const [thisCurrencyObject, setThisCurrencyObject] = useState(props.currencyObject) @@ -353,13 +355,13 @@ export const Widget: React.FunctionComponent = props => { return { root: { minWidth: '240px', - background: '#f5f5f7', + background: isDarkMode ? '#2a2a2a' : '#f5f5f7', position: 'relative', overflow: 'hidden', }, qrCode: { - background: '#fff', - border: '1px solid #eee', + background: isDarkMode ? '#1a1a1a' : '#fff', + border: isDarkMode ? '1px solid #333' : '1px solid #eee', borderRadius: '4px', outline: 'none', lineHeight: 0, @@ -381,18 +383,19 @@ export const Widget: React.FunctionComponent = props => { }, copyTextContainer: { display: base.loading ? 'none' : 'block', - background: '#ffffffcc', + background: isDarkMode ? '#1a1a1acc' : '#ffffffcc', padding: '0 0.15rem 0.15rem 0', }, copyText: { lineHeight: '1.2em', fontSize: '0.7em', color: base.theme.palette.tertiary, - textShadow: - '#fff -2px 0 1px, #fff 0 -2px 1px, #fff 0 2px 1px, #fff 2px 0 1px', + textShadow: isDarkMode + ? '#000 -2px 0 1px, #000 0 -2px 1px, #000 0 2px 1px, #000 2px 0 1px' + : '#fff -2px 0 1px, #fff 0 -2px 1px, #fff 0 2px 1px, #fff 2px 0 1px', '&:disabled span': { filter: 'blur(2px)', - color: 'rgba(0, 0, 0, 0.5)', + color: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)', }, }, text: { @@ -404,7 +407,7 @@ export const Widget: React.FunctionComponent = props => { }, footer: { fontSize: '0.6rem', - color: '#a8a8a8', + color: isDarkMode ? '#888888' : '#a8a8a8', fontWeight: 'normal', userSelect: 'none', display: 'flex', @@ -424,7 +427,8 @@ export const Widget: React.FunctionComponent = props => { cursor: 'pointer', padding: '6px 12px', marginTop: '20px', - background: '#e9e9e9', + background: isDarkMode ? '#444444' : '#e9e9e9', + color: isDarkMode ? '#ffffff' : 'inherit', borderRadius: '5px', transition: 'all ease-in-out 200ms', opacity: 0, @@ -477,7 +481,7 @@ export const Widget: React.FunctionComponent = props => { animationDelay: '0.4s', }, } - }, [success, qrLoading, theme, recentlyCopied, copied]) + }, [success, qrLoading, theme, recentlyCopied, copied, isDarkMode]) const bchSvg = useMemo((): string => { const color = theme.palette.logo ?? theme.palette.primary @@ -1031,6 +1035,7 @@ export const Widget: React.FunctionComponent = props => { size={300} level="H" value={url} + bgColor={isDarkMode ? '#1a1a1a' : '#ffffff'} fgColor={theme.palette.tertiary as unknown as string} imageSettings={{ src: success ? checkSvg : isValidCashAddress(to) ? bchSvg : xecSvg, @@ -1059,7 +1064,7 @@ export const Widget: React.FunctionComponent = props => { @@ -1342,7 +1347,7 @@ export const Widget: React.FunctionComponent = props => { width: '13px', height: '13px', fill: donationEnabled ? '#f44336' : 'none', - stroke: donationEnabled ? '#f44336' : '#5c5c5c', + stroke: donationEnabled ? '#f44336' : (isDarkMode ? '#a0a0a0' : '#5c5c5c'), strokeWidth: donationEnabled ? 0 : 1.5, transition: 'all 0.2s ease-in-out', '&:hover': { @@ -1380,7 +1385,7 @@ export const Widget: React.FunctionComponent = props => { padding: '0px 2px 0px 4px', fontSize: '0.6rem', textAlign: 'left', - color: '#5c5c5c', + color: isDarkMode ? '#b0b0b0' : '#5c5c5c', lineHeight: '1.5em', }, '& fieldset': { @@ -1393,7 +1398,7 @@ export const Widget: React.FunctionComponent = props => { component="span" sx={{ fontSize: '0.6rem', - color: '#5c5c5c', + color: isDarkMode ? '#b0b0b0' : '#5c5c5c', flexShrink: 0, marginLeft: '2px', }} diff --git a/react/lib/tests/components/Widget.test.tsx b/react/lib/tests/components/Widget.test.tsx index 7c3da136..a9dcfd90 100644 --- a/react/lib/tests/components/Widget.test.tsx +++ b/react/lib/tests/components/Widget.test.tsx @@ -6,7 +6,7 @@ import Widget from '../../components/Widget/Widget' jest.mock('copy-to-clipboard', () => jest.fn()) jest.mock('../../util', () => ({ - __esModule: true, + ...jest.requireActual('../../util'), // network / balance getAddressBalance: jest.fn().mockResolvedValue(0), // address / currency helpers diff --git a/react/lib/util/color.ts b/react/lib/util/color.ts new file mode 100644 index 00000000..6a173cf0 --- /dev/null +++ b/react/lib/util/color.ts @@ -0,0 +1,77 @@ +/** + * Converts a hex color to RGB values + */ +export const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { + // Remove # if present + const cleanHex = hex.replace(/^#/, ''); + + // Handle 3-character hex codes + const fullHex = cleanHex.length === 3 + ? cleanHex.split('').map(c => c + c).join('') + : cleanHex; + + const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +}; + +/** + * Normalizes any CSS color value to a hex code using the browser's canvas API + * Handles hex codes, named colors (e.g., "navy"), rgb(), hsl(), etc. + */ +export const normalizeColorToHex = (color: string): string | null => { + if (!color) return null; + + const trimmed = color.trim(); + if (!trimmed) return null; + + // Use canvas to parse any CSS color value + const ctx = document.createElement('canvas').getContext('2d'); + if (!ctx) return null; + + ctx.fillStyle = trimmed; + // The browser normalizes the color to a hex string (or rgb() for transparent colors) + return ctx.fillStyle; +}; + +/** + * Calculates the relative luminance of a color + * Based on WCAG 2.0 formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef + * Returns a value between 0 (black) and 1 (white) + */ +export const getLuminance = (color: string): number => { + const hex = normalizeColorToHex(color); + if (!hex) return 0; + + const rgb = hexToRgb(hex); + if (!rgb) return 0; + + const { r, g, b } = rgb; + + // Convert to sRGB + const sR = r / 255; + const sG = g / 255; + const sB = b / 255; + + // Apply 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; +}; + +/** + * Determines if dark mode should be used based on the color + * Dark mode is enabled when the color is light (luminance > 0.5) + * This threshold can be adjusted based on needs + */ +export const darkMode = (color: string, threshold = 0.5): boolean => { + return getLuminance(color) > threshold; +}; diff --git a/react/lib/util/index.ts b/react/lib/util/index.ts index 5287f47e..06f0b895 100644 --- a/react/lib/util/index.ts +++ b/react/lib/util/index.ts @@ -1,6 +1,7 @@ export * from './address'; export * from './api-client'; export * from './cashtab'; +export * from './color'; export * from './constants'; export * from './format'; export * from './opReturn';