diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index d21cfaa..b88f1e7 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -14,6 +14,8 @@ import { getUpgradeById } from '../upgrades/data/upgrades'; import { titleForPath } from '../navigation'; import { trackNavClick } from '../analytics/events'; +import { navSlideDirection } from './nav-motion'; +import { NavScrollArea } from './NavScrollArea'; import { AnimatedBaseLogo, BaseMark } from './ui/AnimatedBaseLogo'; import { Breadcrumb } from './ui/Breadcrumb'; import { cn } from './ui/cn'; @@ -49,20 +51,38 @@ const styles: Record = { borderRight: `1px solid ${SELECTED}`, display: 'flex', flexDirection: 'column', - padding: '0 12px 20px', + padding: '0 0 20px', position: 'relative', overflow: 'hidden', }, brand: { display: 'flex', alignItems: 'center', - gap: 10, - padding: '0 8px', - height: 64, flexShrink: 0, position: 'relative', }, brandLink: { display: 'inline-block', lineHeight: 0 }, + // Slot between the pinned footer and the top of the sidebar. Overflow hidden + // clips the popLayout slide; scroll lives on NavScrollArea, which wraps the + // logo and the sliding panes so the mark rolls away with the list. + navSlot: { + flex: 1, + minHeight: 0, + position: 'relative', + overflow: 'hidden', + }, + // Sizes to its links. Height used to be 100% when each pane owned the + // scroller; now the scroller is outside and a stretched pane would loop + // against the content height. + navPane: { + 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` makes the nav a stacking context so the active-row pill (which // renders at z-index -1 and travels across rows while animating) paints behind // every row's label instead of on top of the rows it passes over. @@ -89,12 +109,17 @@ const styles: Record = { borderRadius: 999, padding: '1px 6px', }, + // Pinned below the sliding panes so Status/Support/Docs/Blog stay put when + // a section sub-nav slides in. The switch sits on the last row beside Blog. sidebarFooter: { - marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 2, - position: 'relative', + flexShrink: 0, + }, + footerLastRow: { + display: 'flex', + alignItems: 'center', }, footerLink: { display: 'flex', @@ -106,17 +131,6 @@ const styles: Record = { color: 'var(--bds-gray-50)', }, footerIcon: { display: 'inline-flex', width: 18, height: 18 }, - // Sits outside the sliding nav container so the toggle stays pinned to the - // bottom of the sidebar on sub-nav routes too, where `sidebarFooter` (which - // lives inside the main-nav pane) has slid away. - // - // Absolute rather than in flow so it shares a line with the last footer link: - // those links are inside the sliding pane and this is not, so as a flow sibling - // it could only ever stack below them. Taking it out of flow lets the pane occupy - // the full height, which drops the footer onto the bottom row beside the switch. - // Offsets live in `.theme-switch-footer` because they mirror the container's own - // padding, and the desktop sidebar and mobile drawer pad differently. - themeFooter: { display: 'flex', justifyContent: 'flex-end' }, // Hugs the switch rather than filling the row: with no label beside it, a // full-width button would hover-fill a strip of empty sidebar. The inherited // footer-row padding keeps the hit target at 54×38, comfortably past the 24×24 @@ -320,7 +334,7 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l {label} {!enabled && Soon} {hasChildren && ( - + )} @@ -413,15 +427,18 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop const activeParent = getActiveParent(activePath); const directionRef = useRef(1); const prevParentRef = useRef(activeParent?.href ?? null); - - useEffect(() => { - const prev = prevParentRef.current; - const curr = activeParent?.href ?? null; - if (prev !== curr) { - directionRef.current = curr ? 1 : -1; - prevParentRef.current = curr; - } - }, [activeParent]); + const scrollViewportRef = useRef(null); + const paneKey = activeParent?.href ?? 'main'; + + // Direction must update during render. An effect applies one commit late, + // so the first click of the section back-header still slid as if going in. + const prevParent = prevParentRef.current; + const currParent = activeParent?.href ?? null; + const nextDirection = navSlideDirection(prevParent, currParent); + if (nextDirection !== null) { + directionRef.current = nextDirection; + prevParentRef.current = currParent; + } // Any commit hands control back to the router, including a Back that lands // somewhere other than the tapped href. @@ -435,6 +452,13 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop return () => clearTimeout(timer); }, [pendingPath]); + // Shared scroller keeps one logo across pane swaps, so a leftover scrollTop + // from the previous list would hide it. Jump back to the top on each swap. + useEffect(() => { + const viewport = scrollViewportRef.current; + if (viewport) viewport.scrollTop = 0; + }, [paneKey]); + const selectPath = (href: string) => { setPendingPath(href); onNavigate?.(); @@ -444,178 +468,182 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop return ( <> - {!hideBrand && ( -
- -
- )} - -
- - {activeParent ? ( - - { - if (opensInNewTab(event)) return; - selectPath('/'); - }} - > -
+ +
+ )} +
+ + {activeParent ? ( + - - - - {activeParent.label} - - - - ) : ( - - - - - - )} - + {activeParent.label} + + + + ) : ( + + + + )} + +
+
-
- {/* `role="switch"` rather than a plain button: the control reports a state - rather than firing an action, so screen readers announce "on"/"off" - against a stable label instead of the label itself changing. The switch - renders bare, so that name comes from `aria-label` — the visible track - is decorative and hidden from the tree. */} - + +
); diff --git a/app/components/NavScrollArea.tsx b/app/components/NavScrollArea.tsx new file mode 100644 index 0000000..493a4ad --- /dev/null +++ b/app/components/NavScrollArea.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { ScrollArea } from '@base-ui/react/scroll-area'; +import { PropsWithChildren, Ref } from 'react'; + +type NavScrollAreaProps = PropsWithChildren<{ + viewportRef?: Ref; +}>; + +/** + * Scrollable middle of the sidebar: logo, then the sliding pane (main nav or + * a section submenu). Uses Base UI's scroll area so the native bar stays + * 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) { + return ( + + + + {children} + + + + + + + ); +} diff --git a/app/components/nav-motion.test.ts b/app/components/nav-motion.test.ts new file mode 100644 index 0000000..9519e46 --- /dev/null +++ b/app/components/nav-motion.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { navSlideDirection, SCROLL_FADE_MAX_PX, scrollEdges } from './nav-motion'; + +describe('navSlideDirection', () => { + it('slides forward when entering a section', () => { + expect(navSlideDirection(null, '/vibenet')).toBe(1); + }); + + it('slides backward when leaving a section', () => { + expect(navSlideDirection('/vibenet', null)).toBe(-1); + }); + + it('slides forward when moving between sections', () => { + expect(navSlideDirection('/vibenet', '/benchmark')).toBe(1); + }); + + it('returns null when the parent is unchanged', () => { + expect(navSlideDirection('/vibenet', '/vibenet')).toBeNull(); + expect(navSlideDirection(null, null)).toBeNull(); + }); +}); + +describe('scrollEdges', () => { + it('returns no fade when content fits the viewport', () => { + expect(scrollEdges(0, 200, 200)).toEqual({ top: 0, bottom: 0 }); + expect(scrollEdges(0, 180, 200)).toEqual({ top: 0, bottom: 0 }); + }); + + it('grows the bottom fade to the cap at the start of an overflowing list', () => { + expect(scrollEdges(0, 400, 200)).toEqual({ top: 0, bottom: SCROLL_FADE_MAX_PX }); + }); + + it('grows the top fade with scrollTop until the cap', () => { + expect(scrollEdges(16, 400, 200)).toEqual({ top: 16, bottom: 40 }); + expect(scrollEdges(80, 400, 200)).toEqual({ top: SCROLL_FADE_MAX_PX, bottom: SCROLL_FADE_MAX_PX }); + }); + + it('shrinks the bottom fade as the end approaches', () => { + expect(scrollEdges(180, 400, 200)).toEqual({ top: SCROLL_FADE_MAX_PX, bottom: 20 }); + expect(scrollEdges(200, 400, 200)).toEqual({ top: SCROLL_FADE_MAX_PX, bottom: 0 }); + }); +}); diff --git a/app/components/nav-motion.ts b/app/components/nav-motion.ts new file mode 100644 index 0000000..7a9e023 --- /dev/null +++ b/app/components/nav-motion.ts @@ -0,0 +1,45 @@ +/** + * Slide direction for the sidebar pane swap. Computed during render (not in an + * effect) so AnimatePresence sees the new value on the same frame the pane + * changes — otherwise the first back-navigation still uses the inbound +1. + * + * Returns null when the parent did not change, so the caller can leave its + * last direction in place. + */ +export function navSlideDirection( + prevParentHref: string | null, + currParentHref: string | null, +): 1 | -1 | null { + if (prevParentHref === currParentHref) return null; + return currParentHref ? 1 : -1; +} + +export const SCROLL_FADE_MAX_PX = 40; + +export type ScrollEdges = { + top: number; + bottom: number; +}; + +/** + * Fade band size in px at each vertical edge. The sidebar viewport only + * paints the bottom band (`--scroll-area-overflow-y-end`); the start value + * is kept so callers can still reason about both edges. The CSS variable is + * the pixel distance from that edge; the mask grows from 0 to 40px as you + * scroll away, then holds. Both are 0 when content fits the viewport. + */ +export function scrollEdges( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + maxFadePx = SCROLL_FADE_MAX_PX, +): ScrollEdges { + const overflow = Math.max(0, scrollHeight - clientHeight); + if (overflow === 0) return { top: 0, bottom: 0 }; + const start = Math.min(Math.max(0, scrollTop), overflow); + const end = overflow - start; + return { + top: Math.min(maxFadePx, start), + bottom: Math.min(maxFadePx, end), + }; +} diff --git a/app/globals.css b/app/globals.css index 7dbdf57..98138e2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -453,6 +453,107 @@ button { background: rgba(255, 255, 255, 0.35); } +/* Base UI scroll area for each sliding pane (main nav and section submenu). + Native bars stay hidden; the custom thumb is the only visible scroller. */ +.sidebar-scroll-area { + position: relative; + height: 100%; + min-height: 0; +} + +.sidebar-scroll-viewport { + --fade-size: 40px; + height: 100%; + overscroll-behavior: contain; + scrollbar-width: none; + background: var(--bds-gray-0); + /* Bottom fade only — the logo sits at the top of this scroller, so a top + band would wash it out. Base UI's overflow var is the px distance from + the end; the mask grows from 0 to --fade-size as you scroll, then holds. + 0px fallback so a short list does not flash a fade before hydrate. */ + mask-image: linear-gradient( + to bottom, + black 0, + black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end, 0px))), + transparent 100% + ); + mask-repeat: no-repeat; + -webkit-mask-image: linear-gradient( + to bottom, + black 0, + black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end, 0px))), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; +} + +.sidebar-scroll-viewport::-webkit-scrollbar { + display: none; +} + +.sidebar-scroll-bar { + display: flex; + justify-content: center; + width: 5px; + margin: 2px; + opacity: 0; + transition: opacity 150ms ease; + pointer-events: none; +} + +.sidebar-scroll-bar[data-hovering], +.sidebar-scroll-bar[data-scrolling] { + opacity: 1; + pointer-events: auto; +} + +.sidebar-scroll-bar[data-scrolling] { + transition-duration: 0ms; +} + +.sidebar-scroll-thumb { + width: 100%; + border-radius: 9999px; + background: rgba(0, 0, 0, 0.15); +} + +.sidebar-scroll-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +[data-theme='dark'] .sidebar-scroll-thumb { + background: rgba(255, 255, 255, 0.2); +} + +[data-theme='dark'] .sidebar-scroll-thumb:hover { + background: rgba(255, 255, 255, 0.35); +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-scroll-bar { + transition: none; + } +} + +/* Horizontal inset lives on the content inside the scroller (and on the logo / + footer, which sit outside it) so overflow is full-bleed. */ +.sidebar-gutter { + box-sizing: border-box; + width: 100%; + padding-left: 12px; + padding-right: 12px; +} + +/* Extra 8px so the mark still lines up with the nav icons (row padding 10px). + 18px above and below the 28px mark — the old 64px brand row, now inside + the scroller so it rolls away with the list. */ +.sidebar-brand.sidebar-gutter { + padding-top: 18px; + padding-bottom: 18px; + padding-left: 20px; + padding-right: 20px; +} + /* Sidebar hover surfaces. Nav rows paint theirs on a layer at z-index -2 — below the selected pill at -1 and below every label — so the pill can animate *over* the hover surface of the row it lands on (light gray deepens to the selected @@ -484,23 +585,6 @@ button { background-color: var(--bds-gray-5); } -/* Pulled out of flow so the switch shares the sidebar's bottom row with the last - footer link, which lives inside the sliding nav pane the switch sits outside of. - The insets match each container's own bottom/right padding: the desktop sidebar - pads `0 12px 20px`, the mobile drawer `12px 8px 20px`. Both are already - positioned (`relative` and `fixed`), so each is its own containing block. */ -.theme-switch-footer { - position: absolute; - right: 12px; - bottom: 20px; -} - -@media (max-width: 767px) { - .theme-switch-footer { - right: 8px; - } -} - /* Theme switch. The thumb is offset with a transform (see `switchThumbOn` in AppShell) so only the compositor is involved; the track recolours alongside it. */ .theme-switch-track { @@ -676,6 +760,16 @@ button { } @media (max-width: 767px) { + .sidebar-gutter { + padding-left: 8px; + padding-right: 8px; + } + + .sidebar-brand.sidebar-gutter { + padding-left: 16px; + padding-right: 16px; + } + .sidebar-desktop { display: none !important; } @@ -709,8 +803,8 @@ button { bottom: 0; z-index: 100; background: var(--bds-gray-0); - padding: 12px 8px 20px; - overflow-y: auto; + padding: 12px 0 20px; + overflow: hidden; } .mobile-content-offset { diff --git a/app/vibenet/components/CopyableValue.tsx b/app/vibenet/components/CopyableValue.tsx index 3ed70d1..7a8754f 100644 --- a/app/vibenet/components/CopyableValue.tsx +++ b/app/vibenet/components/CopyableValue.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; import { cn } from '../../components/ui/cn'; @@ -10,6 +11,8 @@ type CopyableValueProps = { className?: string; }; +const ICON_TRANSITION = { duration: 0.15 }; + export function CopyableValue({ value, display, className }: CopyableValueProps) { const [copied, setCopied] = useState(false); @@ -41,15 +44,46 @@ export function CopyableValue({ value, display, className }: CopyableValueProps) )} > {shown} - {copied ? ( - - - - ) : ( - - - - )} + + + {copied ? ( + + + + ) : ( + + + + )} + + ); } diff --git a/package-lock.json b/package-lock.json index fdd0937..1169f1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "3.940.0", + "@base-ui/react": "^1.7.0", "@radix-ui/react-select": "^2.3.3", "@radix-ui/react-tooltip": "^1.1.2", "@vercel/analytics": "^2.0.1", @@ -869,6 +870,75 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.7.0.tgz", + "integrity": "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.2", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz", + "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", @@ -9298,6 +9368,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", diff --git a/package.json b/package.json index dbb2016..93a9eda 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "3.940.0", + "@base-ui/react": "^1.7.0", "@radix-ui/react-select": "^2.3.3", "@radix-ui/react-tooltip": "^1.1.2", "@vercel/analytics": "^2.0.1",