fix: eliminate banner color bleed by baking alpha into gradient colors

Replace element-level opacity on banner fallback divs with rgba-based
gradient color stops via new mutedGradient() utility. This prevents
compositing layer artifacts that caused visible color seams at the
banner boundary in AccountPanel, UserProfilePopout, and UserProfileModal.
This commit is contained in:
Jannis Braun
2026-03-10 22:03:51 +01:00
parent 40e8293695
commit 5953a8985e
4 changed files with 40 additions and 14 deletions
@@ -8,7 +8,7 @@ import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { useAuthStore } from '../../stores/authStore';
import { getAvatarGradient, getSpaceGradient, adjustColor } from '../../utils/gradients';
import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients';
import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity';
import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals';
@@ -149,8 +149,11 @@ export function UserProfileModal() {
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
: null;
const bannerFallback = user.accentColor
? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})`
: getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor).gradient;
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
: (() => {
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
return mutedGradient(g.from, g.to);
})();
const handleSendMessage = async () => {
try {
@@ -236,7 +239,7 @@ export function UserProfileModal() {
className="h-[100px] flex-shrink-0 relative"
style={bannerSrc
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: bannerFallback, opacity: 0.6 }
: { background: bannerFallback }
}
>
{/* Close button */}
@@ -3,7 +3,7 @@ import { useAuthStore } from '../../../stores/authStore';
import { Avatar } from '../../ui/Avatar';
import { ImageCropModal } from '../../ui/ImageCropModal';
import { api } from '../../../api/client';
import { getAvatarGradient, adjustColor, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients';
import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients';
import { AVATAR_COLORS } from '@backspace/shared';
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
@@ -86,10 +86,13 @@ export function AccountPanel() {
: null;
const displayAvatarSrc = avatarPreview ?? (avatarFilename === '' ? null : currentAvatarSrc);
// Banner fallback: accent gradient or avatar gradient
// Banner fallback: accent gradient or avatar gradient (alpha baked into gradient colors)
const bannerFallback = effectiveAccent
? `linear-gradient(135deg, ${effectiveAccent}, ${adjustColor(effectiveAccent, -40)})`
: getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor).gradient;
? mutedGradient(effectiveAccent, adjustColor(effectiveAccent, -40))
: (() => {
const g = getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor);
return mutedGradient(g.from, g.to);
})();
// ── File selection handlers ──
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -218,7 +221,7 @@ export function AccountPanel() {
className="h-[80px]"
style={displayBannerSrc
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: bannerFallback, opacity: 0.6 }
: { background: bannerFallback }
}
/>
{/* Avatar + info */}
@@ -339,7 +342,11 @@ export function AccountPanel() {
className="w-full h-full"
style={displayBannerSrc
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: bannerFallback, opacity: 0.5 }
: { background: mutedGradient(
effectiveAccent ?? getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor).from,
effectiveAccent ? adjustColor(effectiveAccent, -40) : getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor).to,
0.5
) }
}
/>
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
@@ -6,7 +6,7 @@ import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { getAvatarGradient, adjustColor } from '../../utils/gradients';
import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
import { loadFederatedMutuals } from '../../utils/mutuals';
@@ -71,8 +71,11 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
? (user.banner.startsWith('http') ? user.banner : userApi.uploads.url(user.banner))
: null;
const bannerFallback = user.accentColor
? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})`
: getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor).gradient;
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
: (() => {
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
return mutedGradient(g.from, g.to);
})();
return (
<div
@@ -92,7 +95,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
className="h-[80px] rounded-t-[12px]"
style={bannerSrc
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: bannerFallback, opacity: 0.6 }
: { background: bannerFallback }
}
/>