From 2e13accadea0d87deabbd35750af1b064d0fa8da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:59:16 +0100 Subject: [PATCH] fix: draw avatar initials in black or white so they read on every colour --- .../avatar-initials-contrast.test.tsx | 22 +++++++++++++++++++ components/ui/avatar.tsx | 20 ++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 components/ui/__tests__/avatar-initials-contrast.test.tsx diff --git a/components/ui/__tests__/avatar-initials-contrast.test.tsx b/components/ui/__tests__/avatar-initials-contrast.test.tsx new file mode 100644 index 000000000..c40bfa20b --- /dev/null +++ b/components/ui/__tests__/avatar-initials-contrast.test.tsx @@ -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(); + const el = container.firstElementChild as HTMLElement; + expect(ratio(el.style.color, el.style.backgroundColor)).toBeGreaterThanOrEqual(4.5); + unmount(); + } + }); +}); diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 96db820ae..cecbaab8e 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -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(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 (
{imgSrc ? (