diff --git a/packages/core/src/auth/avatarColor.test.ts b/packages/core/src/auth/avatarColor.test.ts
new file mode 100644
index 0000000000..235d51fbc9
--- /dev/null
+++ b/packages/core/src/auth/avatarColor.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { avatarColor } from "./avatarColor";
+
+describe("avatarColor", () => {
+ it("is deterministic for a given seed", () => {
+ expect(avatarColor("user-uuid-123")).toEqual(avatarColor("user-uuid-123"));
+ });
+
+ it("returns a paired bg/text hex color", () => {
+ for (const seed of ["a", "raquel@posthog.com", "uuid", "", "James Doe"]) {
+ 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((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
new file mode 100644
index 0000000000..09a0c7590c
--- /dev/null
+++ b/packages/core/src/auth/avatarColor.ts
@@ -0,0 +1,41 @@
+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 {
+ let hash = 0;
+ for (let i = 0; i < seed.length; i += 1) {
+ hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973;
+ }
+ return hash;
+}
+
+// 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
new file mode 100644
index 0000000000..2bb24d6ef6
--- /dev/null
+++ b/packages/ui/src/features/auth/UserAvatar.tsx
@@ -0,0 +1,38 @@
+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";
+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);
+ const color = avatarColor(seed);
+
+ 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..652936afa9
--- /dev/null
+++ b/packages/ui/src/features/auth/useGravatarUrl.test.ts
@@ -0,0 +1,50 @@
+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",
+ ),
+ );
+ });
+
+ 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
new file mode 100644
index 0000000000..c0bc24c33e
--- /dev/null
+++ b/packages/ui/src/features/auth/useGravatarUrl.ts
@@ -0,0 +1,43 @@
+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;
+ // 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);
+ })
+ .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)}
-
+