From bb2003525a0b32d66bab52ece51e44d54e299f21 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 12:37:08 -0700 Subject: [PATCH 1/6] Add Gravatar avatars with colored initials to channel views Channel/thread/activity message avatars were initials-only in a single neutral color. Add a shared UserAvatar that resolves a Gravatar from the author's email (SHA-256 hash, d=404 so a missing Gravatar falls through) and falls back to initials tinted with a deterministic per-user color, so people are visually distinguishable at a glance. - avatarColorClass: deterministic seed -> Radix palette class (core); reviewerAvatarToneClass now delegates to it. - useGravatarUrl: Web Crypto SHA-256, no new hashing dependency. - UserAvatar: quill Avatar + AvatarImage(gravatar) + colored AvatarFallback. - Swap human-author call sites in ActivityView, ThreadPanel, ChannelFeedView; agent/system rows keep the robot icon. Generated-By: PostHog Code Task-Id: 03a1a20f-7597-4267-bf73-0ef6224e71e7 --- packages/core/src/auth/avatarColor.test.ts | 22 ++++++++++ packages/core/src/auth/avatarColor.ts | 30 ++++++++++++++ packages/core/src/inbox/artefacts.ts | 17 +------- packages/ui/src/features/auth/UserAvatar.tsx | 37 +++++++++++++++++ .../src/features/auth/useGravatarUrl.test.ts | 28 +++++++++++++ .../ui/src/features/auth/useGravatarUrl.ts | 40 +++++++++++++++++++ .../canvas/components/ActivityView.tsx | 8 +--- .../canvas/components/ChannelFeedView.tsx | 36 +++++++++-------- .../canvas/components/ThreadPanel.tsx | 30 +++++++------- 9 files changed, 194 insertions(+), 54 deletions(-) create mode 100644 packages/core/src/auth/avatarColor.test.ts create mode 100644 packages/core/src/auth/avatarColor.ts create mode 100644 packages/ui/src/features/auth/UserAvatar.tsx create mode 100644 packages/ui/src/features/auth/useGravatarUrl.test.ts create mode 100644 packages/ui/src/features/auth/useGravatarUrl.ts diff --git a/packages/core/src/auth/avatarColor.test.ts b/packages/core/src/auth/avatarColor.test.ts new file mode 100644 index 0000000000..9b6becf58c --- /dev/null +++ b/packages/core/src/auth/avatarColor.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { avatarColorClass } from "./avatarColor"; + +describe("avatarColorClass", () => { + it("is deterministic for a given seed", () => { + expect(avatarColorClass("user-uuid-123")).toBe( + avatarColorClass("user-uuid-123"), + ); + }); + + it("always returns a white-text palette class", () => { + for (const seed of ["a", "raquel@posthog.com", "uuid", "", "James Doe"]) { + expect(avatarColorClass(seed)).toMatch(/^bg-\(--[a-z]+-9\) text-white$/); + } + }); + + it("spreads distinct seeds across more than one hue", () => { + const seeds = Array.from({ length: 40 }, (_, i) => `person-${i}`); + const distinct = new Set(seeds.map(avatarColorClass)); + expect(distinct.size).toBeGreaterThan(1); + }); +}); diff --git a/packages/core/src/auth/avatarColor.ts b/packages/core/src/auth/avatarColor.ts new file mode 100644 index 0000000000..b055b636fe --- /dev/null +++ b/packages/core/src/auth/avatarColor.ts @@ -0,0 +1,30 @@ +// Deterministic per-user avatar color. A stable seed (user uuid, email, or name) +// always maps to the same palette entry, so a given person keeps one color across +// every view and session. All entries use a Radix "9" step whose accessible pairing +// is white text (the light-9 scales — amber/yellow/lime/mint/sky — are excluded). +const AVATAR_PALETTE = [ + "bg-(--orange-9) text-white", + "bg-(--blue-9) text-white", + "bg-(--purple-9) text-white", + "bg-(--green-9) text-white", + "bg-(--pink-9) text-white", + "bg-(--teal-9) text-white", + "bg-(--red-9) text-white", + "bg-(--indigo-9) text-white", + "bg-(--cyan-9) text-white", + "bg-(--violet-9) text-white", + "bg-(--jade-9) text-white", + "bg-(--crimson-9) text-white", +] as const; + +export function avatarColorSeedHash(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973; + } + return hash; +} + +export function avatarColorClass(seed: string): string { + return AVATAR_PALETTE[avatarColorSeedHash(seed) % AVATAR_PALETTE.length]; +} diff --git a/packages/core/src/inbox/artefacts.ts b/packages/core/src/inbox/artefacts.ts index 1978f710bc..04b26fe9ad 100644 --- a/packages/core/src/inbox/artefacts.ts +++ b/packages/core/src/inbox/artefacts.ts @@ -48,22 +48,7 @@ export function extractSuggestedReviewers( return artefact?.content ?? []; } -const AVATAR_PALETTE = [ - "bg-(--orange-9) text-white", - "bg-(--blue-9) text-white", - "bg-(--purple-9) text-white", - "bg-(--green-9) text-white", - "bg-(--pink-9) text-white", - "bg-(--teal-9) text-white", -] as const; - -export function reviewerAvatarToneClass(seed: string): string { - let hash = 0; - for (let i = 0; i < seed.length; i += 1) { - hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973; - } - return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; -} +export { avatarColorClass as reviewerAvatarToneClass } from "@posthog/core/auth/avatarColor"; export function reviewerInitials( name: string | null | undefined, diff --git a/packages/ui/src/features/auth/UserAvatar.tsx b/packages/ui/src/features/auth/UserAvatar.tsx new file mode 100644 index 0000000000..743aade23d --- /dev/null +++ b/packages/ui/src/features/auth/UserAvatar.tsx @@ -0,0 +1,37 @@ +import { avatarColorClass } from "@posthog/core/auth/avatarColor"; +import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { useGravatarUrl } from "@posthog/ui/features/auth/useGravatarUrl"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; + +type AvatarSize = "lg" | "default" | "sm" | "xs"; + +interface UserAvatarProps { + user?: UserBasic | null; + size?: AvatarSize; + className?: string; +} + +// A person's avatar: Gravatar (by email) when one exists, otherwise a colored +// initials bubble. The color is seeded off a stable identifier so each person keeps +// one hue everywhere. When the Gravatar image loads it covers the colored fallback. +export function UserAvatar({ + user, + size = "default", + className, +}: UserAvatarProps) { + const gravatarUrl = useGravatarUrl(user?.email); + const seed = user?.uuid ?? user?.email ?? userDisplayName(user); + + return ( + + {gravatarUrl ? ( + + ) : null} + + {getUserInitials(user)} + + + ); +} diff --git a/packages/ui/src/features/auth/useGravatarUrl.test.ts b/packages/ui/src/features/auth/useGravatarUrl.test.ts new file mode 100644 index 0000000000..a3133e0fb1 --- /dev/null +++ b/packages/ui/src/features/auth/useGravatarUrl.test.ts @@ -0,0 +1,28 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useGravatarUrl } from "./useGravatarUrl"; + +describe("useGravatarUrl", () => { + it("returns undefined when there is no email", () => { + const { result } = renderHook(() => useGravatarUrl(undefined)); + expect(result.current).toBeUndefined(); + }); + + it("builds a SHA-256 Gravatar URL with the d=404 fallback", async () => { + const { result } = renderHook(() => useGravatarUrl("user@example.com")); + await waitFor(() => + expect(result.current).toBe( + "https://www.gravatar.com/avatar/b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514?s=96&d=404", + ), + ); + }); + + it("lowercases and trims the email before hashing", async () => { + const { result } = renderHook(() => useGravatarUrl(" TEST@Example.com ")); + await waitFor(() => + expect(result.current).toBe( + "https://www.gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?s=96&d=404", + ), + ); + }); +}); diff --git a/packages/ui/src/features/auth/useGravatarUrl.ts b/packages/ui/src/features/auth/useGravatarUrl.ts new file mode 100644 index 0000000000..a16ff1ac16 --- /dev/null +++ b/packages/ui/src/features/auth/useGravatarUrl.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; + +// Gravatar accepts a SHA-256 hex hash of the lowercased, trimmed email, so we hash +// with the built-in Web Crypto API rather than pulling in an md5 dependency. `d=404` +// makes Gravatar return 404 (instead of a default silhouette) when the address has no +// avatar, so the errors and the initials fallback stays visible. +async function gravatarUrlForEmail(email: string): Promise { + if (!globalThis.crypto?.subtle) return undefined; + const normalized = email.trim().toLowerCase(); + const bytes = new TextEncoder().encode(normalized); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + const hash = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `https://www.gravatar.com/avatar/${hash}?s=96&d=404`; +} + +export function useGravatarUrl(email?: string | null): string | undefined { + const [url, setUrl] = useState(undefined); + + useEffect(() => { + if (!email) { + setUrl(undefined); + return; + } + let cancelled = false; + gravatarUrlForEmail(email) + .then((next) => { + if (!cancelled) setUrl(next); + }) + .catch(() => { + if (!cancelled) setUrl(undefined); + }); + return () => { + cancelled = true; + }; + }, [email]); + + return url; +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 17fe071e67..0103d4d327 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -1,8 +1,6 @@ import { AtIcon, LinkIcon } from "@phosphor-icons/react"; import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; import { - Avatar, - AvatarFallback, Button, Empty, EmptyDescription, @@ -14,8 +12,8 @@ import { import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; @@ -68,9 +66,7 @@ function ActivityRow({ className="flex w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-fill-secondary" > - - {getUserInitials(item.author)} - + {isNew && ( {authors.map((author, index) => ( - - {getUserInitials(author)} - + ))} @@ -440,11 +438,15 @@ export function TaskFeedRow({ return ( - - - {starter ? getUserInitials(starter) : } - - + {starter ? ( + + ) : ( + + + + + + )} @@ -611,15 +613,15 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { - - - {message.author ? ( - getUserInitials(message.author) - ) : ( + {message.author ? ( + + ) : ( + + - )} - - + + + )} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index fba801b8f1..170887d8dd 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -52,8 +52,8 @@ import type { } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { @@ -114,17 +114,19 @@ export function ThreadMessageRow({ return ( - - - {isAgent ? ( - - ) : isSystem ? ( - "S" - ) : ( - getUserInitials(message.author) - )} - - + {isAgent || isSystem ? ( + + + {isAgent ? : "S"} + + + ) : ( + + )} @@ -266,9 +268,7 @@ export function UserPromptRow({ return ( - - {getUserInitials(author)} - + From 852a32430f6211e2facc6a840626fdde52932557 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 12:55:57 -0700 Subject: [PATCH 2/6] Render initials color via inline CSS var so it actually shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-user color was applied as a Tailwind `bg-(--x-9)` class built in @posthog/core, but the app's Tailwind content globs only scan packages/ui and apps/*, not packages/core — so those utilities were never generated and every bubble fell back to the neutral gray default. Return a CSS variable (var(--x-9)) instead and apply it via inline style on the AvatarFallback, which doesn't depend on Tailwind class generation. Revert the reviewer-avatar helper to its original standalone form (it has the same latent scanning limitation but is out of scope here). Generated-By: PostHog Code Task-Id: 03a1a20f-7597-4267-bf73-0ef6224e71e7 --- packages/core/src/auth/avatarColor.test.ts | 16 ++++---- packages/core/src/auth/avatarColor.ts | 42 +++++++++++--------- packages/core/src/inbox/artefacts.ts | 17 +++++++- packages/ui/src/features/auth/UserAvatar.tsx | 7 +++- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/packages/core/src/auth/avatarColor.test.ts b/packages/core/src/auth/avatarColor.test.ts index 9b6becf58c..5efa1979f1 100644 --- a/packages/core/src/auth/avatarColor.test.ts +++ b/packages/core/src/auth/avatarColor.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from "vitest"; -import { avatarColorClass } from "./avatarColor"; +import { avatarColorVar } from "./avatarColor"; -describe("avatarColorClass", () => { +describe("avatarColorVar", () => { it("is deterministic for a given seed", () => { - expect(avatarColorClass("user-uuid-123")).toBe( - avatarColorClass("user-uuid-123"), + expect(avatarColorVar("user-uuid-123")).toBe( + avatarColorVar("user-uuid-123"), ); }); - it("always returns a white-text palette class", () => { + it("returns a Radix color-scale CSS variable reference", () => { for (const seed of ["a", "raquel@posthog.com", "uuid", "", "James Doe"]) { - expect(avatarColorClass(seed)).toMatch(/^bg-\(--[a-z]+-9\) text-white$/); + expect(avatarColorVar(seed)).toMatch(/^var\(--[a-z]+-9\)$/); } }); - it("spreads distinct seeds across more than one hue", () => { + it("spreads distinct seeds across more than one color", () => { const seeds = Array.from({ length: 40 }, (_, i) => `person-${i}`); - const distinct = new Set(seeds.map(avatarColorClass)); + const distinct = new Set(seeds.map(avatarColorVar)); expect(distinct.size).toBeGreaterThan(1); }); }); diff --git a/packages/core/src/auth/avatarColor.ts b/packages/core/src/auth/avatarColor.ts index b055b636fe..51aec286b9 100644 --- a/packages/core/src/auth/avatarColor.ts +++ b/packages/core/src/auth/avatarColor.ts @@ -1,20 +1,18 @@ -// Deterministic per-user avatar color. A stable seed (user uuid, email, or name) -// always maps to the same palette entry, so a given person keeps one color across -// every view and session. All entries use a Radix "9" step whose accessible pairing -// is white text (the light-9 scales — amber/yellow/lime/mint/sky — are excluded). -const AVATAR_PALETTE = [ - "bg-(--orange-9) text-white", - "bg-(--blue-9) text-white", - "bg-(--purple-9) text-white", - "bg-(--green-9) text-white", - "bg-(--pink-9) text-white", - "bg-(--teal-9) text-white", - "bg-(--red-9) text-white", - "bg-(--indigo-9) text-white", - "bg-(--cyan-9) text-white", - "bg-(--violet-9) text-white", - "bg-(--jade-9) text-white", - "bg-(--crimson-9) text-white", +// Radix color scales whose "9" step pairs with white text (the light-9 scales — +// amber/yellow/lime/mint/sky — are excluded so initials stay legible). +const AVATAR_COLORS = [ + "orange", + "blue", + "purple", + "green", + "pink", + "teal", + "red", + "indigo", + "cyan", + "violet", + "jade", + "crimson", ] as const; export function avatarColorSeedHash(seed: string): number { @@ -25,6 +23,12 @@ export function avatarColorSeedHash(seed: string): number { return hash; } -export function avatarColorClass(seed: string): string { - return AVATAR_PALETTE[avatarColorSeedHash(seed) % AVATAR_PALETTE.length]; +// Returns a CSS custom-property reference (e.g. "var(--orange-9)") for a stable +// seed, applied via inline style. We return a CSS var rather than a Tailwind +// `bg-*` class because this file lives in @posthog/core, which the app's Tailwind +// content globs don't scan — a dynamically-chosen utility class here would never +// be generated, so the bubble would fall back to the neutral default. +export function avatarColorVar(seed: string): string { + const color = AVATAR_COLORS[avatarColorSeedHash(seed) % AVATAR_COLORS.length]; + return `var(--${color}-9)`; } diff --git a/packages/core/src/inbox/artefacts.ts b/packages/core/src/inbox/artefacts.ts index 04b26fe9ad..1978f710bc 100644 --- a/packages/core/src/inbox/artefacts.ts +++ b/packages/core/src/inbox/artefacts.ts @@ -48,7 +48,22 @@ export function extractSuggestedReviewers( return artefact?.content ?? []; } -export { avatarColorClass as reviewerAvatarToneClass } from "@posthog/core/auth/avatarColor"; +const AVATAR_PALETTE = [ + "bg-(--orange-9) text-white", + "bg-(--blue-9) text-white", + "bg-(--purple-9) text-white", + "bg-(--green-9) text-white", + "bg-(--pink-9) text-white", + "bg-(--teal-9) text-white", +] as const; + +export function reviewerAvatarToneClass(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973; + } + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; +} export function reviewerInitials( name: string | null | undefined, diff --git a/packages/ui/src/features/auth/UserAvatar.tsx b/packages/ui/src/features/auth/UserAvatar.tsx index 743aade23d..3de6f1767b 100644 --- a/packages/ui/src/features/auth/UserAvatar.tsx +++ b/packages/ui/src/features/auth/UserAvatar.tsx @@ -1,4 +1,4 @@ -import { avatarColorClass } from "@posthog/core/auth/avatarColor"; +import { avatarColorVar } from "@posthog/core/auth/avatarColor"; import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useGravatarUrl } from "@posthog/ui/features/auth/useGravatarUrl"; @@ -29,7 +29,10 @@ export function UserAvatar({ {gravatarUrl ? ( ) : null} - + {getUserInitials(user)} From 05cb54698ebd93084869e9458953a2ca9a3a4d3c Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 13:02:00 -0700 Subject: [PATCH 3/6] Use PostHog's Lettermark palette with paired text colors Port the 16 background/text color pairs and the index%16 selection from PostHog's Lettermark. Each background ships with a paired text color chosen for contrast, and the values are theme-independent, so initials stay legible in both light and dark mode. avatarColor(seed) now returns {bg, text}, applied together as inline styles on the fallback. Generated-By: PostHog Code Task-Id: 03a1a20f-7597-4267-bf73-0ef6224e71e7 --- packages/core/src/auth/avatarColor.test.ts | 16 +++--- packages/core/src/auth/avatarColor.ts | 53 +++++++++++--------- packages/ui/src/features/auth/UserAvatar.tsx | 8 ++- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/packages/core/src/auth/avatarColor.test.ts b/packages/core/src/auth/avatarColor.test.ts index 5efa1979f1..235d51fbc9 100644 --- a/packages/core/src/auth/avatarColor.test.ts +++ b/packages/core/src/auth/avatarColor.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from "vitest"; -import { avatarColorVar } from "./avatarColor"; +import { avatarColor } from "./avatarColor"; -describe("avatarColorVar", () => { +describe("avatarColor", () => { it("is deterministic for a given seed", () => { - expect(avatarColorVar("user-uuid-123")).toBe( - avatarColorVar("user-uuid-123"), - ); + expect(avatarColor("user-uuid-123")).toEqual(avatarColor("user-uuid-123")); }); - it("returns a Radix color-scale CSS variable reference", () => { + it("returns a paired bg/text hex color", () => { for (const seed of ["a", "raquel@posthog.com", "uuid", "", "James Doe"]) { - expect(avatarColorVar(seed)).toMatch(/^var\(--[a-z]+-9\)$/); + const color = avatarColor(seed); + expect(color.bg).toMatch(/^#[0-9a-f]{6}$/); + expect(color.text).toMatch(/^#[0-9a-f]{6}$/); } }); it("spreads distinct seeds across more than one color", () => { const seeds = Array.from({ length: 40 }, (_, i) => `person-${i}`); - const distinct = new Set(seeds.map(avatarColorVar)); + const distinct = new Set(seeds.map((s) => avatarColor(s).bg)); expect(distinct.size).toBeGreaterThan(1); }); }); diff --git a/packages/core/src/auth/avatarColor.ts b/packages/core/src/auth/avatarColor.ts index 51aec286b9..09a0c7590c 100644 --- a/packages/core/src/auth/avatarColor.ts +++ b/packages/core/src/auth/avatarColor.ts @@ -1,18 +1,29 @@ -// Radix color scales whose "9" step pairs with white text (the light-9 scales — -// amber/yellow/lime/mint/sky — are excluded so initials stay legible). -const AVATAR_COLORS = [ - "orange", - "blue", - "purple", - "green", - "pink", - "teal", - "red", - "indigo", - "cyan", - "violet", - "jade", - "crimson", +export interface AvatarColor { + bg: string; + text: string; +} + +// Profile-avatar palette ported verbatim from PostHog's Lettermark (the 16 +// --lettermark-N-bg / --lettermark-N-text pairs). Each background is paired with +// a text color picked for legible contrast, and the values are theme-independent, +// so they hold up in both light and dark mode. +const AVATAR_PALETTE: readonly AvatarColor[] = [ + { bg: "#dcb1e3", text: "#572e5e" }, + { bg: "#ffc4b2", text: "#3e5891" }, + { bg: "#b1985d", text: "#3e5891" }, + { bg: "#3e5891", text: "#ffc4b2" }, + { bg: "#8da9e7", text: "#572e5e" }, + { bg: "#572e5e", text: "#dcb1e3" }, + { bg: "#ffc035", text: "#35416b" }, + { bg: "#ff906e", text: "#2a3d65" }, + { bg: "#5dd4db", text: "#1a4a4d" }, + { bg: "#c8e65c", text: "#3d4a1a" }, + { bg: "#e85da4", text: "#4a1a35" }, + { bg: "#e85c5c", text: "#4a1a1a" }, + { bg: "#3d7a5a", text: "#c8f0dc" }, + { bg: "#6b5bd4", text: "#e0ddf5" }, + { bg: "#98e8c8", text: "#1a4a3d" }, + { bg: "#8c3d5a", text: "#f0d0dc" }, ] as const; export function avatarColorSeedHash(seed: string): number { @@ -23,12 +34,8 @@ export function avatarColorSeedHash(seed: string): number { return hash; } -// Returns a CSS custom-property reference (e.g. "var(--orange-9)") for a stable -// seed, applied via inline style. We return a CSS var rather than a Tailwind -// `bg-*` class because this file lives in @posthog/core, which the app's Tailwind -// content globs don't scan — a dynamically-chosen utility class here would never -// be generated, so the bubble would fall back to the neutral default. -export function avatarColorVar(seed: string): string { - const color = AVATAR_COLORS[avatarColorSeedHash(seed) % AVATAR_COLORS.length]; - return `var(--${color}-9)`; +// Picks a stable palette entry for a seed, mirroring Lettermark's `index % 16` +// variant selection. +export function avatarColor(seed: string): AvatarColor { + return AVATAR_PALETTE[avatarColorSeedHash(seed) % AVATAR_PALETTE.length]; } diff --git a/packages/ui/src/features/auth/UserAvatar.tsx b/packages/ui/src/features/auth/UserAvatar.tsx index 3de6f1767b..2bb24d6ef6 100644 --- a/packages/ui/src/features/auth/UserAvatar.tsx +++ b/packages/ui/src/features/auth/UserAvatar.tsx @@ -1,4 +1,4 @@ -import { avatarColorVar } from "@posthog/core/auth/avatarColor"; +import { avatarColor } from "@posthog/core/auth/avatarColor"; import { Avatar, AvatarFallback, AvatarImage } from "@posthog/quill"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useGravatarUrl } from "@posthog/ui/features/auth/useGravatarUrl"; @@ -23,16 +23,14 @@ export function UserAvatar({ }: UserAvatarProps) { const gravatarUrl = useGravatarUrl(user?.email); const seed = user?.uuid ?? user?.email ?? userDisplayName(user); + const color = avatarColor(seed); return ( {gravatarUrl ? ( ) : null} - + {getUserInitials(user)} From 98ffa54bcf3b5eec5b1a3d4ea5ac0256ec0a204a Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 13:18:46 -0700 Subject: [PATCH 4/6] Reset gravatar url when the avatar's email changes A reused UserAvatar whose email prop changed kept the previous person's Gravatar URL until the new email's hash resolved, briefly showing the wrong photo. Clear the URL at the start of each hash so initials show during the transition. Adds a regression test. Generated-By: PostHog Code Task-Id: 03a1a20f-7597-4267-bf73-0ef6224e71e7 --- .../src/features/auth/useGravatarUrl.test.ts | 22 +++++++++++++++++++ .../ui/src/features/auth/useGravatarUrl.ts | 3 +++ 2 files changed, 25 insertions(+) diff --git a/packages/ui/src/features/auth/useGravatarUrl.test.ts b/packages/ui/src/features/auth/useGravatarUrl.test.ts index a3133e0fb1..652936afa9 100644 --- a/packages/ui/src/features/auth/useGravatarUrl.test.ts +++ b/packages/ui/src/features/auth/useGravatarUrl.test.ts @@ -25,4 +25,26 @@ describe("useGravatarUrl", () => { ), ); }); + + it("clears the previous URL while a changed email is hashing", async () => { + const { result, rerender } = renderHook( + ({ email }) => useGravatarUrl(email), + { initialProps: { email: "user@example.com" } }, + ); + await waitFor(() => + expect(result.current).toContain( + "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514", + ), + ); + + rerender({ email: "test@example.com" }); + // The prior person's URL must not linger during the new hash. + expect(result.current).toBeUndefined(); + + await waitFor(() => + expect(result.current).toContain( + "973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b", + ), + ); + }); }); diff --git a/packages/ui/src/features/auth/useGravatarUrl.ts b/packages/ui/src/features/auth/useGravatarUrl.ts index a16ff1ac16..c0bc24c33e 100644 --- a/packages/ui/src/features/auth/useGravatarUrl.ts +++ b/packages/ui/src/features/auth/useGravatarUrl.ts @@ -24,6 +24,9 @@ export function useGravatarUrl(email?: string | null): string | undefined { return; } let cancelled = false; + // Clear any prior URL so a reused avatar whose email just changed shows + // initials during the async hash rather than the previous person's photo. + setUrl(undefined); gravatarUrlForEmail(email) .then((next) => { if (!cancelled) setUrl(next); From d6326d209cc5418e4a39560a38f406773fe5a959 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 19:50:20 -0700 Subject: [PATCH 5/6] Update packages/ui/src/features/auth/useGravatarUrl.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/ui/src/features/auth/useGravatarUrl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/features/auth/useGravatarUrl.ts b/packages/ui/src/features/auth/useGravatarUrl.ts index c0bc24c33e..7a6b4420ef 100644 --- a/packages/ui/src/features/auth/useGravatarUrl.ts +++ b/packages/ui/src/features/auth/useGravatarUrl.ts @@ -26,6 +26,7 @@ export function useGravatarUrl(email?: string | null): string | undefined { let cancelled = false; // Clear any prior URL so a reused avatar whose email just changed shows // initials during the async hash rather than the previous person's photo. + let cancelled = false; setUrl(undefined); gravatarUrlForEmail(email) .then((next) => { From 1be46bdcb598e18a45560b6c108bf2e420ad7055 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 20:30:44 -0700 Subject: [PATCH 6/6] Remove duplicate cancelled declaration in useGravatarUrl A suggestion applied to the branch left two `let cancelled = false;` declarations in the same block, which fails to compile and broke the useGravatarUrl tests. Drop the duplicate. Generated-By: PostHog Code Task-Id: 03a1a20f-7597-4267-bf73-0ef6224e71e7 --- packages/ui/src/features/auth/useGravatarUrl.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/features/auth/useGravatarUrl.ts b/packages/ui/src/features/auth/useGravatarUrl.ts index 7a6b4420ef..c0bc24c33e 100644 --- a/packages/ui/src/features/auth/useGravatarUrl.ts +++ b/packages/ui/src/features/auth/useGravatarUrl.ts @@ -26,7 +26,6 @@ export function useGravatarUrl(email?: string | null): string | undefined { let cancelled = false; // Clear any prior URL so a reused avatar whose email just changed shows // initials during the async hash rather than the previous person's photo. - let cancelled = false; setUrl(undefined); gravatarUrlForEmail(email) .then((next) => {