diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 3224cf98..f2b7146d 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -255,18 +255,15 @@ export function UserProfileModal() { {/* Header (avatar + name) */}
-
- -
+
- {domain ? ( - - ) : ( - @{baseName} - )} +
{user.customStatus && (
diff --git a/packages/web/src/components/ui/Avatar.tsx b/packages/web/src/components/ui/Avatar.tsx index 32758181..1ab0832b 100644 --- a/packages/web/src/components/ui/Avatar.tsx +++ b/packages/web/src/components/ui/Avatar.tsx @@ -12,6 +12,7 @@ interface AvatarProps { onClick?: (e: React.MouseEvent) => void; user?: User; userId?: string; + ring?: { width: number; color: string }; } const statusColors: Record = { @@ -29,27 +30,40 @@ const statusColors: Record = { * Prototype reference (.m-dot): 12px box with 3px border (border-box) = 6px * visible color, positioned at bottom:-2 right:-2 → center 4px from corner. */ -function buildCutoutMask(size: number): string { - const { dot, gap, inset } = getDotMetrics(size); - const cx = size - inset; - const cy = size - inset; +function buildCutoutMask(avatarSize: number, ringWidth: number = 0): string { + const outerSize = avatarSize + ringWidth * 2; + const { dot, gap, inset } = getDotMetrics(avatarSize, ringWidth); + const cx = outerSize - inset; + const cy = outerSize - inset; const r = dot / 2 + gap; return `radial-gradient(circle at ${cx}px ${cy}px, transparent ${r}px, black ${r + 0.5}px)`; } -/** Returns visible dot diameter, gap width, and center inset from avatar edge. */ -function getDotMetrics(size: number) { - if (size <= 24) return { dot: 5, gap: 2, inset: 3 }; - return { dot: 6, gap: 3, inset: 4 }; +/** Returns visible dot diameter, gap width, and center inset from outer edge. */ +function getDotMetrics(avatarSize: number, ringWidth: number = 0) { + let dot: number, gap: number, avatarInset: number; + if (avatarSize <= 24) { + dot = 5; gap = 2; avatarInset = 3; + } else if (avatarSize <= 48) { + dot = 6; gap = 3; avatarInset = 4; + } else { + dot = Math.round(avatarSize * 0.15); + gap = Math.round(avatarSize * 0.05); + avatarInset = Math.round(avatarSize * 0.10); + } + return { dot, gap, inset: avatarInset + ringWidth }; } -export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId }: AvatarProps) { +export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring }: AvatarProps) { const openUserProfile = useUIStore((s) => s.openUserProfile); const initials = name.charAt(0).toUpperCase(); // Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18; const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, user?.avatarColor); + const ringWidth = ring?.width ?? 0; + const outerSize = size + ringWidth * 2; + const handleClick = (e: React.MouseEvent) => { if (onClick) { onClick(e); @@ -64,41 +78,52 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick, }; // Only compute mask when status dot is visible - const cutoutMask = status ? buildCutoutMask(size) : undefined; + const cutoutMask = status ? buildCutoutMask(size, ringWidth) : undefined; const maskStyle: React.CSSProperties | undefined = cutoutMask ? { maskImage: cutoutMask, WebkitMaskImage: cutoutMask } : undefined; - const { dot: dotDiameter, inset: dotInset } = getDotMetrics(size); + const { dot: dotDiameter, inset: dotInset } = getDotMetrics(size, ringWidth); return (
- {src ? ( - {name} { - (e.target as HTMLImageElement).style.display = 'none'; - const parent = (e.target as HTMLImageElement).parentElement; - if (parent) { - const fallback = parent.querySelector('.avatar-fallback') as HTMLElement; - if (fallback) fallback.style.display = 'flex'; - } - }} - /> - ) : null} + {/* Inner masked circle — ring background + avatar content */}
- {initials} + {src ? ( + {name} { + (e.target as HTMLImageElement).style.display = 'none'; + const parent = (e.target as HTMLImageElement).parentElement; + if (parent) { + const fallback = parent.querySelector('.avatar-fallback') as HTMLElement; + if (fallback) fallback.style.display = 'flex'; + } + }} + /> + ) : null} +
+ {initials} +
+ {/* Status dot — outside the masked div so it isn't clipped */} {status && (
{/* Avatar */} -
- -
+ {/* Name & info */}
@@ -122,11 +119,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout className="text-[16px] font-semibold leading-tight" />
- {domain ? ( - - ) : ( - @{baseName} - )} +
{user.customStatus && (
diff --git a/packages/web/src/components/ui/Username.tsx b/packages/web/src/components/ui/Username.tsx index 74e725ca..adcebe5e 100644 --- a/packages/web/src/components/ui/Username.tsx +++ b/packages/web/src/components/ui/Username.tsx @@ -3,21 +3,23 @@ import { Tooltip } from './Tooltip'; interface UsernameProps { username: string; + showAt?: boolean; className?: string; style?: React.CSSProperties; } -export function Username({ username, className, style }: UsernameProps) { +export function Username({ username, showAt, className, style }: UsernameProps) { const atIndex = username.indexOf('@'); + const prefix = showAt ? '@' : ''; if (atIndex === -1) { - return {username}; + return {prefix}{username}; } const name = username.slice(0, atIndex); const domain = username.slice(atIndex + 1); return ( - + - {name} + {prefix}{name} @{domain}