diff --git a/app/globals.css b/app/globals.css index 7ac8715..f5d1384 100644 --- a/app/globals.css +++ b/app/globals.css @@ -714,6 +714,7 @@ a.underline-brutal:hover .arrow { backdrop-filter 260ms cubic-bezier(0.2, 0.8, 0.2, 1), -webkit-backdrop-filter 260ms cubic-bezier(0.2, 0.8, 0.2, 1), border-bottom-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 360ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1); } @@ -735,6 +736,27 @@ a.underline-brutal:hover .arrow { border-bottom-color: color-mix(in srgb, var(--paper) 26%, transparent); } +/* Drawer open: lift the nav above the overlay (55) and below the + panel (60), and swap the translucent backdrop for an opaque + section-toned bar. The shadow is load-bearing — without it, paper + nav on paper section (or ink on ink) reads as no nav at all. */ +.chrome-nav[data-mobile-open="true"] { + backdrop-filter: none; + -webkit-backdrop-filter: none; + z-index: 58; +} +.chrome-nav[data-mobile-open="true"][data-tone="paper"] { + background-color: var(--paper); + border-bottom-color: color-mix(in srgb, var(--ink) 24%, transparent); + box-shadow: 0 10px 22px -12px color-mix(in srgb, var(--ink) 26%, transparent); +} +.chrome-nav[data-mobile-open="true"][data-tone="ink"] { + background-color: var(--ink); + border-bottom-color: color-mix(in srgb, var(--paper) 26%, transparent); + box-shadow: 0 10px 22px -12px + color-mix(in srgb, var(--paper) 22%, transparent); +} + /* Safari <17 etc.: if backdrop-filter isn't supported, collapse to a fully opaque bar so text stays legible instead of floating over content. */ @@ -811,7 +833,10 @@ a.underline-brutal:hover .arrow { @media (prefers-reduced-motion: reduce) { .chrome-nav { - transition: background-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1); + transition: + background-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1), + border-bottom-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 360ms cubic-bezier(0.2, 0.8, 0.2, 1); } .nav-item .nav-label, .nav-item .nav-underline { diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 0013fe3..4323ef1 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -117,11 +117,9 @@ export function Chrome() { }, []); const onDark = DARK_SECTIONS.has(active); - // The toggle is portalled to (see MobileMenu.tsx), so it - // paints on top of the white panel while the drawer is open — force - // it to paper then. The rest of the nav stays at z-50 behind the - // drawer, visible only in the strip the panel doesn't cover, and - // keeps tracking `onDark`. + // The toggle is portalled to (see MobileMenu.tsx) and lives + // on top of the paper panel while the drawer is open — force it to + // paper then so its × reads against the white panel. const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; return ( @@ -130,6 +128,7 @@ export function Chrome() { aria-label="Primary" data-scrolled={scrolled ? "true" : undefined} data-tone={onDark ? "ink" : "paper"} + data-mobile-open={mobileOpen ? "true" : undefined} className={`chrome-nav fixed inset-x-0 top-0 z-50 py-5 ${ onDark ? "text-paper" : "text-ink" }`} diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 38a95f3..06c28bf 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -32,14 +32,64 @@ type DrawerLink = { }; const EXTERNAL: readonly DrawerLink[] = [ - { kind: "internal", href: links.docs, label: "Docs" }, - { kind: "internal", href: links.blog, label: "Blog" }, - { kind: "external", href: links.litepaper, label: "Litepaper" }, + { kind: "internal", href: links.docs, label: "docs" }, + { kind: "internal", href: links.blog, label: "blog" }, + { kind: "external", href: links.litepaper, label: "litepaper" }, ] as const; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; +// `` scroll-behavior is forced to `auto` for the lifetime of +// the rAF: without it, each per-frame `window.scrollTo` defers to +// `motion-safe:scroll-smooth` and queues yet another browser +// smooth-scroll per tick, which fights the rAF and reads as a +// lumpy "slow then sudden" scroll. The loop is single-flight — a +// new call cancels any in-flight rAF and eagerly restores its +// captured scroll-behavior so we never snapshot the polluted +// "auto" value (which would otherwise survive past the helper and +// permanently override the page's CSS scroll-smooth). +let scrollAnchorRaf = 0; +let scrollAnchorRestore: (() => void) | null = null; + +function scrollToAnchor(el: HTMLElement) { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + el.scrollIntoView({ block: "start" }); + return; + } + if (scrollAnchorRaf !== 0) { + cancelAnimationFrame(scrollAnchorRaf); + scrollAnchorRestore?.(); + scrollAnchorRaf = 0; + scrollAnchorRestore = null; + } + const startY = window.scrollY; + const marginTop = parseFloat(getComputedStyle(el).scrollMarginTop); + const targetY = startY + el.getBoundingClientRect().top - marginTop; + const distance = targetY - startY; + if (distance === 0) return; + const html = document.documentElement; + const prevScrollBehavior = html.style.scrollBehavior; + html.style.scrollBehavior = "auto"; + scrollAnchorRestore = () => { + html.style.scrollBehavior = prevScrollBehavior; + }; + const duration = 600; + const startTime = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - startTime) / duration); + window.scrollTo(0, startY + distance * t); + if (t < 1) { + scrollAnchorRaf = requestAnimationFrame(tick); + } else { + scrollAnchorRestore?.(); + scrollAnchorRestore = null; + scrollAnchorRaf = 0; + } + }; + scrollAnchorRaf = requestAnimationFrame(tick); +} + // Hydration gate for the createPortal target. Reads false on the // server and true after hydration, without a useState+useEffect // round-trip that would trip the react-hooks/set-state-in-effect rule. @@ -142,9 +192,8 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { return () => { cancelAnimationFrame(raf); document.removeEventListener("keydown", onKey); - // Removing `position: fixed` snaps the page back to y=0 because - // the negative `top` was the only thing holding the viewport in - // place — scroll somewhere explicitly after restoring styles. + // Removing `position: fixed` leaves the viewport at y=0 — the + // negative `top` was the only thing holding it. const prev = prevBodyStyleRef.current!; const style = document.body.style; style.position = prev.position; @@ -157,21 +206,35 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { const anchor = pendingAnchorRef.current; pendingAnchorRef.current = null; - if (anchor) { - const el = document.getElementById(anchor); - if (el) { - // history.replaceState doesn't scroll; smooth-scroll via - // scrollIntoView. `motion-safe:scroll-smooth` on - // honours reduced-motion automatically. - history.replaceState(null, "", `#${anchor}`); - el.scrollIntoView({ block: "start" }); - } else { - // Anchor target lives on another route (drawer opened from - // /blog/*). Let the browser navigate to home + hash. - window.location.assign(`/#${anchor}`); - } - } else { - window.scrollTo(0, scrollYRef.current); + const targetEl = anchor ? document.getElementById(anchor) : null; + + if (anchor && !targetEl) { + // Drawer opened from a different route (e.g. /blog/*); a + // full reload to /#anchor lets the new page handle the hash + // scroll deterministically (Next 16 App Router's soft-nav + // hash behaviour under output: "export" isn't documented). + // Skip the local restore — the page is about to unload. + window.location.assign(`/#${anchor}`); + return; + } + + // Restore the prior scroll position synchronously. We bypass + // ``'s motion-safe:scroll-smooth manually instead of + // passing `behavior: "instant"` — Safari only honoured the + // "instant" enum from 18.4; older versions silently fall back + // to "auto", which re-introduces the smooth scroll from y=0 + // we're trying to suppress. + const html = document.documentElement; + const prevScrollBehavior = html.style.scrollBehavior; + html.style.scrollBehavior = "auto"; + window.scrollTo(0, scrollYRef.current); + html.style.scrollBehavior = prevScrollBehavior; + + if (anchor && targetEl) { + // replaceState updates the URL without scrolling; + // scrollToAnchor does the visible scroll. + history.replaceState(null, "", `#${anchor}`); + scrollToAnchor(targetEl); } }; }, [open]); @@ -214,9 +277,10 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { // Portal target: toggle/overlay/panel all live in so the // toggle can sit above the panel (z-65 > z-60) while the wordmark - // stays inside the nav at z-50 and is naturally covered when the - // drawer opens. Raising the nav's z-index would lift the wordmark - // above the drawer too, which it shouldn't be. + // stays inside the nav (z-50 resting, z-58 when the drawer opens + // — see Chrome.tsx + globals.css's data-mobile-open block). The + // nav always sits below the panel, so the wordmark is naturally + // covered when the drawer opens. const drawer = ( <>