From 37f429797216bef94dee02501cdb3381109a502b Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 12 Mar 2026 23:08:05 +1100 Subject: [PATCH] feat: add avatar border color customization and improve text contrast across user profile components --- src/app/components/presence/Presence.tsx | 5 +- .../components/user-profile/CreatorChip.tsx | 8 ++-- src/app/components/user-profile/PowerChip.tsx | 7 ++- src/app/components/user-profile/UserChips.tsx | 25 ++++++---- src/app/components/user-profile/UserHero.tsx | 14 ++++-- .../user-profile/UserRoomProfile.tsx | 26 +++++----- src/app/features/settings/account/Profile.tsx | 43 ++++++++++------- src/app/utils/common.ts | 47 +++++++++++++++++++ 8 files changed, 127 insertions(+), 48 deletions(-) diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 108852f..b5a7222 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -62,14 +62,15 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { type AvatarPresenceProps = { badge: ReactNode; variant?: ContainerColor; + badgeBackgroundColor?: string; }; export const AvatarPresence = as<'div', AvatarPresenceProps>( - ({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => ( + ({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => ( {badge && (
{badge}
diff --git a/src/app/components/user-profile/CreatorChip.tsx b/src/app/components/user-profile/CreatorChip.tsx index be39254..191c353 100644 --- a/src/app/components/user-profile/CreatorChip.tsx +++ b/src/app/components/user-profile/CreatorChip.tsx @@ -1,4 +1,4 @@ -import { Chip, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import { Chip, color, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; import React, { MouseEventHandler, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -80,6 +80,7 @@ export function CreatorChip({ borderColor }: { borderColor?: string }) { > {tag.name} diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index 6ff195e..59b2cd7 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -2,6 +2,7 @@ import { Box, Button, Chip, + color, config, Dialog, Header, @@ -319,6 +320,7 @@ export function PowerChip({ userId, borderColor }: { userId: string; borderColor > {tag.name} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 2d81067..029e971 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -8,6 +8,7 @@ import { PopOut, Menu, MenuItem, + color, config, Text, Line, @@ -125,6 +126,7 @@ export function ServerChip({ server, borderColor }: { server: string; borderColo > {server} @@ -212,6 +215,7 @@ export function ShareChip({ userId, borderColor }: { userId: string; borderColor > Share @@ -413,6 +418,7 @@ export function MutualRoomsChip({ userId, borderColor }: { userId: string; borde > } disabled={ @@ -423,8 +429,9 @@ export function MutualRoomsChip({ userId, borderColor }: { userId: string; borde style={borderColor ? { backgroundColor: borderColor, border: 'none', - color: getContrastingTextColor(borderColor) - } : undefined} + color: getContrastingTextColor(borderColor), + opacity: 1 + } : { backgroundColor: color.Surface.Container, opacity: 1 }} > {mutualRoomsState.status === AsyncStatus.Success && @@ -521,14 +528,16 @@ export function OptionsChip({ userId, borderColor }: { userId: string; borderCol > {ignoring ? ( diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index c18e44b..62fbc1a 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -60,6 +60,8 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
} @@ -69,10 +71,14 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined} className={css.UserHeroAvatar} size="500" - style={profileStyle.avatarBorderColor ? { - outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`, - outlineOffset: toRem(-1), - } : undefined} + style={{ + width: toRem(72), + height: toRem(72), + ...(profileStyle.avatarBorderColor ? { + outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`, + outlineOffset: toRem(-1), + } : {}), + }} > {getMemberDisplayName(room, userId)} @@ -120,7 +124,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {userId} @@ -129,8 +133,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {presence?.status && ( {presence.status} @@ -150,9 +154,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {/* Chips Row */} - {server && } - - {creator ? : } + {server && } + + {creator ? : } {/* Message Button */} @@ -172,8 +176,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {/* Additional Chips if needed */} {userId !== myUserId && ( - - + + )} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 307eb7a..7952386 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -37,7 +37,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl'; -import { nameInitials, getContrastingTextColor } from '../../../utils/common'; +import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { useObjectURL } from '../../../hooks/useObjectURL'; @@ -563,6 +563,11 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject @@ -727,6 +733,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject {profile.displayName || getMxIdLocalPart(userId) || userId} @@ -937,7 +945,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject {userId} @@ -947,8 +955,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject setHoveredArea('status')} onMouseLeave={() => setHoveredArea(null)} style={{ - backgroundColor: previewBorderColor || color.Surface.Container, - border: previewBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, + backgroundColor: previewPillBgColor || color.Surface.Container, + border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, padding: `${toRem(6)} ${toRem(10)}`, borderRadius: toRem(16), maxWidth: toRem(250), @@ -998,16 +1006,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject {presence?.status || 'Click to set a custom status...'} {presence?.status && hoveredArea === 'status' && ( <> - + { @@ -1038,8 +1045,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject ruv.wtf @@ -1055,8 +1062,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject Share @@ -1072,8 +1079,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject Admin diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index 87b00b7..90bc219 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -139,6 +139,32 @@ export const splitWithSpace = (content: string): string[] => { return trimmedContent.split(' '); }; +/** + * Strip alpha channel from a color, returning a fully opaque hex color + * Supports: #RGB, #RGBA, #RRGGBB, #RRGGBBAA, rgb(), rgba() + */ +export const stripAlphaFromColor = (color: string): string => { + // Handle rgba() format + const rgbaMatch = color.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1], 10).toString(16).padStart(2, '0'); + const g = parseInt(rgbaMatch[2], 10).toString(16).padStart(2, '0'); + const b = parseInt(rgbaMatch[3], 10).toString(16).padStart(2, '0'); + return `#${r}${g}${b}`.toUpperCase(); + } + + // Handle hex format + let hex = color.replace('#', ''); + + // Convert shorthand #RGB or #RGBA to full form + if (hex.length === 3 || hex.length === 4) { + hex = hex.split('').map(c => c + c).join(''); + } + + // Take only the first 6 characters (RGB), ignore alpha if present + return `#${hex.substring(0, 6).toUpperCase()}`; +}; + /** * Calculate contrasting text color (black or white) for a given background color * @param bgColor - Hex color string (e.g., '#RRGGBB' or '#RRGGBBAA') @@ -159,3 +185,24 @@ export const getContrastingTextColor = (bgColor: string): string => { // Return black for light backgrounds, white for dark backgrounds return luminance > 0.5 ? '#000000' : '#FFFFFF'; }; + +/** + * Calculate shadow color for text - white shadow only for very dark text + * @param textColor - Hex color string (e.g., '#RRGGBB') + * @returns '#FFFFFF' only for very dark text (close to black), '#000000' otherwise + */ +export const getTextShadowColor = (textColor: string): string => { + // Remove # if present + const hex = textColor.replace('#', ''); + + // Parse RGB values (ignore alpha if present) + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + // Calculate relative luminance using WCAG formula + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // Return white shadow only for very dark text (luminance < 0.2) + return luminance < 0.2 ? '#FFFFFF' : '#000000'; +};