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} + ) : (