Account menu
What this solves
Gestalt hierarchy for the signed-in identity: the trigger is identity-shaped content (avatar, name, role), not a bare icon button, so the account control reads as "this is you" before it reads as "click me for a menu." The panel then separates who-you-are (name, email, workspace) from where-you-go (account, settings) from the one action that leaves.
Use it when
- The topbar's signed-in identity, in either the full rail or a collapsed sidebar footspeedway AccountMenu.tsx: same component, a compact prop shrinks it to the avatar alone
- A sidebar foot seat where the trigger already reads name and rolewalmart-mvp SidebarUserMenu: IdentityRow supplies both the trigger and would supply the panel header
Rendered
Fixture data; check both themes.patterns/account-menu/example.tsxCopy this into a fresh route and it renders as above.
"use client";import type { IconKeys } from "@versable-git/ui";import { Button, Dropdown, IdentityRow, RenderIcon, StatusPill, Tabs } from "@versable-git/ui";import { useEffect, useState } from "react";// The two live shapes converge here: speedway's initials-disc trigger with a// name/role block (AccountMenu.tsx:41-71) and walmart's IdentityRow already// doing double duty as trigger content (App.tsx:204-213). This fixture reuses// IdentityRow both places, once compact in the trigger and once full-size as// the panel header, the way neither app quite does on its own.type View = "closed" | "open" | "staff" | "signed-out";const VIEWS: { value: View; label: string }[] = [ { value: "closed", label: "Closed" }, { value: "open", label: "Open" }, { value: "staff", label: "Staff" }, { value: "signed-out", label: "Signed out" },];const member = { name: "Priya Raman", email: "priya@versable.ai", role: "Owner", workspace: "Speedway Motors", initials: "PR",};const staff = { name: "Dev Ops", email: "dev@versable-internal.ai", role: "Staff", workspace: "Internal", initials: "DO",};function MenuLink({ Icon, label }: { Icon: IconKeys; label: string }) { return ( <button type="button" className="hover:bg-base-200 rounded-field flex items-center gap-2.5 px-2.5 py-2 text-left text-sm transition-colors" > <RenderIcon Icon={Icon} size={14} className="text-base-content/45" /> {label} </button> );}export function AccountMenu() { const [view, setView] = useState<View>("closed"); const [open, setOpen] = useState(false); // The dropdown's own open state is real (click the trigger). Switching the // fixture's view just forces it to match what that view is meant to show. useEffect(() => { setOpen(view === "open" || view === "staff"); }, [view]); const user = view === "staff" ? staff : member; const isStaff = view === "staff"; return ( <div className="flex flex-col gap-4"> <Tabs size="sm" value={view} onChange={(v) => setView(v as View)} items={VIEWS} /> {view === "signed-out" ? ( <div className="border-base-300 rounded-field flex items-center gap-2.5 border border-dashed px-3 py-2"> <span className="bg-base-300 flex size-7 shrink-0 items-center justify-center rounded-full"> <RenderIcon Icon="User" size={13} className="text-base-content/45" /> </span> <span className="text-base-content/65 text-sm">Not signed in</span> <Button size="xs" variant="text" shade color="primary" className="ml-auto"> Sign in </Button> </div> ) : ( <Dropdown open={open} setOpen={setOpen} placement="bottom-start" button={ <button type="button" className="hover:bg-base-200 rounded-field flex w-fit cursor-pointer items-center gap-1 py-1 pr-2 pl-1 transition-colors" > <IdentityRow avatar={user.initials} title={user.name} subtitle={user.role} size="sm" /> <RenderIcon Icon="Down" size={12} className="text-base-content/45" /> </button> } > <div className="bg-base-100 border-base-300 rounded-field flex w-56 flex-col border p-1.5 shadow-(--shadow-md)"> <IdentityRow avatar={user.initials} title={user.name} subtitle={user.email} /> <div className="px-2.5 py-1"> <StatusPill kind="neutral" size="sm"> {user.workspace} </StatusPill> </div> <div className="border-base-300 my-1 border-t" /> <MenuLink Icon="User" label="Account" /> <MenuLink Icon="Settings" label="Settings" /> {isStaff && <MenuLink Icon="Admin" label="Admin" />} <div className="border-base-300 my-1 border-t" /> {/* No color at all marks a dead-end/exit action (design-language doc 2, §A2.2). */} <button type="button" className="hover:bg-base-200 rounded-field text-base-content/70 flex items-center gap-2.5 px-2.5 py-2 text-left text-sm transition-colors" > <RenderIcon Icon="SignOut" size={14} className="text-base-content/45" /> Sign out </button> </div> </Dropdown> )} </div> );}Where it ships
speedway/app/components/AccountMenu.tsx:25-40initials-disc trigger inside a Dropdown; placement and compact props seat it in the topbar or a collapsed railwalmart-mvp/frontend/src/App.tsx:192-249SidebarUserMenu: IdentityRow as the trigger, workspace/connection status and settings in the panel
App-specific: No theme row in this menu: both apps keep the theme toggle in the topbar shell, and the playground's own rule is one theme toggle, in the navbar (doc 55 O3), so the menu does not grow a second one. The sign-out row here carries no color per design-language doc 2, §A2 rule 2 ('No color at all marks a dead-end/exit action'); walmart's live SidebarUserMenu still colors it text-error, so this fixture shows the canon call rather than either app's exact rendering.