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:
@@ -8,7 +8,7 @@ import { useUIStore } from '../../stores/uiStore';
|
|||||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||||
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
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 { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity';
|
||||||
import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals';
|
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))
|
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
||||||
: null;
|
: null;
|
||||||
const bannerFallback = user.accentColor
|
const bannerFallback = user.accentColor
|
||||||
? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})`
|
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
|
||||||
: getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor).gradient;
|
: (() => {
|
||||||
|
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
|
||||||
|
return mutedGradient(g.from, g.to);
|
||||||
|
})();
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -236,7 +239,7 @@ export function UserProfileModal() {
|
|||||||
className="h-[100px] flex-shrink-0 relative"
|
className="h-[100px] flex-shrink-0 relative"
|
||||||
style={bannerSrc
|
style={bannerSrc
|
||||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
: { background: bannerFallback, opacity: 0.6 }
|
: { background: bannerFallback }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useAuthStore } from '../../../stores/authStore';
|
|||||||
import { Avatar } from '../../ui/Avatar';
|
import { Avatar } from '../../ui/Avatar';
|
||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
import { api } from '../../../api/client';
|
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 { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||||
|
|
||||||
@@ -86,10 +86,13 @@ export function AccountPanel() {
|
|||||||
: null;
|
: null;
|
||||||
const displayAvatarSrc = avatarPreview ?? (avatarFilename === '' ? null : currentAvatarSrc);
|
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
|
const bannerFallback = effectiveAccent
|
||||||
? `linear-gradient(135deg, ${effectiveAccent}, ${adjustColor(effectiveAccent, -40)})`
|
? mutedGradient(effectiveAccent, adjustColor(effectiveAccent, -40))
|
||||||
: getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor).gradient;
|
: (() => {
|
||||||
|
const g = getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor);
|
||||||
|
return mutedGradient(g.from, g.to);
|
||||||
|
})();
|
||||||
|
|
||||||
// ── File selection handlers ──
|
// ── File selection handlers ──
|
||||||
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -218,7 +221,7 @@ export function AccountPanel() {
|
|||||||
className="h-[80px]"
|
className="h-[80px]"
|
||||||
style={displayBannerSrc
|
style={displayBannerSrc
|
||||||
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
: { background: bannerFallback, opacity: 0.6 }
|
: { background: bannerFallback }
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Avatar + info */}
|
{/* Avatar + info */}
|
||||||
@@ -339,7 +342,11 @@ export function AccountPanel() {
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={displayBannerSrc
|
style={displayBannerSrc
|
||||||
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
? { 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">
|
<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 { Username } from '../ui/Username';
|
||||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { getAvatarGradient, adjustColor } from '../../utils/gradients';
|
import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients';
|
||||||
import { parseFederatedUsername } from '../../utils/identity';
|
import { parseFederatedUsername } from '../../utils/identity';
|
||||||
import { loadFederatedMutuals } from '../../utils/mutuals';
|
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))
|
? (user.banner.startsWith('http') ? user.banner : userApi.uploads.url(user.banner))
|
||||||
: null;
|
: null;
|
||||||
const bannerFallback = user.accentColor
|
const bannerFallback = user.accentColor
|
||||||
? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})`
|
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
|
||||||
: getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor).gradient;
|
: (() => {
|
||||||
|
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
|
||||||
|
return mutedGradient(g.from, g.to);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -92,7 +95,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
className="h-[80px] rounded-t-[12px]"
|
className="h-[80px] rounded-t-[12px]"
|
||||||
style={bannerSrc
|
style={bannerSrc
|
||||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
: { background: bannerFallback, opacity: 0.6 }
|
: { background: bannerFallback }
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ export const BANNER_COLOR_PRESETS: string[][] = [
|
|||||||
/** @deprecated Use BANNER_COLOR_PRESETS instead. */
|
/** @deprecated Use BANNER_COLOR_PRESETS instead. */
|
||||||
export const ACCENT_PRESETS: string[] = BANNER_COLOR_PRESETS.map(f => f[1]!);
|
export const ACCENT_PRESETS: string[] = BANNER_COLOR_PRESETS.map(f => f[1]!);
|
||||||
|
|
||||||
|
/** Convert 6-digit hex (#rrggbb) to rgba string. */
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a banner-style muted gradient from two hex colors with baked-in alpha. */
|
||||||
|
export function mutedGradient(from: string, to: string, alpha = 0.6): string {
|
||||||
|
return `linear-gradient(135deg, ${hexToRgba(from, alpha)}, ${hexToRgba(to, alpha)})`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Shift each RGB component of a hex color by `amount` (positive = lighter, negative = darker). */
|
/** Shift each RGB component of a hex color by `amount` (positive = lighter, negative = darker). */
|
||||||
export function adjustColor(hex: string, amount: number): string {
|
export function adjustColor(hex: string, amount: number): string {
|
||||||
const r = Math.max(0, Math.min(255, parseInt(hex.slice(1, 3), 16) + amount));
|
const r = Math.max(0, Math.min(255, parseInt(hex.slice(1, 3), 16) + amount));
|
||||||
|
|||||||
Reference in New Issue
Block a user