From e97b76c1ba4817753bd06a584d4bfa0a2b91e155 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 18 Aug 2026 19:35:17 -0700 Subject: [PATCH 1/2] fix(build): break the vendor <-> vendor-react chunk cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manualChunks matched packages by bare substring, so /react-dom/ also caught @floating-ui/react-dom and swept it into vendor-react, which then imported @floating-ui/dom back out of vendor. Rollup reported the result as "Circular chunk: vendor -> vendor-react -> vendor", one warning among the chunk-size advice it prints on every build. A cycle leaves rollup to pick an execution order, and the losing side evaluates against a namespace object that does not exist yet. When that order flips, the app dies at module-eval with "Cannot set properties of undefined (setting 'Activity')" — Activity being a React 19 export — and serves a blank page with no other console output. Nothing about the message points at bundling, so it reads like a React or auth fault. Anchor the match to the package root so only the real react packages land in vendor-react, and hold that chunk to react, react-dom, scheduler and react-is. A leaf chunk imports nothing from its siblings and so cannot be one end of a cycle, whatever else moves between chunks later. react-intl, @formatjs, sonner and the react-remove-scroll family move to vendor, alongside the helpers they already import from there. This was latent rather than new: rollup happened to pick a working order for as long as chunk composition held still. Any unrelated change that shifts it can flip the order, so the failure surfaces attached to whichever commit disturbed the bundle rather than to this one. Signed-off-by: Anna Effort --- vite.config.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index f18f1ff..f381f3a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,19 +36,14 @@ export default defineConfig({ output: { manualChunks(id) { if (!id.includes("node_modules")) return; - if ( - id.includes("/react/") || - id.includes("/react-dom/") || - id.includes("/scheduler/") || - id.includes("/react-is/") || - id.includes("/react-remove-scroll") || - id.includes("/react-style-singleton") || - id.includes("/use-callback-ref") || - id.includes("/use-sidecar") || - id.includes("react-intl") || - id.includes("@formatjs") || - id.includes("/sonner/") - ) return "vendor-react"; + // Anchored to the package root: a bare `/react-dom/` substring also + // matches @floating-ui/react-dom, which drags @floating-ui/dom in + // from `vendor` and makes vendor <-> vendor-react circular. Keep this + // chunk a leaf — react-adjacent packages (react-intl, @formatjs, + // sonner, react-remove-scroll) belong in `vendor`, since they import + // helpers that live there. + if (/node_modules\/(react|react-dom|scheduler|react-is)\//.test(id)) + return "vendor-react"; if (id.includes("@radix-ui") || id.includes("radix-ui")) return "vendor-radix"; if (id.includes("lucide-react")) return "vendor-lucide"; return "vendor"; From 3428cca951ff93f22a828a5d0f9c56c7d6c8ecd9 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Wed, 19 Aug 2026 03:20:23 -0700 Subject: [PATCH 2/2] feat(ui): show a user icon in the profile menu trigger (#46) 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} + + + + ); +}