Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLElement | null>(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.
Expand Down Expand Up @@ -712,6 +714,7 @@ export function AppShell({ children }: PropsWithChildren) {
};

return (
<DrawerSlotContext.Provider value={drawerSlot}>
<div className="flex min-h-dvh flex-col">
<div style={styles.root}>
{/* Desktop sidebar */}
Expand Down Expand Up @@ -741,6 +744,7 @@ export function AppShell({ children }: PropsWithChildren) {
<Dialog.Portal>
<Dialog.Popup className="drawer outline-none [transform:translate3d(0,0,0)] transition-transform duration-300 ease-out data-[ending-style]:[transform:translate3d(100%,0,0)] data-[starting-style]:[transform:translate3d(100%,0,0)] motion-reduce:transition-none">
<Dialog.Title className="sr-only">Menu</Dialog.Title>
<div ref={setDrawerSlot} className="shrink-0 px-2 pt-3 empty:hidden" />
<SidebarContent
dark={dark}
onToggleTheme={toggleTheme}
Expand Down Expand Up @@ -792,5 +796,6 @@ export function AppShell({ children }: PropsWithChildren) {
</div>
<Toaster position="top-center" style={{ '--width': '300px' } as React.CSSProperties} toastOptions={{ className: 'text-[13px] tracking-[0px]' }} />
</div>
</DrawerSlotContext.Provider>
);
}
11 changes: 11 additions & 0 deletions app/components/DrawerSlot.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(null);

export function useDrawerSlot() {
return useContext(DrawerSlotContext);
}
10 changes: 5 additions & 5 deletions app/components/ui/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type BreadcrumbProps = {
export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: BreadcrumbProps) {
const backHref = middle?.href ?? parentHref;
return (
<span className="relative flex w-full items-center justify-center">
<span className="relative flex w-full min-w-0 items-center justify-center px-24">
<Link
href={backHref}
className="group absolute left-0 flex h-8 w-8 items-center justify-center rounded-full text-bds-gray-40 no-underline transition-colors hover:text-foreground"
Expand All @@ -35,8 +35,8 @@ export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: Brea
/>
</svg>
</Link>
<span className="flex items-center gap-2">
<Link href={parentHref} className="no-underline">
<span data-breadcrumb-trail className="flex min-w-0 items-center gap-2 whitespace-nowrap">
<Link href={parentHref} className="min-w-0 truncate no-underline">
<Text as="span" variant="headline" className="text-bds-gray-40">
{parentLabel}
</Text>
Expand All @@ -46,7 +46,7 @@ export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: Brea
</Text>
{middle ? (
<>
<Link href={middle.href} className="no-underline">
<Link href={middle.href} className="min-w-0 truncate no-underline">
<Text as="span" variant="headline" className="text-bds-gray-40">
{middle.label}
</Text>
Expand All @@ -56,7 +56,7 @@ export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: Brea
</Text>
</>
) : null}
<Text as="span" variant="headline">
<Text as="span" variant="headline" className="shrink-0">
{childLabel}
</Text>
</span>
Expand Down
64 changes: 46 additions & 18 deletions app/vibenet/demos/_components/AccountDemoShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,28 +47,53 @@ export function AccountDemoShell({
}: AccountDemoShellProps) {
const engine = useAccountEngine();
const [topbarSlot, setTopbarSlot] = useState<HTMLElement | null>(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);
useEffect(() => {
setTopbarSlot(document.getElementById('topbar-actions-slot'));
}, []);

const switcher = (
<AccountSwitcher
accounts={engine.accounts}
activeAccountId={engine.activeAccountId}
onSelect={engine.setActiveAccountId}
onCreate={onCreate}
onDelete={engine.deleteAccount}
onDetails={(id) => {
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<HTMLElement>('[data-account-label]');
const trail = header.querySelector<HTMLElement>('[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 (
<>
Expand All @@ -77,7 +103,11 @@ export function AccountDemoShell({
<div className={cn('relative -mb-20 flex min-w-0 flex-1 flex-col text-foreground', className)}>
{/* 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(<AccountSwitcher {...switcherProps} collapsed={collapsed} />, topbarSlot) : null}
{/* Mobile: the top bar is hidden, so the switcher lives in the hamburger menu. */}
{hasAccounts && drawerSlot
? createPortal(<AccountSwitcher {...switcherProps} triggerClassName="w-full" />, drawerSlot)
: null}

<DemoGate
accounts={engine.accounts}
Expand All @@ -86,8 +116,6 @@ export function AccountDemoShell({
title={gateTitle}
description={gateDescription}
>
{/* Mobile only — desktop uses the top-bar switcher. */}
<div className="shrink-0 md:hidden">{switcher}</div>
{children}
{activity ? (
<ActivityDrawer count={activityCount} emptyMessage={activityEmptyMessage}>
Expand Down
11 changes: 10 additions & 1 deletion app/vibenet/demos/_shared/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -85,6 +87,7 @@ export function AccountSwitcher({
onDelete,
onCreate,
triggerClassName,
collapsed = false,
}: AccountSwitcherProps) {
const [open, setOpen] = useState(false);

Expand Down Expand Up @@ -151,6 +154,7 @@ export function AccountSwitcher({
}}
>
<Popover.Trigger
aria-label={collapsed && active ? `Account: ${active.label}` : undefined}
className={cn(
'group flex h-10 items-center justify-between gap-2 rounded-lg bg-transparent px-2.5 outline-none',
'ring-1 ring-inset ring-bds-gray-10',
Expand All @@ -163,7 +167,12 @@ export function AccountSwitcher({
{active ? (
<span className="flex min-w-0 items-center gap-2">
<AccountAvatar variant="default" size={22} />
<span className="max-w-[180px] truncate text-[13px] font-medium">{active.label}</span>
<span
data-account-label
className={cn('max-w-[180px] truncate text-[13px] font-medium', collapsed && '-ml-2 w-0 overflow-hidden')}
>
{active.label}
</span>
</span>
) : (
<Text as="span" variant="label.medium" tone="muted">
Expand Down
Loading