diff --git a/app/benchmark/components/Tooltip.tsx b/app/benchmark/components/Tooltip.tsx index f57b258..4a4da08 100644 --- a/app/benchmark/components/Tooltip.tsx +++ b/app/benchmark/components/Tooltip.tsx @@ -1,9 +1,11 @@ -import * as React from "react"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +'use client'; + +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; interface TooltipProps { - children: React.ReactNode; - content: React.ReactNode; + children: ReactNode; + content: ReactNode; className?: string; delayDuration?: number; side?: "top" | "right" | "bottom" | "left"; @@ -18,20 +20,34 @@ const Tooltip = ({ side = "top", align = "center", }: TooltipProps) => { + const trigger: ReactElement = isValidElement(children) ? ( + children + ) : ( + + ); + return ( - + - {children} + - - {content} - - + + {content} + + + + + diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 1113e76..e47b283 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -1,20 +1,20 @@ 'use client'; -import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useInsertionEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Dialog } from '@base-ui/react/dialog'; -import { AnimatePresence, motion, useMotionTemplate, useMotionValue, useReducedMotion, type MotionValue } from 'motion/react'; +import { AnimatePresence, easeOut, motion, useIsPresent, useMotionTemplate, useMotionValue, usePresenceData, useReducedMotion, type MotionValue } from 'motion/react'; import { Toaster } from 'sonner'; import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation'; -import { BLUE, BORDER, BRAND_BLUE, DISABLED, INK, MUTED, SELECTED } from '../theme'; +import { BLUE, BORDER, DISABLED, INK, MUTED, SELECTED } from '../theme'; import { getChangeBySlug } from '../upgrades/data/changes'; import { demoLabel } from '../vibenet/demos/catalogue'; import { getUpgradeById } from '../upgrades/data/upgrades'; import { trackNavClick } from '../analytics/events'; -import { navSlideDirection } from './nav-motion'; +import { navExitingHighlightPath, navSlideDirection, type NavPresenceCustom } from './nav-motion'; import { NavScrollArea } from './NavScrollArea'; import { AnimatedBaseLogo, BaseMark } from './ui/AnimatedBaseLogo'; import { Breadcrumb } from './ui/Breadcrumb'; @@ -76,14 +76,7 @@ const styles: Record = { display: 'flex', flexDirection: 'column', }, - // Clips the pane's horizontal slide so it never becomes overflow-x on the - // scroll viewport. Height is content-sized, so this does not clip vertically. - navSlideClip: { - overflow: 'hidden', - }, - // `isolation` keeps the selected pill (z-index -1) in this stacking context - // so it paints behind the row's label instead of behind the sidebar. - nav: { display: 'flex', flexDirection: 'column', gap: 2, position: 'relative', isolation: 'isolate' }, + nav: { display: 'flex', flexDirection: 'column', gap: 2 }, navLink: { textDecoration: 'none', color: 'inherit' }, navRow: { display: 'flex', @@ -92,9 +85,6 @@ const styles: Record = { padding: '9px 10px', borderRadius: 8, fontSize: 14, - // Anchors the selected pill and the `.nav-row-hover` fill, both of which are - // absolutely positioned within the row. - position: 'relative', }, navIcon: { display: 'inline-flex', width: 20, height: 20 }, soon: { @@ -125,7 +115,6 @@ const styles: Record = { padding: '9px 10px', borderRadius: 8, textDecoration: 'none', - color: 'var(--bds-gray-50)', }, footerIcon: { display: 'inline-flex', width: 18, height: 18 }, // Hugs the switch rather than filling the row: with no label beside it, a @@ -151,10 +140,8 @@ const styles: Record = { height: 20, padding: 2, borderRadius: 999, - background: 'var(--bds-gray-50)', boxSizing: 'border-box', }, - switchTrackOn: { background: BRAND_BLUE }, switchThumb: { display: 'inline-flex', alignItems: 'center', @@ -162,11 +149,12 @@ const styles: Record = { width: 16, height: 16, borderRadius: '50%', - // Reads against both the grey off-track and the blue on-track. + // Reads against both the grey off-track and the blue on-track. On-state + // color and travel are Tailwind `dark:` variants on the thumb, keyed off + // `html[data-theme]` — React state lags a frame behind the pre-paint + // script, and driving those here flashes the switch on reload. background: 'var(--bds-gray-0)', - color: 'var(--bds-gray-50)', }, - switchThumbOn: { transform: 'translateX(14px)', color: BRAND_BLUE }, main: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }, // Grow on short pages so the activity drawer can sit at the bottom; don't // shrink, or tall pages compress instead of letting the document scroll. @@ -298,31 +286,8 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate }: color = active ? 'var(--bds-gray-80)' : 'var(--bds-gray-50)'; } - const row = ( -
- {active && ( -
- )} + const content = ( + <> {icon && ( @@ -335,21 +300,33 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate }: )} -
+ ); - if (!enabled) return row; + const rowStyle = { + ...styles.navRow, + ...styles.navLink, + color, + fontWeight: active ? 500 : 400, + cursor: enabled ? 'pointer' : 'default', + }; + + if (!enabled) { + return
{content}
; + } + return ( { if (opensInNewTab(event)) return; trackNavClick(label); onNavigate?.(); }} > - {row} + {content} ); } @@ -363,12 +340,37 @@ function opensInNewTab(event: ReactMouseEvent): boolean { } const slideVariants = { - enter: (direction: number) => ({ x: direction > 0 ? '60%' : '-60%', opacity: 0 }), - center: { x: 0, opacity: 1 }, - exit: (direction: number) => ({ x: direction > 0 ? '-60%' : '60%', opacity: 0 }), + enter: ({ direction }: NavPresenceCustom) => ({ x: direction > 0 ? 10 : -10, opacity: 0, filter: 'blur(1px)' }), + center: { x: 0, opacity: 1, filter: 'none' }, + exit: ({ direction }: NavPresenceCustom) => ({ x: direction > 0 ? -10 : 10, opacity: 0, filter: 'blur(1px)' }), }; -const slideTransition = { duration: 0.2, ease: [0.23, 1, 0.32, 1] as const }; +function useExitingHighlightPath(presentPath: string): string { + const isPresent = useIsPresent(); + return navExitingHighlightPath(isPresent, usePresenceData(), presentPath); +} + +function TopNavList({ highlightPath, onSelect }: { highlightPath: string; onSelect: (href: string) => void }) { + const path = useExitingHighlightPath(highlightPath); + return ( + + ); +} + +const slideTransition = { duration: 0.2, ease: easeOut, x: { visualDuration: 0.2, type: 'spring', bounce: 0 } }; /** Matches `h-9` / theme(spacing.9). */ const APP_BANNER_HEIGHT = '2.25rem'; @@ -377,9 +379,24 @@ const APP_BANNER_HEIGHT = '2.25rem'; // slipped through), so it just has to be longer than a slow route. const PENDING_PATH_TIMEOUT_MS = 5000; +const SIDEBAR_FOOTER_LINK = + 'group outline-none text-bds-gray-50 transition-colors duration-150 hover:text-bds-gray-80'; + +const SIDEBAR_FOOTER_LINK_LABEL = + 'inline-flex items-center gap-2.5 rounded-lg group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-brand-blue'; + +const NAV_FOCUS_RING = + 'outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-blue'; + +// Track and thumb. Shared with the `:not(...)` list in disableAnimation so the +// page-wide no-transition stamp cannot override the 180ms slide. +const THEME_SWITCH_ANIM = 'theme-switch-anim'; + // Rides inside the switch thumb. Stroke is heavier than the nav glyphs' 1.8 -// because at 10px that weight all but disappears. -function ThemeIcon({ dark }: { dark: boolean }) { +// because at 10px that weight all but disappears. Both glyphs are in the +// tree so `dark:` (html[data-theme]) can pick the right one on first paint, +// before React hydrates the stored preference. +function ThemeIcon() { const common = { width: 10, height: 10, @@ -390,15 +407,16 @@ function ThemeIcon({ dark }: { dark: boolean }) { strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, }; - return dark ? ( - - - - ) : ( - - - - + return ( + <> + + + + + + + + ); } @@ -462,22 +480,25 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC }; const direction = directionRef.current; + // highlightPath rides AnimatePresence custom so the exiting root list can + // move the pill — its React props are frozen on the previous commit. + const presenceCustom: NavPresenceCustom = { direction, highlightPath: activePath }; return ( <>
- + {!hideBrand && (
)} -
- +
+ {activeParent ? ( { if (opensInNewTab(event)) return; // Stay on the current page and keep the mobile drawer @@ -538,7 +559,7 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC ) : ( - + )} @@ -569,38 +577,46 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC
- - - - - + + + + + + + + Status - Status - - - - - + + + + + + + + Support - Support - - - - - + + + + + + + + Docs - Docs
- - - - - + + + + + + + + Blog - Blog {/* `role="switch"` rather than a plain button: the control reports a state rather than firing an action, so screen readers announce "on"/"off" @@ -613,19 +629,19 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC aria-checked={dark} aria-label="Dark mode" onClick={onToggleTheme} - className="nav-header-hover theme-switch" + className="group outline-none" style={{ ...styles.footerLink, ...styles.themeButton }} > - + @@ -666,7 +682,14 @@ function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerP New! EIP-8130: Accounts - + { + if (opensInNewTab(event)) return; + onDismiss(); + }} + > Test on Vibenet @@ -685,6 +708,31 @@ function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerP ); } +// next-themes `disableTransitionOnChange`: stamp a global `transition: none` +// rule, apply the theme, force a restyle, then drop the rule on the next tick +// so color tokens don't animate through every `transition-colors` on the page. +// The switch is excluded: its motion is the control, not a side effect. +// https://github.com/pacocoursey/next-themes/blob/main/next-themes/src/index.tsx +function disableAnimation() { + const css = document.createElement('style'); + css.appendChild( + document.createTextNode( + `*:not(.${THEME_SWITCH_ANIM}),*:not(.${THEME_SWITCH_ANIM})::before,*:not(.${THEME_SWITCH_ANIM})::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`, + ), + ); + document.head.appendChild(css); + + return () => { + // Force restyle + (() => window.getComputedStyle(document.body))(); + + // Wait for next tick before removing + setTimeout(() => { + document.head.removeChild(css); + }, 1); + }; +} + export function AppShell({ children }: PropsWithChildren) { const pathname = usePathname() || '/'; const title = titleForPath(pathname); @@ -692,24 +740,39 @@ export function AppShell({ children }: PropsWithChildren) { const sidebarHeight = useMotionTemplate`calc(100dvh - ${bannerHeight})`; const [menuOpen, setMenuOpen] = useState(false); const [bannerDismissed, setBannerDismissed] = useState(false); - // Starts false on both server and client so the first render matches; the - // effect below reads the attribute the pre-paint script in layout.tsx set. + // 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. const [dark, setDark] = useState(false); + // Stays false through mount and the hydration sync below so we don't overwrite + // the pre-paint script with the SSR default (`dark` starts false). + const applyTheme = useRef(false); useEffect(() => { setDark(document.documentElement.dataset.theme === 'dark'); }, []); - const toggleTheme = () => { - const nextDark = !dark; - document.documentElement.dataset.theme = nextDark ? 'dark' : 'light'; - setDark(nextDark); + // Lock page transitions before React commits so color tokens don't tween + // (next-themes disableTransitionOnChange). The switch is opted out of that + // stamp and keeps its 180ms slide. + useInsertionEffect(() => { + if (!applyTheme.current) return; + const next = dark ? 'dark' : 'light'; + if (document.documentElement.dataset.theme === next) return; + const restore = disableAnimation(); + document.documentElement.dataset.theme = next; try { - localStorage.setItem('theme', nextDark ? 'dark' : 'light'); + localStorage.setItem('theme', next); } catch { // Private browsing or a blocked-storage profile — the theme still applies // for this session, it just won't survive a reload. } + restore(); + }, [dark]); + + const toggleTheme = () => { + applyTheme.current = true; + setDark(document.documentElement.dataset.theme !== 'dark'); }; return ( diff --git a/app/components/NavScrollArea.tsx b/app/components/NavScrollArea.tsx index 493a4ad..a789692 100644 --- a/app/components/NavScrollArea.tsx +++ b/app/components/NavScrollArea.tsx @@ -5,6 +5,7 @@ import { PropsWithChildren, Ref } from 'react'; type NavScrollAreaProps = PropsWithChildren<{ viewportRef?: Ref; + contentClassName?: string; }>; /** @@ -13,11 +14,11 @@ type NavScrollAreaProps = PropsWithChildren<{ * hidden and a thin custom thumb appears while scrolling. The bottom fade is * a CSS mask on the viewport driven by `--scroll-area-overflow-y-end`. */ -export function NavScrollArea({ children, viewportRef }: NavScrollAreaProps) { +export function NavScrollArea({ children, viewportRef, contentClassName }: NavScrollAreaProps) { return ( - + {children} diff --git a/app/components/nav-motion.test.ts b/app/components/nav-motion.test.ts index 9519e46..86bae02 100644 --- a/app/components/nav-motion.test.ts +++ b/app/components/nav-motion.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { navSlideDirection, SCROLL_FADE_MAX_PX, scrollEdges } from './nav-motion'; +import { navExitingHighlightPath, navSlideDirection, SCROLL_FADE_MAX_PX, scrollEdges } from './nav-motion'; describe('navSlideDirection', () => { it('slides forward when entering a section', () => { @@ -21,6 +21,23 @@ describe('navSlideDirection', () => { }); }); +describe('navExitingHighlightPath', () => { + const custom = { direction: 1, highlightPath: '/vibenet' }; + + it('uses the live path while the pane is present', () => { + expect(navExitingHighlightPath(true, custom, '/')).toBe('/'); + }); + + it('reads AnimatePresence custom after the pane is removed', () => { + expect(navExitingHighlightPath(false, custom, '/')).toBe('/vibenet'); + }); + + it('falls back when custom is not a presence payload', () => { + expect(navExitingHighlightPath(false, 1, '/')).toBe('/'); + expect(navExitingHighlightPath(false, null, '/snapshots')).toBe('/snapshots'); + }); +}); + describe('scrollEdges', () => { it('returns no fade when content fits the viewport', () => { expect(scrollEdges(0, 200, 200)).toEqual({ top: 0, bottom: 0 }); diff --git a/app/components/nav-motion.ts b/app/components/nav-motion.ts index 7a9e023..0b29119 100644 --- a/app/components/nav-motion.ts +++ b/app/components/nav-motion.ts @@ -14,6 +14,37 @@ export function navSlideDirection( return currParentHref ? 1 : -1; } +/** + * Payload on AnimatePresence `custom`. Direction drives the slide variants; + * `highlightPath` is for the exiting pane, whose React props are frozen. + */ +export type NavPresenceCustom = { + direction: number; + highlightPath: string; +}; + +/** + * Highlight path for a nav pane. Present panes use their live props. Exiting + * panes read `highlightPath` off AnimatePresence custom — the same render that + * unmounts the pane can still move the pill. + */ +export function navExitingHighlightPath( + isPresent: boolean, + custom: unknown, + presentPath: string, +): string { + if (isPresent) return presentPath; + if ( + typeof custom === 'object' && + custom !== null && + 'highlightPath' in custom && + typeof custom.highlightPath === 'string' + ) { + return custom.highlightPath; + } + return presentPath; +} + export const SCROLL_FADE_MAX_PX = 40; export type ScrollEdges = { diff --git a/app/components/ui/InfoTooltip.tsx b/app/components/ui/InfoTooltip.tsx index 4b8312a..f3e6741 100644 --- a/app/components/ui/InfoTooltip.tsx +++ b/app/components/ui/InfoTooltip.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as Tooltip from '@radix-ui/react-tooltip'; import type { ReactNode } from 'react'; +import { Tooltip } from '@base-ui/react/tooltip'; import { cn } from './cn'; @@ -17,42 +17,55 @@ type InfoTooltipProps = { className?: string; }; +/** Open chevron: fill covers the popup’s inset ring at the join; stroke only the slants. */ +function TooltipCaret() { + return ( + + + + ); +} + /** * Small "ⓘ" affordance that reveals a short explanation on hover or focus. * * The trigger is a real + + - - {children} - - + + + {children} + + + diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx index c2583da..2b6ace7 100644 --- a/app/components/ui/Select.tsx +++ b/app/components/ui/Select.tsx @@ -1,6 +1,6 @@ 'use client'; -import * as RadixSelect from '@radix-ui/react-select'; +import { Select as BaseSelect } from '@base-ui/react/select'; import { cn } from './cn'; @@ -26,10 +26,10 @@ type SelectProps = { className?: string; }; -// Styled wrapper over @radix-ui/react-select — the accessible, popper-positioned -// counterpart to the native FilterSelect. Ported from the account demo's -// SelectMenu/SelectMenuItem, restyled onto bds tokens. Supports a flat option -// list, labelled groups, or both. +// Styled wrapper over Base UI Select — portaled + positioned so the menu never +// participates in layout (Radix's item-align / scroll-into-view was jumping +// the Account Details modal). Same public API as before: flat options, labelled +// groups, or both. export function Select({ value, onValueChange, @@ -40,19 +40,29 @@ export function Select({ disabled, className, }: SelectProps) { + const items = [ + ...options.map((option) => ({ value: option.value, label: option.label })), + ...groups.flatMap((group) => group.options.map((option) => ({ value: option.value, label: option.label }))), + ]; + return ( - - { + if (next != null) onValueChange(next); + }} + items={items} + disabled={disabled} + > + - - - - + + - - - - , so it competes with the modal in the root stacking context; + + + + , + // so it competes with the modal in the root stacking context; // anything lower renders behind the panel when a Select sits in a Modal. + // alignItemWithTrigger must stay false: the default lines the selected + // row up with the trigger and is what shifted this form. // See the layer scale in globals.css. - className="z-[130] max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-bds-gray-10 bg-background shadow-lg dark:border-white/10 dark:bg-[#1a1a1a]" + className="z-[130] outline-none" + align="start" + sideOffset={6} + alignItemWithTrigger={false} > - - - - - {options.map((option) => ( - - ))} - {groups.map((group) => ( - - {options.length > 0 ? ( - - ) : null} - - {group.label} - - {group.options.map((option) => ( - - ))} - - ))} - - - - - - - + + + {options.map((option) => ( + + ))} + {groups.map((group) => ( + + {options.length > 0 ? ( + + ) : null} + + {group.label} + + {group.options.map((option) => ( + + ))} + + ))} + + + + + ); } function SelectItem({ option }: { option: SelectOption }) { return ( - - + ✓ - - {option.label} - + + {option.label} + ); } diff --git a/app/components/ui/Tabs.tsx b/app/components/ui/Tabs.tsx index b292893..4d052a9 100644 --- a/app/components/ui/Tabs.tsx +++ b/app/components/ui/Tabs.tsx @@ -67,6 +67,7 @@ export function Tabs({