Summary
frontend/src/components/layout/ProfileMenu.tsx is a hand-rolled popover (custom open state, onBlur/Escape handling, role="menu" div, Tab-based focus order). The canonical Nebari app-header recipe (.claude/skills/nebari-ui/SKILL.md, "Profile / account menu" and "Theme picker") builds the profile menu from the registry DropdownMenu (modal={false}, DropdownMenuTrigger variant="ghost", DropdownMenuContent align="end" className="w-[248px] p-2") with the Light/Dark/System picker as a Base UI Menu.RadioGroup / Menu.RadioItem (closeOnClick={false}) wired to useTheme().
The registry re-sync PR applied the mechanical parts (248px width, token-based shadow, ThemeMode from @nebari/use-theme-preference, text-sign-out-foreground) but left the composition alone because it is a structural change.
Motivation
- One header across Nebari apps: keyboard behaviour (arrow-key navigation, typeahead, focus return) and open/close semantics should come from the shared
DropdownMenu rather than a bespoke implementation.
- Removes ~100 lines of focus/blur bookkeeping that the registry primitive already handles.
- The theme picker should read/write theme state via
useTheme() directly instead of props threaded from App through Layout.
Acceptance Criteria
Out of Scope
- Adding a notifications bell (nebi has no in-app notification feed).
- Introducing an avatar primitive; the registry does not publish
@nebari/avatar, so the current <img> / initial fallback stays.
- Other hand-rolled menus (e.g.
SplitButton).
Summary
frontend/src/components/layout/ProfileMenu.tsxis a hand-rolled popover (customopenstate,onBlur/Escape handling,role="menu"div, Tab-based focus order). The canonical Nebari app-header recipe (.claude/skills/nebari-ui/SKILL.md, "Profile / account menu" and "Theme picker") builds the profile menu from the registryDropdownMenu(modal={false},DropdownMenuTrigger variant="ghost",DropdownMenuContent align="end" className="w-[248px] p-2") with the Light/Dark/System picker as a Base UIMenu.RadioGroup/Menu.RadioItem(closeOnClick={false}) wired touseTheme().The registry re-sync PR applied the mechanical parts (248px width, token-based shadow,
ThemeModefrom@nebari/use-theme-preference,text-sign-out-foreground) but left the composition alone because it is a structural change.Motivation
DropdownMenurather than a bespoke implementation.useTheme()directly instead of props threaded fromAppthroughLayout.Acceptance Criteria
ProfileMenurendersDropdownMenu modal={false}+DropdownMenuTrigger/DropdownMenuContentfrom@/components/ui/dropdown-menu(248px,p-2), keeping the avatar/initial + name +ChevronDowntrigger.Menu.RadioGroupof threeMenu.RadioItems exposingrole="menuitemradio"+aria-checked, withcloseOnClick={false}, wired touseTheme()from@/hooks/theme-provider.Layoutno longer needsthemeMode/onThemeChangeprops (it can keepisDarkModefor the logo, or readuseTheme()itself).DropdownMenuItemwithtext-sign-out-foreground(nebi is a Wails desktop app in local mode; the item stays conditional on team mode as today).ProfileMenu.test.tsxand the e2eselectThemehelper are updated for menu keyboard semantics (arrow keys instead of Tab between items) and still pass.Out of Scope
@nebari/avatar, so the current<img>/ initial fallback stays.SplitButton).