feat: add avatar border color customization and improve text contrast across user profile components

This commit is contained in:
2026-03-12 23:08:05 +11:00
parent 67f3ae4d34
commit 37f4297972
8 changed files with 127 additions and 48 deletions

View File

@@ -62,14 +62,15 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
type AvatarPresenceProps = { type AvatarPresenceProps = {
badge: ReactNode; badge: ReactNode;
variant?: ContainerColor; variant?: ContainerColor;
badgeBackgroundColor?: string;
}; };
export const AvatarPresence = as<'div', AvatarPresenceProps>( export const AvatarPresence = as<'div', AvatarPresenceProps>(
({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => ( ({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}> <Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
{badge && ( {badge && (
<div <div
className={css.AvatarPresenceBadge} className={css.AvatarPresenceBadge}
style={{ backgroundColor: color[variant].Container }} style={{ backgroundColor: badgeBackgroundColor || color[variant].Container }}
> >
{badge} {badge}
</div> </div>

View File

@@ -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 React, { MouseEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -80,6 +80,7 @@ export function CreatorChip({ borderColor }: { borderColor?: string }) {
> >
<Chip <Chip
variant="Success" variant="Success"
fill={borderColor ? 'Soft' : 'None'}
outlined outlined
radii="Pill" radii="Pill"
before={ before={
@@ -95,8 +96,9 @@ export function CreatorChip({ borderColor }: { borderColor?: string }) {
style={borderColor ? { style={borderColor ? {
backgroundColor: borderColor, backgroundColor: borderColor,
border: 'none', border: 'none',
color: getContrastingTextColor(borderColor) color: getContrastingTextColor(borderColor),
} : undefined} opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
> >
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}> <Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{tag.name} {tag.name}

View File

@@ -2,6 +2,7 @@ import {
Box, Box,
Button, Button,
Chip, Chip,
color,
config, config,
Dialog, Dialog,
Header, Header,
@@ -319,6 +320,7 @@ export function PowerChip({ userId, borderColor }: { userId: string; borderColor
> >
<Chip <Chip
variant={error ? 'Critical' : 'SurfaceVariant'} variant={error ? 'Critical' : 'SurfaceVariant'}
fill={borderColor ? 'Soft' : 'None'}
radii="Pill" radii="Pill"
before={ before={
cords ? ( cords ? (
@@ -336,8 +338,9 @@ export function PowerChip({ userId, borderColor }: { userId: string; borderColor
style={borderColor ? { style={borderColor ? {
backgroundColor: borderColor, backgroundColor: borderColor,
border: 'none', border: 'none',
color: getContrastingTextColor(borderColor) color: getContrastingTextColor(borderColor),
} : undefined} opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
> >
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}> <Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{tag.name} {tag.name}

View File

@@ -8,6 +8,7 @@ import {
PopOut, PopOut,
Menu, Menu,
MenuItem, MenuItem,
color,
config, config,
Text, Text,
Line, Line,
@@ -125,6 +126,7 @@ export function ServerChip({ server, borderColor }: { server: string; borderColo
> >
<Chip <Chip
variant={myServer === server ? 'SurfaceVariant' : 'Warning'} variant={myServer === server ? 'SurfaceVariant' : 'Warning'}
fill={borderColor ? 'Soft' : 'None'}
radii="Pill" radii="Pill"
before={ before={
cords ? ( cords ? (
@@ -138,8 +140,9 @@ export function ServerChip({ server, borderColor }: { server: string; borderColo
style={borderColor ? { style={borderColor ? {
backgroundColor: borderColor, backgroundColor: borderColor,
border: 'none', border: 'none',
color: getContrastingTextColor(borderColor) color: getContrastingTextColor(borderColor),
} : undefined} opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
> >
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}> <Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{server} {server}
@@ -212,6 +215,7 @@ export function ShareChip({ userId, borderColor }: { userId: string; borderColor
> >
<Chip <Chip
variant={copied ? 'Success' : 'SurfaceVariant'} variant={copied ? 'Success' : 'SurfaceVariant'}
fill={borderColor ? 'Soft' : 'None'}
radii="Pill" radii="Pill"
before={ before={
cords ? ( cords ? (
@@ -225,8 +229,9 @@ export function ShareChip({ userId, borderColor }: { userId: string; borderColor
style={borderColor ? { style={borderColor ? {
backgroundColor: borderColor, backgroundColor: borderColor,
border: 'none', border: 'none',
color: getContrastingTextColor(borderColor) color: getContrastingTextColor(borderColor),
} : undefined} opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
> >
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}> <Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
Share Share
@@ -413,6 +418,7 @@ export function MutualRoomsChip({ userId, borderColor }: { userId: string; borde
> >
<Chip <Chip
variant="SurfaceVariant" variant="SurfaceVariant"
fill={borderColor ? 'Soft' : 'None'}
radii="Pill" radii="Pill"
before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />} before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />}
disabled={ disabled={
@@ -423,8 +429,9 @@ export function MutualRoomsChip({ userId, borderColor }: { userId: string; borde
style={borderColor ? { style={borderColor ? {
backgroundColor: borderColor, backgroundColor: borderColor,
border: 'none', border: 'none',
color: getContrastingTextColor(borderColor) color: getContrastingTextColor(borderColor),
} : undefined} opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
> >
<Text size="B300" style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}> <Text size="B300" style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{mutualRoomsState.status === AsyncStatus.Success && {mutualRoomsState.status === AsyncStatus.Success &&
@@ -521,14 +528,16 @@ export function OptionsChip({ userId, borderColor }: { userId: string; borderCol
> >
<Chip <Chip
variant="SurfaceVariant" variant="SurfaceVariant"
fill={borderColor ? 'Soft' : 'None'}
radii="Pill" radii="Pill"
onClick={open} onClick={open}
aria-pressed={!!cords} aria-pressed={!!cords}
style={borderColor ? { style={borderColor ? {
backgroundColor: borderColor, backgroundColor: borderColor,
border: 'none', border: 'none',
color: getContrastingTextColor(borderColor) color: getContrastingTextColor(borderColor),
} : undefined} opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
> >
{ignoring ? ( {ignoring ? (
<Spinner variant="Secondary" size="50" /> <Spinner variant="Secondary" size="50" />

View File

@@ -60,6 +60,8 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
<div className={css.UserHeroAvatarContainer}> <div className={css.UserHeroAvatarContainer}>
<AvatarPresence <AvatarPresence
className={css.UserAvatarContainer} className={css.UserAvatarContainer}
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
badgeBackgroundColor={profileStyle.avatarBorderColor}
badge={ badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} /> presence && <PresenceBadge presence={presence.presence} status={presence.status} />
} }
@@ -69,10 +71,14 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined} onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar} className={css.UserHeroAvatar}
size="500" size="500"
style={profileStyle.avatarBorderColor ? { style={{
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`, width: toRem(72),
outlineOffset: toRem(-1), height: toRem(72),
} : undefined} ...(profileStyle.avatarBorderColor ? {
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
outlineOffset: toRem(-1),
} : {}),
}}
> >
<UserAvatar <UserAvatar
className={css.UserHeroAvatarImg} className={css.UserHeroAvatarImg}

View File

@@ -23,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip'; import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths'; import { DirectCreateSearchParams } from '../../pages/paths';
import { getContrastingTextColor } from '../../utils/common'; import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
import classNames from 'classnames'; import classNames from 'classnames';
import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
@@ -87,6 +87,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
(canKickUser && membership === Membership.Join) || (canKickUser && membership === Membership.Join) ||
(canBanUser && membership !== Membership.Ban); (canBanUser && membership !== Membership.Ban);
const profileColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : colorMXID(userId);
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
const pillBgColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : undefined;
return ( return (
<Box direction="Column"> <Box direction="Column">
<UserHero <UserHero
@@ -111,7 +115,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Text <Text
size="H4" size="H4"
className={classNames(BreakWord, LineClamp3)} className={classNames(BreakWord, LineClamp3)}
style={{ color: getMemberDisplayName(room, userId) !== userId ? (profileStyle.avatarBorderColor || colorMXID(userId)) : undefined }} style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
> >
{getMemberDisplayName(room, userId)} {getMemberDisplayName(room, userId)}
</Text> </Text>
@@ -120,7 +124,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Text <Text
size="T200" size="T200"
className={BreakWord} className={BreakWord}
style={{ color: profileStyle.avatarBorderColor || colorMXID(userId), opacity: 0.8 }} style={{ color: profileColor, textShadow: profileTextShadow }}
> >
{userId} {userId}
</Text> </Text>
@@ -129,8 +133,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{presence?.status && ( {presence?.status && (
<Box <Box
style={{ style={{
backgroundColor: profileStyle.avatarBorderColor || color.Surface.Container, backgroundColor: pillBgColor || color.Surface.Container,
border: profileStyle.avatarBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`, padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16), borderRadius: toRem(16),
maxWidth: toRem(250), maxWidth: toRem(250),
@@ -140,7 +144,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
size="T300" size="T300"
className={BreakWord} className={BreakWord}
style={{ style={{
color: profileStyle.avatarBorderColor ? getContrastingTextColor(profileStyle.avatarBorderColor) : undefined color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
}} }}
> >
{presence.status} {presence.status}
@@ -150,9 +154,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Chips Row */} {/* Chips Row */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center"> <Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
{server && <ServerChip server={server} borderColor={profileStyle.avatarBorderColor} />} {server && <ServerChip server={server} borderColor={pillBgColor} />}
<ShareChip userId={userId} borderColor={profileStyle.avatarBorderColor} /> <ShareChip userId={userId} borderColor={pillBgColor} />
{creator ? <CreatorChip borderColor={profileStyle.avatarBorderColor} /> : <PowerChip userId={userId} borderColor={profileStyle.avatarBorderColor} />} {creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
</Box> </Box>
{/* Message Button */} {/* Message Button */}
@@ -172,8 +176,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Additional Chips if needed */} {/* Additional Chips if needed */}
{userId !== myUserId && ( {userId !== myUserId && (
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center"> <Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<MutualRoomsChip userId={userId} borderColor={profileStyle.avatarBorderColor} /> <MutualRoomsChip userId={userId} borderColor={pillBgColor} />
<OptionsChip userId={userId} borderColor={profileStyle.avatarBorderColor} /> <OptionsChip userId={userId} borderColor={pillBgColor} />
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -37,7 +37,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl'; 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 { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker'; import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL'; import { useObjectURL } from '../../../hooks/useObjectURL';
@@ -563,6 +563,11 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
? rgbaToHex(localBorderColor) ? rgbaToHex(localBorderColor)
: profileStyle.avatarBorderColor; : profileStyle.avatarBorderColor;
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
const previewProfileColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : (userColor || colorMXID(userId));
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
return ( return (
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<Box <Box
@@ -710,7 +715,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
as="button" as="button"
onClick={handleAvatarClick} onClick={handleAvatarClick}
style={{ style={{
backgroundColor: color.Surface.Container, backgroundColor: previewBorderColor || color.Surface.Container,
border: 'none', border: 'none',
padding: 0, padding: 0,
cursor: 'pointer', cursor: 'pointer',
@@ -718,6 +723,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}} }}
> >
<AvatarPresence <AvatarPresence
badgeBackgroundColor={previewBorderColor}
badge={ badge={
presence && ( presence && (
<PresenceBadge presence={presence.presence} status={presence.status} /> <PresenceBadge presence={presence.presence} status={presence.status} />
@@ -727,6 +733,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Avatar <Avatar
size="500" size="500"
style={{ style={{
width: toRem(72),
height: toRem(72),
outline: previewBorderColor outline: previewBorderColor
? `${toRem(4)} solid ${previewBorderColor}` ? `${toRem(4)} solid ${previewBorderColor}`
: `${config.borderWidth.B600} solid ${color.Surface.Container}`, : `${config.borderWidth.B600} solid ${color.Surface.Container}`,
@@ -905,7 +913,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
size="H4" size="H4"
className={classNames(BreakWord, LineClamp3)} className={classNames(BreakWord, LineClamp3)}
title={profile.displayName || getMxIdLocalPart(userId)} title={profile.displayName || getMxIdLocalPart(userId)}
style={{ color: userColor || colorMXID(userId) }} style={{ color: previewProfileColor, textShadow: previewTextShadow }}
> >
{profile.displayName || getMxIdLocalPart(userId) || userId} {profile.displayName || getMxIdLocalPart(userId) || userId}
</Text> </Text>
@@ -937,7 +945,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
size="T200" size="T200"
className={BreakWord} className={BreakWord}
title={userId} title={userId}
style={{ color: userColor || colorMXID(userId), opacity: 0.8 }} style={{ color: previewProfileColor, textShadow: previewTextShadow }}
> >
{userId} {userId}
</Text> </Text>
@@ -947,8 +955,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
onMouseEnter={() => setHoveredArea('status')} onMouseEnter={() => setHoveredArea('status')}
onMouseLeave={() => setHoveredArea(null)} onMouseLeave={() => setHoveredArea(null)}
style={{ style={{
backgroundColor: previewBorderColor || color.Surface.Container, backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`, padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16), borderRadius: toRem(16),
maxWidth: toRem(250), maxWidth: toRem(250),
@@ -998,16 +1006,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
size="T300" size="T300"
className={BreakWord} className={BreakWord}
style={{ style={{
opacity: presence?.status ? 1 : 0.5,
fontStyle: presence?.status ? 'normal' : 'italic', fontStyle: presence?.status ? 'normal' : 'italic',
color: previewBorderColor ? getContrastingTextColor(previewBorderColor) : undefined, color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}} }}
> >
{presence?.status || 'Click to set a custom status...'}</Text> {presence?.status || 'Click to set a custom status...'}</Text>
</Box> </Box>
{presence?.status && hoveredArea === 'status' && ( {presence?.status && hoveredArea === 'status' && (
<> <>
<Icon size="50" src={Icons.Pencil} style={{ color: previewBorderColor ? getContrastingTextColor(previewBorderColor) : undefined }} /> <Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
<Box <Box
as="button" as="button"
onClick={(e) => { onClick={(e) => {
@@ -1038,8 +1045,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center"> <Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<Box <Box
style={{ style={{
backgroundColor: previewBorderColor || color.Surface.Container, backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`, padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16), borderRadius: toRem(16),
}} }}
@@ -1047,7 +1054,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Text <Text
size="B300" size="B300"
style={{ style={{
color: previewBorderColor ? getContrastingTextColor(previewBorderColor) : undefined, color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}} }}
> >
ruv.wtf ruv.wtf
@@ -1055,8 +1062,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
</Box> </Box>
<Box <Box
style={{ style={{
backgroundColor: previewBorderColor || color.Surface.Container, backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`, padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16), borderRadius: toRem(16),
}} }}
@@ -1064,7 +1071,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Text <Text
size="B300" size="B300"
style={{ style={{
color: previewBorderColor ? getContrastingTextColor(previewBorderColor) : undefined, color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}} }}
> >
Share Share
@@ -1072,8 +1079,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
</Box> </Box>
<Box <Box
style={{ style={{
backgroundColor: previewBorderColor || color.Surface.Container, backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`, padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16), borderRadius: toRem(16),
}} }}
@@ -1081,7 +1088,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Text <Text
size="B300" size="B300"
style={{ style={{
color: previewBorderColor ? getContrastingTextColor(previewBorderColor) : undefined, color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}} }}
> >
Admin Admin

View File

@@ -139,6 +139,32 @@ export const splitWithSpace = (content: string): string[] => {
return trimmedContent.split(' '); 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 * Calculate contrasting text color (black or white) for a given background color
* @param bgColor - Hex color string (e.g., '#RRGGBB' or '#RRGGBBAA') * @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 black for light backgrounds, white for dark backgrounds
return luminance > 0.5 ? '#000000' : '#FFFFFF'; 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';
};