From 710be2d7053763131d16e21f678ac94c3f90b9bc Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 18 Aug 2026 09:37:53 -0700 Subject: [PATCH 1/4] fix(ui): slide the sidebar back the right way on first header click Direction was applied in an effect, so AnimatePresence still used the inbound value on the first back-navigation. Co-Authored-By: Claude Co-authored-by: Cursor --- app/components/AppShell.tsx | 18 ++++++++++-------- app/components/nav-motion.test.ts | 22 ++++++++++++++++++++++ app/components/nav-motion.ts | 15 +++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 app/components/nav-motion.test.ts create mode 100644 app/components/nav-motion.ts diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index d21cfaa..ab02e4c 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -14,6 +14,7 @@ import { getUpgradeById } from '../upgrades/data/upgrades'; import { titleForPath } from '../navigation'; import { trackNavClick } from '../analytics/events'; +import { navSlideDirection } from './nav-motion'; import { AnimatedBaseLogo, BaseMark } from './ui/AnimatedBaseLogo'; import { Breadcrumb } from './ui/Breadcrumb'; import { cn } from './ui/cn'; @@ -414,14 +415,15 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop 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]); + // 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. diff --git a/app/components/nav-motion.test.ts b/app/components/nav-motion.test.ts new file mode 100644 index 0000000..5434c90 --- /dev/null +++ b/app/components/nav-motion.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { navSlideDirection } 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(); + }); +}); diff --git a/app/components/nav-motion.ts b/app/components/nav-motion.ts new file mode 100644 index 0000000..4259e97 --- /dev/null +++ b/app/components/nav-motion.ts @@ -0,0 +1,15 @@ +/** + * 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; +} From 043ae24bc9d64f37241176a27a7737d6dd786d55 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 18 Aug 2026 13:55:37 -0700 Subject: [PATCH 2/4] fixes --- app/components/AppShell.tsx | 177 ++++++++++++----------- app/components/NavScrollArea.tsx | 32 ++++ app/components/nav-motion.test.ts | 23 ++- app/components/nav-motion.ts | 29 ++++ app/globals.css | 136 ++++++++++++++--- app/vibenet/components/CopyableValue.tsx | 52 +++++-- package-lock.json | 76 ++++++++++ package.json | 1 + 8 files changed, 415 insertions(+), 111 deletions(-) create mode 100644 app/components/NavScrollArea.tsx diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index ab02e4c..fc58f38 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -15,6 +15,7 @@ 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'; @@ -50,20 +51,33 @@ 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 logo and pinned footer. Overflow hidden clips the popLayout + // slide; each pane inside scrolls on its own (`NavScrollArea`). + navSlot: { + flex: 1, + minHeight: 0, + position: 'relative', + overflow: 'hidden', + }, + // Fills the slot. Overflow lives on NavScrollArea inside, not on this pane, + // so popLayout can still slide in-flow. + navPane: { + display: 'flex', + flexDirection: 'column', + height: '100%', + minHeight: 0, + }, // `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. @@ -90,12 +104,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', @@ -107,17 +126,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 @@ -321,7 +329,7 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l {label} {!enabled && Soon} {hasChildren && ( - + )} @@ -447,13 +455,13 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop return ( <> {!hideBrand && ( -
+
)} -
- +
+ {activeParent ? ( + + ) : ( + - - + )}
-
- {/* `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..4bacdee --- /dev/null +++ b/app/components/NavScrollArea.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { ScrollArea } from '@base-ui/react/scroll-area'; +import { PropsWithChildren } from 'react'; + +import { cn } from './ui/cn'; + +type NavScrollAreaProps = PropsWithChildren<{ + /** Space that used to sit under the 64px brand row. Omit in the mobile drawer. */ + belowBrand?: boolean; +}>; + +/** + * Scrollable middle of the sidebar (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. Edge fades are a CSS mask on the viewport driven + * by `--scroll-area-overflow-y-start` / `--scroll-area-overflow-y-end`. + */ +export function NavScrollArea({ children, belowBrand }: NavScrollAreaProps) { + return ( + + + + {children} + + + + + + + ); +} diff --git a/app/components/nav-motion.test.ts b/app/components/nav-motion.test.ts index 5434c90..9519e46 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 } from './nav-motion'; +import { navSlideDirection, SCROLL_FADE_MAX_PX, scrollEdges } from './nav-motion'; describe('navSlideDirection', () => { it('slides forward when entering a section', () => { @@ -20,3 +20,24 @@ describe('navSlideDirection', () => { 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 index 4259e97..ec71474 100644 --- a/app/components/nav-motion.ts +++ b/app/components/nav-motion.ts @@ -13,3 +13,32 @@ export function navSlideDirection( 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. Matches Base UI's viewport mask: + * `min(40px, var(--scroll-area-overflow-y-start|end))`. The CSS variables are + * 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..8301fa7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -453,6 +453,111 @@ 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); + /* Base UI overflow vars are the px distance from each edge. The mask band + grows from 0 to --fade-size as you scroll away, then holds. End fallback + 0px so a short list does not flash a bottom fade before hydrate. */ + mask-image: linear-gradient( + to bottom, + transparent 0, + black min(var(--fade-size), var(--scroll-area-overflow-y-start, 0px)), + 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, + transparent 0, + black min(var(--fade-size), var(--scroll-area-overflow-y-start, 0px)), + 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). + Top inset is half of the old 64px brand row (28px mark); the other half + scrolls away as padding on `.sidebar-gutter-below-brand`. */ +.sidebar-brand.sidebar-gutter { + padding-top: 18px; + padding-left: 20px; + padding-right: 20px; +} + +.sidebar-gutter-below-brand { + padding-top: 18px; +} + /* 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 +589,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 +764,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 +807,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..12b4024 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-npm.cbhq.net/@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-npm.cbhq.net/@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-npm.cbhq.net/@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-npm.cbhq.net/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", From 7f6292675c5772366908d49811763c58cfda43e7 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 25 Aug 2026 11:07:45 -0700 Subject: [PATCH 3/4] include base logo in scrollable area --- app/components/AppShell.tsx | 247 ++++++++++++++++--------------- app/components/NavScrollArea.tsx | 21 ++- app/components/nav-motion.ts | 5 +- app/globals.css | 22 ++- 4 files changed, 151 insertions(+), 144 deletions(-) diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index fc58f38..b88f1e7 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -62,21 +62,26 @@ const styles: Record = { position: 'relative', }, brandLink: { display: 'inline-block', lineHeight: 0 }, - // Slot between logo and pinned footer. Overflow hidden clips the popLayout - // slide; each pane inside scrolls on its own (`NavScrollArea`). + // 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', }, - // Fills the slot. Overflow lives on NavScrollArea inside, not on this pane, - // so popLayout can still slide in-flow. + // 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', - height: '100%', - minHeight: 0, + }, + // 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 @@ -422,6 +427,8 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop const activeParent = getActiveParent(activePath); const directionRef = useRef(1); const prevParentRef = useRef(activeParent?.href ?? null); + 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. @@ -445,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?.(); @@ -454,119 +468,118 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop return ( <> - {!hideBrand && ( -
- -
- )} -
- - {activeParent ? ( - - - { - if (opensInNewTab(event)) return; - selectPath('/'); - }} - > - - {activeParent.label} - - - - - ) : ( - - - - - + + {!hideBrand && ( +
+ +
)} -
+
+ + {activeParent ? ( + + { + if (opensInNewTab(event)) return; + selectPath('/'); + }} + > + + {activeParent.label} + + + + ) : ( + + + + )} + +
+
diff --git a/app/components/NavScrollArea.tsx b/app/components/NavScrollArea.tsx index 4bacdee..493a4ad 100644 --- a/app/components/NavScrollArea.tsx +++ b/app/components/NavScrollArea.tsx @@ -1,26 +1,23 @@ 'use client'; import { ScrollArea } from '@base-ui/react/scroll-area'; -import { PropsWithChildren } from 'react'; - -import { cn } from './ui/cn'; +import { PropsWithChildren, Ref } from 'react'; type NavScrollAreaProps = PropsWithChildren<{ - /** Space that used to sit under the 64px brand row. Omit in the mobile drawer. */ - belowBrand?: boolean; + viewportRef?: Ref; }>; /** - * Scrollable middle of the sidebar (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. Edge fades are a CSS mask on the viewport driven - * by `--scroll-area-overflow-y-start` / `--scroll-area-overflow-y-end`. + * 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, belowBrand }: NavScrollAreaProps) { +export function NavScrollArea({ children, viewportRef }: NavScrollAreaProps) { return ( - - + + {children} diff --git a/app/components/nav-motion.ts b/app/components/nav-motion.ts index ec71474..7a9e023 100644 --- a/app/components/nav-motion.ts +++ b/app/components/nav-motion.ts @@ -22,8 +22,9 @@ export type ScrollEdges = { }; /** - * Fade band size in px at each vertical edge. Matches Base UI's viewport mask: - * `min(40px, var(--scroll-area-overflow-y-start|end))`. The CSS variables are + * 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. */ diff --git a/app/globals.css b/app/globals.css index 8301fa7..98138e2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -467,21 +467,20 @@ button { overscroll-behavior: contain; scrollbar-width: none; background: var(--bds-gray-0); - /* Base UI overflow vars are the px distance from each edge. The mask band - grows from 0 to --fade-size as you scroll away, then holds. End fallback - 0px so a short list does not flash a bottom fade before hydrate. */ + /* 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, - transparent 0, - black min(var(--fade-size), var(--scroll-area-overflow-y-start, 0px)), + 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, - transparent 0, - black min(var(--fade-size), var(--scroll-area-overflow-y-start, 0px)), + black 0, black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end, 0px))), transparent 100% ); @@ -546,18 +545,15 @@ button { } /* Extra 8px so the mark still lines up with the nav icons (row padding 10px). - Top inset is half of the old 64px brand row (28px mark); the other half - scrolls away as padding on `.sidebar-gutter-below-brand`. */ + 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-gutter-below-brand { - padding-top: 18px; -} - /* 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 From a638ad808c530805bed7dfa7631a3189036d73c6 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Tue, 25 Aug 2026 11:14:52 -0700 Subject: [PATCH 4/4] fix: point new lockfile tarballs at the public npm registry GitHub Actions cannot resolve registry-npm.cbhq.net, so npm ci failed on the four packages added with @base-ui/react. Co-authored-by: Cursor --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12b4024..1169f1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -872,7 +872,7 @@ }, "node_modules/@babel/runtime": { "version": "7.29.7", - "resolved": "https://registry-npm.cbhq.net/@babel/runtime/-/runtime-7.29.7.tgz", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { @@ -881,7 +881,7 @@ }, "node_modules/@base-ui/react": { "version": "1.7.0", - "resolved": "https://registry-npm.cbhq.net/@base-ui/react/-/react-1.7.0.tgz", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.7.0.tgz", "integrity": "sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==", "license": "MIT", "dependencies": { @@ -919,7 +919,7 @@ }, "node_modules/@base-ui/utils": { "version": "0.3.2", - "resolved": "https://registry-npm.cbhq.net/@base-ui/utils/-/utils-0.3.2.tgz", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz", "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==", "license": "MIT", "dependencies": { @@ -9370,7 +9370,7 @@ }, "node_modules/reselect": { "version": "5.2.0", - "resolved": "https://registry-npm.cbhq.net/reselect/-/reselect-5.2.0.tgz", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" },