From cc8778b457256ac99401338f6d0c3655345c4e74 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 18 Aug 2026 19:35:18 -0700 Subject: [PATCH] feat(ui): show a user icon in the profile menu trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header profile trigger rendered an empty rounded square, since no avatar data exists to put in it. Fill it with a lucide UserRound behind an Avatar primitive, so the frame that already reserved the space reads as a person rather than a gap. Colors come from the muted / muted-foreground tokens, which index.css redefines under .dark, so light and dark need no conditional logic here and no dark: variants. A test asserts the fallback carries no dark: prefix, to keep a future hardcoded override from creeping back in. UserAvatar owns the fallback ladder rather than HeaderProfileMenu inlining an icon, and takes an optional src. The API exposes no avatar field today, so that leg is unused — but Radix Avatar is what handles the image load/error swap, which is the part that gets ugly to retrofit once profile pictures land. The primitive comes from the radix-ui umbrella package, already a dependency, so this adds none. Sizing stays size-6 rounded-md with overflow-hidden: an image later fills the same box and the header geometry does not move. The trigger button already carries aria-label={displayName}, so the icon is decorative and adds no second accessible name and no new i18n strings. Signed-off-by: Anna Effort --- .../layout/HeaderProfileMenu.test.tsx | 6 +++ src/components/layout/HeaderProfileMenu.tsx | 4 +- src/components/ui/avatar.test.tsx | 42 ++++++++++++++++ src/components/ui/avatar.tsx | 42 ++++++++++++++++ src/components/ui/user-avatar.test.tsx | 48 +++++++++++++++++++ src/components/ui/user-avatar.tsx | 36 ++++++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/avatar.test.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/user-avatar.test.tsx create mode 100644 src/components/ui/user-avatar.tsx diff --git a/src/components/layout/HeaderProfileMenu.test.tsx b/src/components/layout/HeaderProfileMenu.test.tsx index b1becce..6fd91e5 100644 --- a/src/components/layout/HeaderProfileMenu.test.tsx +++ b/src/components/layout/HeaderProfileMenu.test.tsx @@ -63,6 +63,12 @@ describe("HeaderProfileMenu", () => { expect(screen.getByRole("button", { name: "Bobo Example" })).toBeInTheDocument(); }); + it("renders an avatar icon in the trigger", () => { + // Regression: the trigger used to hold an empty placeholder box. + const { container } = renderMenu(); + expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument(); + }); + it("navigates to settings from the dropdown", async () => { const user = userEvent.setup(); renderMenu(); diff --git a/src/components/layout/HeaderProfileMenu.tsx b/src/components/layout/HeaderProfileMenu.tsx index 1170caf..e3f6c7f 100644 --- a/src/components/layout/HeaderProfileMenu.tsx +++ b/src/components/layout/HeaderProfileMenu.tsx @@ -4,6 +4,7 @@ import { useAuth } from "../../auth/useAuth"; import { useTheme } from "../../hooks/useTheme"; import { useRouter } from "../../router"; import { Button } from "@/components/ui/button"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { DropdownMenu, DropdownMenuContent, @@ -26,14 +27,13 @@ export function HeaderProfileMenu() { return ( - {/* TODO: User photo/avatar data is not currently available, using fallback for now. */} diff --git a/src/components/ui/avatar.test.tsx b/src/components/ui/avatar.test.tsx new file mode 100644 index 0000000..ab3e05b --- /dev/null +++ b/src/components/ui/avatar.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Avatar, AvatarFallback } from "./avatar"; + +describe("Avatar", () => { + it("renders the root with its data-slot", () => { + const { container } = render(); + expect(container.querySelector('[data-slot="avatar"]')).toBeInTheDocument(); + }); + + it("merges a custom className over the defaults", () => { + const { container } = render(); + const root = container.querySelector('[data-slot="avatar"]')!; + // tailwind-merge must drop the conflicting defaults, not stack them. + expect(root).toHaveClass("size-6", "rounded-md"); + expect(root).not.toHaveClass("size-8", "rounded-full"); + }); + + it("clips overflowing children so images cannot escape the frame", () => { + const { container } = render(); + expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("overflow-hidden"); + }); + + it("renders fallback content when there is no image", () => { + render( + + AB + , + ); + expect(screen.getByText("AB")).toBeInTheDocument(); + }); + + it("gives the fallback theme-aware surface and foreground tokens", () => { + const { container } = render( + + AB + , + ); + const fallback = container.querySelector('[data-slot="avatar-fallback"]')!; + expect(fallback).toHaveClass("bg-muted", "text-muted-foreground"); + }); +}); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..5614c41 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { Avatar as AvatarPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/user-avatar.test.tsx b/src/components/ui/user-avatar.test.tsx new file mode 100644 index 0000000..f4bfd03 --- /dev/null +++ b/src/components/ui/user-avatar.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { UserAvatar } from "./user-avatar"; + +describe("UserAvatar", () => { + it("falls back to the user icon when no image is available", () => { + const { container } = render(); + const icon = container.querySelector('[data-slot="avatar-fallback"] svg'); + expect(icon).toBeInTheDocument(); + }); + + it("hides the fallback icon from assistive tech", () => { + // The control wrapping the avatar carries the accessible name, so the icon + // must not announce a second one. + const { container } = render(); + const icon = container.querySelector('[data-slot="avatar-fallback"] svg')!; + expect(icon).toHaveAttribute("aria-hidden", "true"); + }); + + it("colors the icon from theme tokens rather than fixed values", () => { + const { container } = render(); + const fallback = container.querySelector('[data-slot="avatar-fallback"]')!; + expect(fallback).toHaveClass("bg-muted", "text-muted-foreground"); + // A hardcoded or dark:-prefixed color would defeat the token indirection. + expect(fallback.className).not.toMatch(/dark:/); + }); + + it("sizes to the 24px header slot by default", () => { + const { container } = render(); + expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("size-6", "rounded-md"); + }); + + it("accepts a className override", () => { + const { container } = render(); + const root = container.querySelector('[data-slot="avatar"]')!; + expect(root).toHaveClass("size-10"); + expect(root).not.toHaveClass("size-6"); + }); + + it("mounts the image slot only when a src is supplied", () => { + // jsdom never resolves the image load, so Radix keeps the fallback visible + // and withholds the . Asserting on the mounted-vs-absent Image child + // is not possible here; the meaningful check is that passing a src neither + // crashes nor removes the fallback, so there is never an empty frame. + const { container } = render(); + expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/user-avatar.tsx b/src/components/ui/user-avatar.tsx new file mode 100644 index 0000000..72045a4 --- /dev/null +++ b/src/components/ui/user-avatar.tsx @@ -0,0 +1,36 @@ +import { UserRound } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +interface UserAvatarProps { + /** + * Profile image URL. The API exposes no avatar field today, so this is + * normally undefined and the icon fallback renders instead. + */ + src?: string; + /** + * Alt text for the image. Defaults to empty: the avatar is decorative when + * the control wrapping it already carries the user's name. + */ + alt?: string; + className?: string; +} + +/** + * A user's avatar, falling back to a neutral icon when no image is available. + * + * Colors come from the `muted` / `muted-foreground` tokens, which are redefined + * under `.dark` in index.css, so light and dark are handled without any + * theme-conditional logic here. + */ +export function UserAvatar({ src, alt = "", className }: UserAvatarProps) { + return ( + + {src ? : null} + + + + ); +}