From 6a7e9c25558b4e01270d150c210f3b8ca6e49785 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 22:45:32 +0300 Subject: [PATCH 01/10] fix(nav): elevate the navbar when the drawer is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Painting the nav in the section's own tone (paper on paper, ink on ink) gave it zero contrast against the section behind it once the drawer opened, so the band read as no nav at all — just a wordmark floating above a dimmed page. Give the drawer-open nav real visual presence: - z-index lifted to 58 (above the overlay's 55, below the panel's 60) - backdrop-filter cleared so the bar is fully opaque - background painted in the section's own tone (var(--paper) or var(--ink) per data-tone), not flipped to match the drawer - hairline border-bottom matched to the scrolled-state values (24% ink on paper, 26% paper on ink) - soft drop shadow that elevates the band above the dimmed section (ink shadow on paper sections, paper glow on ink sections) - box-shadow added to the chrome-nav transition list so the shadow fades in alongside the drawer's open animation A new data-mobile-open attribute on the nav element drives the rule; Chrome.tsx reads mobileOpen from MobileMenu's onOpenChange callback (already wired). Tone keeps tracking the section behind the nav — the drawer is the paper anchor, the nav remains a continuation of the section. --- app/globals.css | 28 ++++++++++++++++++++++++++++ components/site/Chrome.tsx | 12 +++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7ac8715..7bf6219 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,33 @@ 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) but leave it below + the panel (60) so the panel still covers it. Drop the translucent + backdrop for a fully opaque section-toned bar with a soft drop + shadow — without the shadow, an opaque paper nav on a paper + section (or ink on ink) reads as no nav at all, because the band + has no contrast against the section behind it. The shadow gives + the band depth so it reads as an elevated header. Tone keeps + tracking the section behind the nav. */ +.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"], +.chrome-nav[data-mobile-open="true"][data-tone="paper"][data-scrolled="true"] { + 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"], +.chrome-nav[data-mobile-open="true"][data-tone="ink"][data-scrolled="true"] { + 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. */ diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 0013fe3..15f1745 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -117,11 +117,12 @@ 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. The rest of + // the nav stays section-toned; data-mobile-open just lifts it above + // the overlay and turns its translucent backdrop opaque so section + // content stops bleeding through the visible left strip. const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; return ( @@ -130,6 +131,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" }`} From 0a29a6a83b767368ab89b8cf3e4a31e1fffe9a7a Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 22:54:20 +0300 Subject: [PATCH 02/10] refactor(nav): apply review fixes for drawer-open elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the PR #89 bot reviews: 1. The `[data-scrolled="true"]` second selector on both per-tone drawer-open rules was redundant. The base `.chrome-nav[data-mobile-open="true"][data-tone="paper"]` selector (0,3,0) matches both scrolled and unscrolled states, and is later in source than the scrolled-state rules (also 0,3,0) — source order alone makes it win. Dropped the doubled selectors. 2. The `prefers-reduced-motion` block overrides `.chrome-nav`'s transition list down to just `background-color`, which silently undid the new `box-shadow 360ms` for users with reduced motion enabled (the shadow would snap instead of fade). Added `box-shadow` to the reduced-motion transition list so the fade completes for everyone — consistent with the rest of the file's pattern of keeping soft opacity-like transitions under reduced-motion (drawer panel keeps a 240ms opacity fade, etc.). --- app/globals.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7bf6219..a4e2ef5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -749,14 +749,12 @@ a.underline-brutal:hover .arrow { -webkit-backdrop-filter: none; z-index: 58; } -.chrome-nav[data-mobile-open="true"][data-tone="paper"], -.chrome-nav[data-mobile-open="true"][data-tone="paper"][data-scrolled="true"] { +.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"], -.chrome-nav[data-mobile-open="true"][data-tone="ink"][data-scrolled="true"] { +.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 @@ -839,7 +837,9 @@ 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), + box-shadow 360ms cubic-bezier(0.2, 0.8, 0.2, 1); } .nav-item .nav-label, .nav-item .nav-underline { From ececb0f09bd859a0288352ff6e2121ff8efa51c3 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 23:12:27 +0300 Subject: [PATCH 03/10] fix(mobile-menu): kill the jump-to-top on drawer close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close-effect cleanup unpinned the body (page snaps to y=0 because the negative top was the only thing holding the viewport) and then called window.scrollTo(0, scrollYRef.current). Since carries motion-safe:scroll-smooth, that scrollTo defaulted to a smooth animation — so the user saw the page leap to the top and then scroll down to their prior position. Pass behavior: "instant" on the restore scrollTo so it bypasses the page's smooth-scroll CSS. The y=0 intermediate now happens in the same synchronous tick as the restore — no paint between, no animation. Close-only (×, overlay, ESC) stays exactly where the user was; anchor taps become a clean smooth ride from prior position to target instead of a jump-then-descent. Also drops the now-redundant else branch (the unconditional scrollTo above already restores the position; the anchor branch just rides on top of it). --- components/site/MobileMenu.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 38a95f3..7ac0381 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -144,7 +144,14 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { 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. + // place. Restore the prior scroll synchronously and **instantly** + // — `behavior: "instant"` bypasses the page's + // `motion-safe:scroll-smooth`, which would otherwise animate + // from y=0 back to the saved position and read as "the page + // jumped to the top and scrolled down again" on close. With the + // instant restore in place, close-only stays put and anchor taps + // become a clean smooth ride from the prior position to the + // target instead of a jump-then-descent. const prev = prevBodyStyleRef.current!; const style = document.body.style; style.position = prev.position; @@ -154,15 +161,20 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { style.width = prev.width; style.overflow = prev.overflow; prevBodyStyleRef.current = null; + window.scrollTo({ + top: scrollYRef.current, + left: 0, + behavior: "instant", + }); 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 doesn't scroll; scrollIntoView rides + // on the just-restored scroll position via the page's + // `motion-safe:scroll-smooth` (honours reduced-motion). history.replaceState(null, "", `#${anchor}`); el.scrollIntoView({ block: "start" }); } else { @@ -170,8 +182,6 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { // /blog/*). Let the browser navigate to home + hash. window.location.assign(`/#${anchor}`); } - } else { - window.scrollTo(0, scrollYRef.current); } }; }, [open]); From bbe1e5b6e48a23f12f00dc301f98f1b658e72ba9 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 23:27:07 +0300 Subject: [PATCH 04/10] refactor(mobile-menu): apply review fixes for the scroll-restore path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness issues plus a comment-tightening pass surfaced during PR #89 review. Correctness ----------- - behavior: "instant" on scrollTo was silently degrading to "auto" on Safari < 18.4 — and "auto" honours 's motion-safe:scroll-smooth, which re-introduces the jump-to-top the previous commit was meant to kill. Swap the page's scroll-behavior to "auto" around a legacy two-arg scrollTo instead; that works regardless of how the "instant" enum is interpreted. - The scroll restore was unconditional, so when the drawer was opened from /blog/ and the user tapped a section row, the blog page (which is about to unload) would visibly scroll back to scrollYRef.current before window.location.assign navigated. Now we only restore when staying on this page — anchor with the target found, or no anchor at all. Comments -------- - Trim the cleanup-block lead (10 lines → mechanism-only one-liner plus the Safari note where the WHY actually lives). - Drop the duplicated framing in the toggleTone comment in Chrome.tsx (the data-mobile-open behaviour is documented at its CSS site, doesn't need restating here). - Compress the globals.css drawer-open lead block; the contrast-failure WHY is the only load-bearing sentence and now stands alone. --- app/globals.css | 12 +++---- components/site/Chrome.tsx | 5 +-- components/site/MobileMenu.tsx | 58 ++++++++++++++++++---------------- 3 files changed, 35 insertions(+), 40 deletions(-) diff --git a/app/globals.css b/app/globals.css index a4e2ef5..069cf98 100644 --- a/app/globals.css +++ b/app/globals.css @@ -736,14 +736,10 @@ 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) but leave it below - the panel (60) so the panel still covers it. Drop the translucent - backdrop for a fully opaque section-toned bar with a soft drop - shadow — without the shadow, an opaque paper nav on a paper - section (or ink on ink) reads as no nav at all, because the band - has no contrast against the section behind it. The shadow gives - the band depth so it reads as an elevated header. Tone keeps - tracking the section behind the nav. */ +/* 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; diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 15f1745..4323ef1 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -119,10 +119,7 @@ export function Chrome() { const onDark = DARK_SECTIONS.has(active); // 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. The rest of - // the nav stays section-toned; data-mobile-open just lifts it above - // the overlay and turns its translucent backdrop opaque so section - // content stops bleeding through the visible left strip. + // paper then so its × reads against the white panel. const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; return ( diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 7ac0381..19d541e 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -142,16 +142,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. Restore the prior scroll synchronously and **instantly** - // — `behavior: "instant"` bypasses the page's - // `motion-safe:scroll-smooth`, which would otherwise animate - // from y=0 back to the saved position and read as "the page - // jumped to the top and scrolled down again" on close. With the - // instant restore in place, close-only stays put and anchor taps - // become a clean smooth ride from the prior position to the - // target instead of a jump-then-descent. + // 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; @@ -161,27 +153,37 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { style.width = prev.width; style.overflow = prev.overflow; prevBodyStyleRef.current = null; - window.scrollTo({ - top: scrollYRef.current, - left: 0, - behavior: "instant", - }); const anchor = pendingAnchorRef.current; pendingAnchorRef.current = null; - if (anchor) { - const el = document.getElementById(anchor); - if (el) { - // history.replaceState doesn't scroll; scrollIntoView rides - // on the just-restored scroll position via the page's - // `motion-safe:scroll-smooth` (honours reduced-motion). - 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}`); - } + const targetEl = anchor ? document.getElementById(anchor) : null; + + if (anchor && !targetEl) { + // Drawer opened from a different route (e.g. /blog/*); the + // new page handles the hash scroll. Skip the local restore — + // any scroll on this page would be a visible artefact on the + // way out. + 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 doesn't scroll; scrollIntoView smooth-scrolls + // from the just-restored position via motion-safe:scroll-smooth. + history.replaceState(null, "", `#${anchor}`); + targetEl.scrollIntoView({ block: "start" }); } }; }, [open]); From 82917df1fcfaf03966463d2f58edf0cc9b1f1124 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Tue, 12 May 2026 00:00:18 +0300 Subject: [PATCH 05/10] style(mobile-menu): lowercase external labels and brand the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The drawer's section rows render in lowercase (`compare`, `method`, `faq`, `contact`), but the external list below them was Title-Cased (`Docs`, `Blog`, `Litepaper`). Match the editorial- lowercase vocabulary. - The footer read `decdn / labs` → `DECDN / LABS` via `.meta`'s uppercase. Swap the slash for the wordmark's underscore and paint it whisper-green so `DECDN_LABS` echoes the wordmark mark. --- components/site/MobileMenu.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 19d541e..d2ec80e 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -32,9 +32,9 @@ 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 = @@ -313,7 +313,9 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
- decdn / labs + + decdn_labs +
From 6670f0cdfac55952fdedf87aa4ef6030597772b4 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Tue, 12 May 2026 00:13:51 +0300 Subject: [PATCH 06/10] refactor(mobile-menu): apply review fixes from PR #89 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of four bot suggestions verified valid and applied: - Cross-route navigation goes through next/navigation's router.push instead of window.location.assign — keeps the /blog/* -> / hop client-side, matches the -based section nav in Chrome.tsx. router added to the open-effect deps (stable, so harmless). - The decorative underscore in the footer (decdn_labs) gets aria-hidden so screen readers no longer announce "underscore" — the accessible name is now "decdn labs", a clean wordmark echo without an audible artefact. - border-bottom-color added to the prefers-reduced-motion .chrome-nav transition list. Matches the existing pattern of keeping color-only transitions under reduced-motion; without it the hairline snapped while the background + shadow faded. The fourth bot suggestion (source-case mismatch between desktop "Docs"/"Blog"/"Litepaper" and the drawer's "docs"/"blog"/ "litepaper") was rejected — the visual output is intentional: .meta carries text-transform: uppercase so the desktop renders uppercase regardless of source case, while .mm-label in the drawer renders verbatim. Source-case difference is cosmetic only. --- app/globals.css | 1 + components/site/MobileMenu.tsx | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/globals.css b/app/globals.css index 069cf98..f5d1384 100644 --- a/app/globals.css +++ b/app/globals.css @@ -835,6 +835,7 @@ a.underline-brutal:hover .arrow { .chrome-nav { 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, diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index d2ec80e..4f4abf3 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -8,6 +8,7 @@ import { useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; +import { useRouter } from "next/navigation"; import { links } from "@/lib/links"; type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; @@ -48,6 +49,7 @@ const getHydratedSnapshot = () => true; const getServerSnapshot = () => false; export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { + const router = useRouter(); const [open, setOpen] = useState(false); // Static export: document.body isn't available during prerender. Gate // the portal until after hydration so the first client render matches @@ -160,10 +162,11 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { if (anchor && !targetEl) { // Drawer opened from a different route (e.g. /blog/*); the - // new page handles the hash scroll. Skip the local restore — - // any scroll on this page would be a visible artefact on the - // way out. - window.location.assign(`/#${anchor}`); + // new page handles the hash scroll. Use router.push so the + // transition stays client-side instead of a full reload. + // Skip the local restore — any scroll on this page would be + // a visible artefact on the way out. + router.push(`/#${anchor}`); return; } @@ -186,7 +189,7 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { targetEl.scrollIntoView({ block: "start" }); } }; - }, [open]); + }, [open, router]); // Skipped on the initial render (when `open` is already false) via // the wasOpenRef guard. @@ -314,7 +317,11 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
- decdn_labs + decdn + + labs
From d86794b175537f3a454e855b92130e638f1f1d66 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Tue, 12 May 2026 00:17:03 +0300 Subject: [PATCH 07/10] style(mobile-menu): slow the section-anchor scroll to ~900ms ease-out-expo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollIntoView's CSS smooth-scroll was completing in ~300-500ms, which read as snappy next to the drawer's 720ms slide and 850ms brutal-rise reveals — anchor taps felt clipped against the rest of the page's editorial choreography. Replace it with a rAF loop running 900ms easeOutExpo (1 - 2^-10t), matching the heavy settling-into-place feel the rest of the page uses. Reduced-motion path keeps the existing scrollIntoView (instant under prefers-reduced-motion), preserves scroll-margin-top via getComputedStyle so future --nav-h changes work without touching this helper. Drawer slide-close (720ms) and the scroll (900ms) now overlap such that the drawer dismisses first and the scroll's tail-end remains visible — clean visual hierarchy that reads as one coordinated motion rather than two competing animations. --- components/site/MobileMenu.tsx | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 4f4abf3..ae99cad 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -41,6 +41,34 @@ const EXTERNAL: readonly DrawerLink[] = [ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; +// Slow, editorial scroll to an anchor — matches the drawer's 720ms +// open and 850ms brutal-rise reveals rather than the browser's +// snappy default smooth-scroll. easeOutExpo gives the heavy +// settling-into-place feel the rest of the page uses. +// Reduced-motion path jumps instantly via scrollIntoView (still +// honours the element's scroll-margin-top). +function scrollToAnchor(el: HTMLElement) { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + el.scrollIntoView({ block: "start" }); + return; + } + const startY = window.scrollY; + const marginTop = + parseFloat(getComputedStyle(el).scrollMarginTop || "0") || 0; + const targetY = startY + el.getBoundingClientRect().top - marginTop; + const distance = targetY - startY; + if (distance === 0) return; + const duration = 900; + const startTime = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - startTime) / duration); + const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t); + window.scrollTo(0, startY + distance * eased); + if (t < 1) requestAnimationFrame(tick); + }; + 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. @@ -183,10 +211,11 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { html.style.scrollBehavior = prevScrollBehavior; if (anchor && targetEl) { - // replaceState doesn't scroll; scrollIntoView smooth-scrolls - // from the just-restored position via motion-safe:scroll-smooth. + // replaceState doesn't scroll; scrollToAnchor rides from the + // just-restored position to the target over 900ms — slow + // enough to feel of-a-piece with the drawer's slide-close. history.replaceState(null, "", `#${anchor}`); - targetEl.scrollIntoView({ block: "start" }); + scrollToAnchor(targetEl); } }; }, [open, router]); From 308711369c1f1b2aac2f0fc23c4b7215403623fc Mon Sep 17 00:00:00 2001 From: yigitdot Date: Tue, 12 May 2026 00:24:51 +0300 Subject: [PATCH 08/10] style(mobile-menu): switch section-anchor scroll to linear at 600ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 900ms easeOutExpo from d86794b covered ~50% of the distance in the first 10% of time and then dragged out the rest — combined with the drawer's 720ms slide-close occluding the high-velocity opening burst, this read as "slow at first, then suddenly speeds up." Constant velocity at 600ms reads as deliberate without any internal pace shift. scrollToAnchor's eased term collapses to just `t`, duration drops from 900 to 600. Reduced-motion path unchanged (scrollIntoView under prefers-reduced-motion still jumps instantly). Comments above the helper and at the call site updated to match. --- components/site/MobileMenu.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index ae99cad..93546eb 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -41,10 +41,10 @@ const EXTERNAL: readonly DrawerLink[] = [ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; -// Slow, editorial scroll to an anchor — matches the drawer's 720ms -// open and 850ms brutal-rise reveals rather than the browser's -// snappy default smooth-scroll. easeOutExpo gives the heavy -// settling-into-place feel the rest of the page uses. +// Constant-velocity scroll to an anchor at 600ms — slower than the +// browser's default smooth-scroll, but linear so the page doesn't +// burst forward and then slow to a crawl the way ease-out curves do +// (especially noticeable behind the drawer's slide-close). // Reduced-motion path jumps instantly via scrollIntoView (still // honours the element's scroll-margin-top). function scrollToAnchor(el: HTMLElement) { @@ -58,12 +58,11 @@ function scrollToAnchor(el: HTMLElement) { const targetY = startY + el.getBoundingClientRect().top - marginTop; const distance = targetY - startY; if (distance === 0) return; - const duration = 900; + const duration = 600; const startTime = performance.now(); const tick = (now: number) => { const t = Math.min(1, (now - startTime) / duration); - const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t); - window.scrollTo(0, startY + distance * eased); + window.scrollTo(0, startY + distance * t); if (t < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); @@ -212,8 +211,7 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { if (anchor && targetEl) { // replaceState doesn't scroll; scrollToAnchor rides from the - // just-restored position to the target over 900ms — slow - // enough to feel of-a-piece with the drawer's slide-close. + // just-restored position to the target at constant velocity. history.replaceState(null, "", `#${anchor}`); scrollToAnchor(targetEl); } From 50878bcc69b741aee54337c3c5d4211d2de30311 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Tue, 12 May 2026 00:36:47 +0300 Subject: [PATCH 09/10] fix(mobile-menu): own the scroll-behavior swap inside scrollToAnchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous linear-rAF scroll still felt lumpy ("slow at first, then suddenly speeds up"). Cause: after the cleanup's instant restore, ``'s scrollBehavior reverts to "" and Tailwind's `motion-safe:scroll-smooth` kicks back in. Each per-frame window.scrollTo inside scrollToAnchor then defers to CSS scroll-behavior: smooth, so the browser queues a new managed smooth animation toward a moving target every tick — the rAF and the CSS smooth-scroll fight each other and the perceived velocity goes uneven. Have scrollToAnchor own its own swap: force scrollBehavior to "auto" before the first frame, restore the previous value when the loop completes. Each per-frame scrollTo now lands instantly, which is what the rAF assumes. The result is genuinely constant pixels-per-frame. --- components/site/MobileMenu.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 93546eb..802ed6c 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -45,6 +45,11 @@ const FOCUSABLE_SELECTOR = // browser's default smooth-scroll, but linear so the page doesn't // burst forward and then slow to a crawl the way ease-out curves do // (especially noticeable behind the drawer's slide-close). +// `` scroll-behavior is forced to `auto` for the lifetime of +// the rAF: without it, each per-frame `window.scrollTo` defers to +// the page's `motion-safe:scroll-smooth` CSS and queues yet another +// browser-managed smooth animation, which fights the rAF and reads +// as a lumpy "slow then sudden" scroll. // Reduced-motion path jumps instantly via scrollIntoView (still // honours the element's scroll-margin-top). function scrollToAnchor(el: HTMLElement) { @@ -58,12 +63,19 @@ function scrollToAnchor(el: HTMLElement) { 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"; 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) requestAnimationFrame(tick); + if (t < 1) { + requestAnimationFrame(tick); + } else { + html.style.scrollBehavior = prevScrollBehavior; + } }; requestAnimationFrame(tick); } From c6936db0465ebbe27549af9b6e5c38b10b2c58c5 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Tue, 12 May 2026 00:46:10 +0300 Subject: [PATCH 10/10] refactor(mobile-menu): apply PR #89 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes plus a comment-tightening pass surfaced by the post-50878bc review. Critical -------- - scrollToAnchor is now single-flight. Module-scope state (scrollAnchorRaf + scrollAnchorRestore) tracks the in-flight rAF id and the original prevScrollBehavior. A new call cancels the prior rAF and eagerly restores its captured prev BEFORE snapshotting again, so the polluted "auto" value can never become the captured "previous" state. Without this, two overlapping calls could permanently strand at scroll-behavior: auto for the rest of the session. Important --------- - Reverted the cross-route gate from router.push to window.location.assign. Next 16 App Router's hash-on-soft-nav behaviour under output: "export" isn't documented; full reload is deterministic. Drops useRouter + the router dep on the open-effect. Suggestions ----------- - Updated the portal-target comment to reflect that the nav now rides z-50 resting / z-58 while the drawer is open (was stale after the data-mobile-open block landed in globals.css). - Trimmed scrollToAnchor's doc-comment (WHY-only), the cross- route gate comment, and the replaceState comment. - Dropped dead-defense `|| 0` after parseFloat — getComputedStyle always returns a pixel string for an unset property. --- components/site/MobileMenu.tsx | 66 +++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 802ed6c..06c28bf 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -8,7 +8,6 @@ import { useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; -import { useRouter } from "next/navigation"; import { links } from "@/lib/links"; type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; @@ -41,43 +40,54 @@ const EXTERNAL: readonly DrawerLink[] = [ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; -// Constant-velocity scroll to an anchor at 600ms — slower than the -// browser's default smooth-scroll, but linear so the page doesn't -// burst forward and then slow to a crawl the way ease-out curves do -// (especially noticeable behind the drawer's slide-close). // `` scroll-behavior is forced to `auto` for the lifetime of // the rAF: without it, each per-frame `window.scrollTo` defers to -// the page's `motion-safe:scroll-smooth` CSS and queues yet another -// browser-managed smooth animation, which fights the rAF and reads -// as a lumpy "slow then sudden" scroll. -// Reduced-motion path jumps instantly via scrollIntoView (still -// honours the element's scroll-margin-top). +// `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 || "0") || 0; + 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) { - requestAnimationFrame(tick); + scrollAnchorRaf = requestAnimationFrame(tick); } else { - html.style.scrollBehavior = prevScrollBehavior; + scrollAnchorRestore?.(); + scrollAnchorRestore = null; + scrollAnchorRaf = 0; } }; - requestAnimationFrame(tick); + scrollAnchorRaf = requestAnimationFrame(tick); } // Hydration gate for the createPortal target. Reads false on the @@ -88,7 +98,6 @@ const getHydratedSnapshot = () => true; const getServerSnapshot = () => false; export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { - const router = useRouter(); const [open, setOpen] = useState(false); // Static export: document.body isn't available during prerender. Gate // the portal until after hydration so the first client render matches @@ -200,12 +209,12 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { const targetEl = anchor ? document.getElementById(anchor) : null; if (anchor && !targetEl) { - // Drawer opened from a different route (e.g. /blog/*); the - // new page handles the hash scroll. Use router.push so the - // transition stays client-side instead of a full reload. - // Skip the local restore — any scroll on this page would be - // a visible artefact on the way out. - router.push(`/#${anchor}`); + // 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; } @@ -222,13 +231,13 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { html.style.scrollBehavior = prevScrollBehavior; if (anchor && targetEl) { - // replaceState doesn't scroll; scrollToAnchor rides from the - // just-restored position to the target at constant velocity. + // replaceState updates the URL without scrolling; + // scrollToAnchor does the visible scroll. history.replaceState(null, "", `#${anchor}`); scrollToAnchor(targetEl); } }; - }, [open, router]); + }, [open]); // Skipped on the initial render (when `open` is already false) via // the wasOpenRef guard. @@ -268,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 = ( <>