From 3cd24912470e92169eb493361895690055d55ffc Mon Sep 17 00:00:00 2001 From: Danyal Prout Date: Tue, 6 Oct 2026 10:44:04 -0500 Subject: [PATCH] fix(ui): stop account switcher overlapping the topbar breadcrumb The switcher is absolutely positioned in the topbar, so on narrower desktop widths it painted over the centered breadcrumb. Collapse it to avatar + chevron only when the full label wouldn't fit next to the breadcrumb, and let the parent crumbs truncate so the current page title stays readable. On mobile, move the switcher into the hamburger menu instead of the page. --- app/components/AppShell.tsx | 5 ++ app/components/DrawerSlot.tsx | 11 ++++ app/components/ui/Breadcrumb.tsx | 10 +-- .../demos/_components/AccountDemoShell.tsx | 64 +++++++++++++------ app/vibenet/demos/_shared/AccountSwitcher.tsx | 11 +++- 5 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 app/components/DrawerSlot.tsx diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index e40e709d..fec4a11a 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -14,6 +14,7 @@ import { demoLabel, demoBreadcrumb } from '../vibenet/demos/catalogue'; import { trackNavClick } from '../analytics/events'; import { navExitingHighlightPath, navSlideDirection, type NavPresenceCustom } from './nav-motion'; +import { DrawerSlotContext } from './DrawerSlot'; import { NavScrollArea } from './NavScrollArea'; import { AnimatedBaseLogo, BaseMark } from './ui/AnimatedBaseLogo'; import { Breadcrumb } from './ui/Breadcrumb'; @@ -676,6 +677,7 @@ export function AppShell({ children }: PropsWithChildren) { const pathname = usePathname() || '/'; const title = titleForPath(pathname); const [menuOpen, setMenuOpen] = useState(false); + const [drawerSlot, setDrawerSlot] = useState(null); // aria-checked only. The switch's look is CSS against html[data-theme], // which the pre-paint script already set — this state starts false so SSR // and the first client render match, then catches up after mount. @@ -712,6 +714,7 @@ export function AppShell({ children }: PropsWithChildren) { }; return ( +
{/* Desktop sidebar */} @@ -741,6 +744,7 @@ export function AppShell({ children }: PropsWithChildren) { Menu +
+ ); } diff --git a/app/components/DrawerSlot.tsx b/app/components/DrawerSlot.tsx new file mode 100644 index 00000000..43e433cc --- /dev/null +++ b/app/components/DrawerSlot.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +// The mobile menu drawer's actions slot. Null while the drawer is closed, since +// the drawer only mounts its contents when open. +export const DrawerSlotContext = createContext(null); + +export function useDrawerSlot() { + return useContext(DrawerSlotContext); +} diff --git a/app/components/ui/Breadcrumb.tsx b/app/components/ui/Breadcrumb.tsx index fb536451..9702d142 100644 --- a/app/components/ui/Breadcrumb.tsx +++ b/app/components/ui/Breadcrumb.tsx @@ -17,7 +17,7 @@ type BreadcrumbProps = { export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: BreadcrumbProps) { const backHref = middle?.href ?? parentHref; return ( - + - - + + {parentLabel} @@ -46,7 +46,7 @@ export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: Brea {middle ? ( <> - + {middle.label} @@ -56,7 +56,7 @@ export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: Brea ) : null} - + {childLabel} diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx index 60fb7e38..506dbffa 100644 --- a/app/vibenet/demos/_components/AccountDemoShell.tsx +++ b/app/vibenet/demos/_components/AccountDemoShell.tsx @@ -12,9 +12,10 @@ // Each demo renders this inside one AccountEngineProvider, avoiding duplicate // store instances and repeated account-settings wiring. -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; +import { useDrawerSlot } from '../../../components/DrawerSlot'; import { cn } from '../../../components/ui/cn'; import { AccountSwitcher } from '../_shared/AccountSwitcher'; import { ActivityDrawer } from '../_shared/ActivityDrawer'; @@ -46,6 +47,8 @@ export function AccountDemoShell({ }: AccountDemoShellProps) { const engine = useAccountEngine(); const [topbarSlot, setTopbarSlot] = useState(null); + const drawerSlot = useDrawerSlot(); + const [collapsed, setCollapsed] = useState(false); // The switcher and the empty-state gate both open the create-account drawer. const [createOpen, setCreateOpen] = useState(false); const onCreate = () => setCreateOpen(true); @@ -53,21 +56,44 @@ export function AccountDemoShell({ setTopbarSlot(document.getElementById('topbar-actions-slot')); }, []); - const switcher = ( - { - const addr = engine.accounts.find((a) => a.id === id)?.address; - if (addr) window.open(`/vibenet/explorer/address/${addr}`, '_blank', 'noopener,noreferrer'); - }} - /> - ); - const hasAccounts = engine.accounts.length > 0; + const activeLabel = engine.accounts.find((a) => a.id === engine.activeAccountId)?.label; + + // Collapse the topbar switcher to avatar + chevron only when the expanded + // control would run into the centered breadcrumb; otherwise show the label. + useLayoutEffect(() => { + const header = topbarSlot?.parentElement; + if (!topbarSlot || !header || !hasAccounts) return; + const measure = () => { + const trigger = topbarSlot.firstElementChild as HTMLElement | null; + const label = topbarSlot.querySelector('[data-account-label]'); + const trail = header.querySelector('[data-breadcrumb-trail]'); + if (!trigger || !label || !trail) return; + const naturalLabel = Math.min(label.scrollWidth, 180); + const expandedWidth = trigger.offsetWidth + (label.offsetWidth < naturalLabel ? 8 + naturalLabel : 0); + const parts = Array.from(trail.children) as HTMLElement[]; + const trailWidth = parts.reduce((sum, el) => sum + Math.max(el.scrollWidth, el.offsetWidth), 0) + 8 * (parts.length - 1); + // Switcher sits 28px from the right edge; keep 16px clear of the trail. + const room = header.clientWidth / 2 - trailWidth / 2 - expandedWidth - 28 - 16; + setCollapsed(room < 0); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(header); + return () => observer.disconnect(); + }, [topbarSlot, hasAccounts, activeLabel]); + + const switcherProps = { + accounts: engine.accounts, + activeAccountId: engine.activeAccountId, + onSelect: engine.setActiveAccountId, + onCreate, + onDelete: engine.deleteAccount, + onDetails: (id: string) => { + const addr = engine.accounts.find((a) => a.id === id)?.address; + if (addr) window.open(`/vibenet/explorer/address/${addr}`, '_blank', 'noopener,noreferrer'); + }, + }; return ( <> @@ -77,7 +103,11 @@ export function AccountDemoShell({
{/* Desktop: the switcher lives in the app top bar. Hidden until an account exists so the gate reads as a clean full-page empty state. */} - {hasAccounts && topbarSlot ? createPortal(switcher, topbarSlot) : null} + {hasAccounts && topbarSlot ? createPortal(, topbarSlot) : null} + {/* Mobile: the top bar is hidden, so the switcher lives in the hamburger menu. */} + {hasAccounts && drawerSlot + ? createPortal(, drawerSlot) + : null} - {/* Mobile only — desktop uses the top-bar switcher. */} -
{switcher}
{children} {activity ? ( diff --git a/app/vibenet/demos/_shared/AccountSwitcher.tsx b/app/vibenet/demos/_shared/AccountSwitcher.tsx index a3f17931..7ab2b5d0 100644 --- a/app/vibenet/demos/_shared/AccountSwitcher.tsx +++ b/app/vibenet/demos/_shared/AccountSwitcher.tsx @@ -75,6 +75,8 @@ type AccountSwitcherProps = { onCreate?: () => void; /** Extra classes for the trigger button (e.g. `w-full` inside a form). */ triggerClassName?: string; + /** Hide the label, leaving the avatar and chevron (for tight spaces like the topbar). */ + collapsed?: boolean; }; export function AccountSwitcher({ @@ -85,6 +87,7 @@ export function AccountSwitcher({ onDelete, onCreate, triggerClassName, + collapsed = false, }: AccountSwitcherProps) { const [open, setOpen] = useState(false); @@ -151,6 +154,7 @@ export function AccountSwitcher({ }} > - {active.label} + + {active.label} + ) : (