fix: draw avatar initials in black or white so they read on every colour

This commit is contained in:
Linus Rath
2026-10-29 19:59:16 +01:00
parent d552a3bb86
commit 2e13accade
2 changed files with 41 additions and 1 deletions
@@ -0,0 +1,22 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Avatar } from '../avatar';
import { getLuminance, parseColor } from '@/lib/color-transform';
// LV-10: white initials failed 4.5:1 on the light generated colours.
function ratio(fg: string, bg: string): number {
const [la, lb] = [parseColor(fg)!, parseColor(bg)!].map((c) => getLuminance(c.r, c.g, c.b));
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
describe('avatar initials', () => {
it('read at 4.5:1 on any generated colour', () => {
for (const name of ['Alice Smith', 'Bob Chen', 'Carol Lee', 'Dev User', 'Erik Ström', 'Fatima Noor', 'Gao Wei', 'Hana Kim']) {
const { container, unmount } = render(<Avatar name={name} disableImages />);
const el = container.firstElementChild as HTMLElement;
expect(ratio(el.style.color, el.style.backgroundColor)).toBeGreaterThanOrEqual(4.5);
unmount();
}
});
});
+19 -1
View File
@@ -2,6 +2,7 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils";
import { getLuminance, parseColor } from "@/lib/color-transform";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoIndex, type ContactPhotoRef } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config";
@@ -156,6 +157,18 @@ interface AvatarProps {
fallbackColor?: string;
}
/**
* White or black initials, whichever contrasts more with the background.
* One of the two always reaches 4.58:1, so the initials pass 4.5:1 on every
* generated colour (white alone failed on yellows and greens).
*/
function initialsColor(background: string): string {
const rgb = parseColor(background);
if (!rgb) return '#ffffff';
const l = getLuminance(rgb.r, rgb.g, rgb.b);
return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#ffffff' : '#000000';
}
export function Avatar({ name, email, contactPhoto, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
@@ -272,6 +285,8 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
}
}, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
const initialsBackground = fallbackColor ?? getBackgroundColor();
return (
<div
ref={observe}
@@ -280,7 +295,10 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
sizeClasses[size],
className
)}
style={{ backgroundColor: imgSrc ? (isFavicon ? "#ffffff" : "transparent") : (fallbackColor ?? getBackgroundColor()) }}
style={{
backgroundColor: imgSrc ? (isFavicon ? "#ffffff" : "transparent") : initialsBackground,
color: imgSrc ? undefined : initialsColor(initialsBackground),
}}
title={name || email}
>
{imgSrc ? (