Redesign navbar with avatar dropdown and mobile drawer

Stream C from docs/information-architecture.md. The navbar's account
cluster (<username> + Settings + Logout) was three controls for the
same concept; the cluster now collapses behind an avatar dropdown.
On mobile, all primary nav and account controls move into a
hamburger drawer so the top bar stays at logo + bell + hamburger
without wrapping at small viewports.

- New apps/journal/app/components/Avatar.tsx — initials-only avatar
  (no image-upload story yet; the component is the single place to
  add image fallback when one lands).
- New apps/journal/app/components/AccountDropdown.tsx — click the
  avatar trigger to reveal Profile / Settings / Log Out menuitems.
  Click-outside + Escape-to-close. role="menu" + role="menuitem" for
  a11y.
- New apps/journal/app/components/MobileNavMenu.tsx — hamburger
  trigger + slide-out drawer at md and below. Contains all primary
  nav (Feed, Explore, Routes, Activities, Notifications) plus the
  account cluster (Profile, Settings, Log Out). Auto-closes on
  navigation; locks body scroll while open.
- apps/journal/app/root.tsx — refactor NavBar:
  * Brand + primary nav links hide at md and below (drawer covers
    them).
  * Right cluster: bell on every viewport; avatar dropdown on
    desktop, hamburger on mobile.
  * Loader exposes user.displayName so Avatar can compute initials.
  * Drops the now-unused Form import; Form moved into the
    dropdown/drawer components.

E2E tests updated:
- e2e/auth.test.ts — logout helper opens the avatar dropdown via
  aria-label = displayName||username, then clicks the Log Out
  menuitem. Username assertions in nav switch from getByText to
  getByRole("button", {name: username}) (the avatar button).
- e2e/settings.test.ts — "settings link visible in nav" opens the
  avatar dropdown first, then asserts the Settings menuitem.

i18n: nav.openMenu, nav.closeMenu keys for the hamburger trigger
labels (EN + DE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-26 09:56:20 +02:00
parent 65e6699dc4
commit 4e2bfb0bbe
8 changed files with 372 additions and 66 deletions

View file

@ -35,8 +35,12 @@ async function registerUser(page: Page, email: string, username: string) {
await page.getByRole("button", { name: /Register with Passkey/ }).click();
}
async function logout(page: Page) {
await page.getByRole("navigation").getByRole("button", { name: "Log Out" }).click();
async function logout(page: Page, accountLabel: string) {
// The account cluster lives inside an avatar dropdown now. Click the
// avatar (its aria-label is displayName || username), then click the
// Log Out menuitem inside the popup.
await page.getByRole("navigation").getByRole("button", { name: accountLabel }).click();
await page.getByRole("menuitem", { name: "Log Out" }).click();
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });
}
@ -51,16 +55,19 @@ test.describe("Passkey Authentication", () => {
// Register
await registerUser(page, email, username);
await expect(page).toHaveURL("/", { timeout: 10000 });
await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 });
// The avatar button's aria-label is the displayName or username;
// for a freshly-registered user with no displayName set, that's
// the username.
await expect(page.getByRole("navigation").getByRole("button", { name: username })).toBeVisible({ timeout: 5000 });
// Log out
await logout(page);
await logout(page, username);
// Sign in with passkey
await page.goto("/auth/login");
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("navigation").getByRole("button", { name: username })).toBeVisible({ timeout: 5000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
@ -81,11 +88,12 @@ test.describe("Passkey Authentication", () => {
const authenticatorId = await setupVirtualAuthenticator(cdp);
const email = `dup-${Date.now()}@example.com`;
const firstUsername = `first${Date.now()}`;
// Register first user
await registerUser(page, email, `first${Date.now()}`);
await registerUser(page, email, firstUsername);
await expect(page).toHaveURL("/", { timeout: 10000 });
await logout(page);
await logout(page, firstUsername);
// Try to register with same email
await registerUser(page, email, `second${Date.now()}`);
@ -103,7 +111,7 @@ test.describe("Passkey Authentication", () => {
// Register first user
await registerUser(page, `first-${Date.now()}@example.com`, username);
await expect(page).toHaveURL("/", { timeout: 10000 });
await logout(page);
await logout(page, username);
// Try to register with same username
await registerUser(page, `second-${Date.now()}@example.com`, username);

View file

@ -139,18 +139,22 @@ test.describe("Account Settings", () => {
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("settings link visible in nav when logged in", async ({ page }) => {
test("settings link reachable from nav when logged in", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await registerAndLogin(page, cdp);
const { username } = await registerAndLogin(page, cdp);
await expect(page.getByRole("navigation").getByRole("link", { name: "Settings" })).toBeVisible();
// Settings now lives inside the avatar dropdown; opening the
// dropdown reveals the menuitem.
await page.getByRole("navigation").getByRole("button", { name: username }).click();
await expect(page.getByRole("menuitem", { name: "Settings" })).toBeVisible();
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("settings link not visible when logged out", async ({ page }) => {
await page.goto("/");
// Anonymous navbar has neither the avatar nor the Settings link.
await expect(page.getByRole("navigation").getByRole("link", { name: "Settings" })).not.toBeVisible();
});