From 184ed7176f4947a0f8f0cb44e01d07b126568551 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 11:56:46 +0300 Subject: [PATCH 1/9] feat(nav): add phone-only slide-out menu drawer On phones ( --- app/globals.css | 232 +++++++++++++++++++++++++++++++++ components/site/Chrome.tsx | 30 ++++- components/site/MobileMenu.tsx | 204 +++++++++++++++++++++++++++++ types/css.d.ts | 1 + 4 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 components/site/MobileMenu.tsx diff --git a/app/globals.css b/app/globals.css index 195a766..bd2db8c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -818,3 +818,235 @@ a.underline-brutal:hover .arrow { transition: none; } } + +/* --- mobile menu ------------------------------------------------------ + Phone-only ( = new Set([ export function Chrome() { const [active, setActive] = useState<(typeof SECTION_IDS)[number]>("intro"); const [scrolled, setScrolled] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); const navRef = useRef(null); + const handleMobileOpenChange = useCallback( + (open: boolean) => setMobileOpen(open), + [], + ); + // Active section = whichever one paints the strip directly under the // navbar. We collapse the IO root into a 1px horizontal slice at y = // navbar.offsetHeight via rootMargin — the section currently @@ -106,16 +113,21 @@ export function Chrome() { }, []); const onDark = DARK_SECTIONS.has(active); + // While the mobile drawer is open the top-right corner of the nav + // sits visually on top of the paper drawer, so force a paper tone + // (ink text) so the toggle / wordmark stay legible regardless of + // which section is hiding behind the overlay. + const navTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; + const navTextClass = + !mobileOpen && onDark ? "text-[var(--paper)]" : "text-[var(--ink)]"; return ( diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx new file mode 100644 index 0000000..7f17dc4 --- /dev/null +++ b/components/site/MobileMenu.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { links } from "@/lib/links"; + +type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; + +type Props = { + activeSection: SectionId; + onOpenChange?: (open: boolean) => void; +}; + +const SECTIONS: readonly { id: SectionId; label: string }[] = [ + { id: "compare", label: "compare" }, + { id: "method", label: "method" }, + { id: "faq", label: "faq" }, + { id: "contact", label: "contact" }, +] as const; + +const EXTERNAL: readonly { href: string; label: string; external: boolean }[] = + [ + { href: links.docs, label: "Docs", external: false }, + { href: links.litepaper, label: "Litepaper", external: true }, + ] as const; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function MobileMenu({ activeSection, onOpenChange }: Props) { + const [open, setOpen] = useState(false); + const toggleRef = useRef(null); + const panelRef = useRef(null); + // Stash whatever inline overflow value was set before the drawer + // opened so we restore the page exactly as it was, rather than + // forcing it back to "" and stomping a value some other code owned. + const prevBodyOverflowRef = useRef(null); + + // Notify the parent whenever open state flips so it can adjust the + // navbar's tone (the toggle sits inside the nav and must read + // against the paper drawer when the menu is open). + useEffect(() => { + onOpenChange?.(open); + }, [open, onOpenChange]); + + // Body scroll lock + ESC + focus trap, all gated on `open`. + useEffect(() => { + if (!open) return; + + prevBodyOverflowRef.current = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const focusFirst = () => { + const panel = panelRef.current; + if (!panel) return; + const focusables = + panel.querySelectorAll(FOCUSABLE_SELECTOR); + // Skip the close button (first focusable) — landing on the first + // menu link is the more useful initial position. + const first = focusables[1] ?? focusables[0]; + first?.focus({ preventScroll: true }); + }; + // requestAnimationFrame so the panel's open transition is started + // before we move focus; otherwise iOS Safari can occasionally + // scroll the focused element into view through the transform. + const raf = requestAnimationFrame(focusFirst); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + return; + } + if (e.key !== "Tab") return; + + const panel = panelRef.current; + if (!panel) return; + const focusables = Array.from( + panel.querySelectorAll(FOCUSABLE_SELECTOR), + ); + if (focusables.length === 0) return; + const first = focusables[0]!; + const last = focusables[focusables.length - 1]!; + const activeEl = document.activeElement as HTMLElement | null; + + if (e.shiftKey && activeEl === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && activeEl === last) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKey); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevBodyOverflowRef.current ?? ""; + prevBodyOverflowRef.current = null; + }; + }, [open]); + + // After closing, return focus to the toggle so keyboard users land + // exactly where they were. Skipped on the initial render (when + // `open` is already false) by gating on a ref of the previous state. + const wasOpenRef = useRef(false); + useEffect(() => { + if (wasOpenRef.current && !open) { + toggleRef.current?.focus({ preventScroll: true }); + } + wasOpenRef.current = open; + }, [open]); + + const close = useCallback(() => setOpen(false), []); + + return ( + <> + + +
    + {SECTIONS.map((s, i) => { + const isActive = activeSection === s.id; + return ( +
  • + + {s.label} + + +
  • + ); + })} +
+ +
+ + + +
+ decdn / labs +
+ + + ); +} diff --git a/types/css.d.ts b/types/css.d.ts index 6833af2..a617d8c 100644 --- a/types/css.d.ts +++ b/types/css.d.ts @@ -4,5 +4,6 @@ declare module "react" { interface CSSProperties { "--reveal-delay"?: `${number}ms`; "--wordmark-h"?: string; + "--mm-i"?: number; } } From 4e65b0da6d92607713938af4359d86889c152764 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 12:03:41 +0300 Subject: [PATCH 2/9] fix(mobile-menu): portal drawer to body to escape nav containing block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .chrome-nav sets `backdrop-filter: blur(0)` as its base — any non-`none` backdrop-filter creates a containing block for `position: fixed` descendants, so the panel's `top: 0; bottom: 0` was being resolved against the ~70px nav strip instead of the viewport. The drawer rendered as a thin band across the top with the items clipped out. Portal the overlay + panel to `document.body` so their fixed positioning resolves against the viewport. The toggle stays inside the nav (it's intended to live there). Hydration-gate the portal target via useSyncExternalStore — the static-export SSR sees no portal, the first client render matches, and the post-hydration render mounts it. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/site/MobileMenu.tsx | 59 ++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 7f17dc4..002c2f3 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createPortal } from "react-dom"; import { links } from "@/lib/links"; type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; @@ -26,8 +33,24 @@ const EXTERNAL: readonly { href: string; label: string; external: boolean }[] = const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; +// Hydration gate for the createPortal target — useSyncExternalStore is +// the React-18+ idiomatic way to read a different value on the server +// vs the client without tripping the `set-state-in-effect` lint rule. +const noopSubscribe = () => () => {}; +const getHydratedSnapshot = () => true; +const getServerSnapshot = () => false; + export function MobileMenu({ activeSection, onOpenChange }: Props) { const [open, setOpen] = useState(false); + // Gate the portal until after hydration so the static export's + // server-rendered HTML matches what React first renders on the + // client (no portal target available at SSR time). + const isHydrated = useSyncExternalStore( + noopSubscribe, + getHydratedSnapshot, + getServerSnapshot, + ); + const toggleRef = useRef(null); const panelRef = useRef(null); // Stash whatever inline overflow value was set before the drawer @@ -113,19 +136,13 @@ export function MobileMenu({ activeSection, onOpenChange }: Props) { const close = useCallback(() => setOpen(false), []); - return ( + // Portal target: the .chrome-nav sets `backdrop-filter: blur(0)` + // as its base, which creates a containing block for `position: fixed` + // descendants. Rendering the overlay + panel as children of
diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index b3b7c3a..205ccc3 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -61,6 +61,12 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { overflow: string; } | null>(null); const scrollYRef = useRef(0); + // When the drawer closes because the user tapped an in-page anchor + // (`#section`), the cleanup must scroll to that target instead of + // restoring the user's prior scroll position — otherwise the body + // unlock would override the anchor navigation and the page would + // appear to stay put. + const pendingAnchorRef = useRef(null); // Notify the parent whenever open state flips so it can adjust the // navbar's tone (the toggle sits inside the nav and must read @@ -91,11 +97,22 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { style.width = "100%"; style.overflow = "hidden"; + // Cache the focus order once for the lifetime of this open cycle: + // - panel descendants (home + section rows + external links), in + // DOM order; + // - then the portalled toggle, so keyboard users can Tab to the + // close × instead of being stuck inside the panel with only + // ESC as an exit. + // The menu content doesn't change while the drawer is open, so + // there's no reason to re-query on every Tab keystroke. + const panel = panelRef.current; + const focusables: HTMLElement[] = panel + ? Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)) + : []; + if (toggleRef.current) focusables.push(toggleRef.current); + const focusFirst = () => { - const panel = panelRef.current; - if (!panel) return; - const first = panel.querySelector(FOCUSABLE_SELECTOR); - first?.focus({ preventScroll: true }); + focusables[0]?.focus({ preventScroll: true }); }; // requestAnimationFrame so the panel's open transition is started // before we move focus; otherwise iOS Safari can occasionally @@ -109,12 +126,6 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { return; } if (e.key !== "Tab") return; - - const panel = panelRef.current; - if (!panel) return; - const focusables = Array.from( - panel.querySelectorAll(FOCUSABLE_SELECTOR), - ); if (focusables.length === 0) return; const first = focusables[0]!; const last = focusables[focusables.length - 1]!; @@ -133,10 +144,12 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { return () => { cancelAnimationFrame(raf); document.removeEventListener("keydown", onKey); - // Restore body styles exactly + scroll back to where we pinned. - // Removing `position: fixed` without scrollTo would snap the - // page to y=0 because the negative `top` was the only thing - // holding the viewport in place. + // Restore body styles exactly. Removing `position: fixed` snaps + // the page back to y=0 because the negative `top` was the only + // thing holding the viewport in place — so we have to scroll + // somewhere explicitly. If the drawer was closed by tapping an + // in-page anchor link, scroll to that target; otherwise return + // to the user's pre-open scroll position. const prev = prevBodyStyleRef.current; if (prev) { const style = document.body.style; @@ -147,7 +160,25 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { style.width = prev.width; style.overflow = prev.overflow; prevBodyStyleRef.current = null; - window.scrollTo(0, scrollYRef.current); + + const anchor = pendingAnchorRef.current; + pendingAnchorRef.current = null; + if (anchor) { + const el = document.getElementById(anchor); + if (el) { + // Update the URL hash without re-triggering a native + // anchor jump (history.replaceState doesn't scroll), then + // smooth-scroll via scrollIntoView — html already carries + // `motion-safe:scroll-smooth`, so this honours the user's + // reduced-motion preference automatically. + history.replaceState(null, "", `#${anchor}`); + el.scrollIntoView({ block: "start" }); + } else { + window.scrollTo(0, scrollYRef.current); + } + } else { + window.scrollTo(0, scrollYRef.current); + } } }; }, [open]); @@ -177,6 +208,21 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { const close = useCallback(() => setOpen(false), []); + // Single click handler for every link inside the drawer. For an + // in-page anchor (`#section`), we preventDefault so the browser + // doesn't start its own scroll while the body is still pinned, then + // stash the target id for the cleanup to scroll to after the body + // unlock. For external links (Docs / Litepaper) we let the browser + // handle navigation normally and just close the drawer. + const handleClick = useCallback((e: React.MouseEvent) => { + const href = e.currentTarget.getAttribute("href"); + if (href?.startsWith("#")) { + e.preventDefault(); + pendingAnchorRef.current = href.slice(1); + } + setOpen(false); + }, []); + // Portal target: the .chrome-nav sets `backdrop-filter: blur(0)` as // its base, which creates a containing block for `position: fixed` // descendants. We also need the toggle to sit above the drawer (so @@ -220,7 +266,7 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { home @@ -238,7 +284,7 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { data-active={isActive ? "true" : undefined} aria-current={isActive ? "true" : undefined} style={{ "--mm-i": i }} - onClick={close} + onClick={handleClick} > {s.label} @@ -258,7 +304,7 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { : {})} className="mm-row mm-row-external" style={{ "--mm-i": SECTIONS.length + i }} - onClick={close} + onClick={handleClick} > {e.label} From 75c7fab18cfb69a0fad4373703a7ba7368294562 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 21:35:11 +0300 Subject: [PATCH 8/9] feat(nav): re-wire MobileMenu into Chrome on top of main's nav Parking the integration in the merge commit (ae1fcc3) left the nav unusable = new Set([ export function Chrome() { const [active, setActive] = useState<(typeof SECTION_IDS)[number]>("intro"); const [scrolled, setScrolled] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); const navRef = useRef(null); + const handleMobileOpenChange = useCallback( + (open: boolean) => setMobileOpen(open), + [], + ); + // Active section = whichever one paints the strip directly under the // navbar. We collapse the IO root into a 1px horizontal slice at y = // navbar.offsetHeight via rootMargin — the section currently @@ -110,6 +117,15 @@ export function Chrome() { }, []); const onDark = DARK_SECTIONS.has(active); + // Only the portalled toggle sits on top of the paper drawer while + // the menu is open (z-65 above the panel's z-60). Everything else + // in the nav — wordmark, labs span, the tinted backdrop — stays at + // z-50 behind the drawer and is only visible in the left strip the + // drawer doesn't cover, where it paints on top of the underlying + // section's own background. So the nav itself tracks `onDark` + // directly; only the toggle gets the menu-open paper override via + // the `toggleTone` prop forwarded to MobileMenu. + const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; return ( From fb544c093d49a16a96c15a0adf95503ae5ae9d54 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Mon, 11 May 2026 21:56:21 +0300 Subject: [PATCH 9/9] refactor(mobile-menu): apply review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address findings from the four-agent PR review: - Fix the /blog/* anchor regression. When the drawer is opened from a non-home route and a section row is tapped, the close-cleanup's getElementById returned null and silently fell back to scrollTo(scrollYRef) — the tap looked broken. Now the null branch navigates via window.location.assign("/#" + anchor) so the browser handles the cross-route hash jump. - Rewrite two inaccurate comments. globals.css's lead block claimed Chrome.tsx forces data-tone="paper" on the whole nav while the drawer is open — it doesn't, only toggleTone flips. Chrome.tsx's toggleTone comment framed the toggle as a nav child; it's portalled to , so its tone is independent of the nav's data-tone. Collapse the duplicated CSS lead block + .mm-toggle gloss into a one-liner pointing at MobileMenu.tsx — three copies of the portal/z-index story were the worst rot risk in the file. - Replace `external: boolean` on the drawer's bottom-link table with a `kind: "internal" | "external"` discriminant — kills the EXTERNAL.external name overlap and makes the navigation behavior explicit instead of hiding under a boolean. - Make `onOpenChange` required. The only caller passes it and the toggleTone coupling in Chrome.tsx depends on it; optional understated the contract. - Drop two over-defensive guards (`if (focusables.length === 0)` and the `panel ? ... : []` ternary). With the static drawer contents, focusables.length >= 8 and panelRef is always attached when the open-effect runs. - Trim restated-what comments (parent-notify block, body-lock section label, branch narration in the cleanup) and tighten the reduced-motion + .mm-mark blurbs. WHY-only. Kept useSyncExternalStore for the portal hydration gate: the project's eslint config enforces react-hooks/set-state-in-effect, which is exactly the rule the SES pattern exists to avoid. The existing comment was right; the reviewer's suggestion to swap to useState+useEffect was incompatible with the lint config. Did not extract SectionId to a shared module — that path was deliberately closed when lib/sections.ts was deleted in ae1fcc3. --- app/globals.css | 28 +---- components/site/Chrome.tsx | 13 +-- components/site/MobileMenu.tsx | 180 ++++++++++++++------------------- 3 files changed, 87 insertions(+), 134 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6529a17..7ac8715 100644 --- a/app/globals.css +++ b/app/globals.css @@ -969,22 +969,10 @@ a.underline-brutal:hover .arrow { } /* --- mobile menu ------------------------------------------------------ - Phone-only ( alongside the overlay and panel (see the - `position: fixed; z-index: 65` on `.mm-toggle` below and the - comment in MobileMenu.tsx) so it sits above the drawer at z-65 - while the wordmark stays inside the nav at z-50 and is naturally - covered when the drawer opens. While the drawer is open Chrome.tsx - forces data-tone="paper" so the entire nav strip reads in ink. The - overlay dims with ink (it sits over the page sections, not the - drawer). */ + Phone-only ( alongside the overlay/panel — see MobileMenu.tsx - for the rationale (keeps the wordmark beneath the drawer while the - toggle's morphed × stays clickable on top of it). */ position: fixed; top: 24px; right: var(--frame-gutter); @@ -1144,9 +1132,7 @@ a.underline-brutal:hover .arrow { font-weight: 500; } -/* Echo of the wordmark's green underscore — slides in next to the - active section label when its row is current. The element renders - for every row at zero opacity so the row height stays stable. */ +/* Mark renders at zero opacity on every row so the row height stays stable. */ .mm-mark { width: 14px; height: 3px; @@ -1194,11 +1180,7 @@ a.underline-brutal:hover .arrow { .mm-toggle::after { transition: none; } - /* Replace the slide with a short opacity fade. The overlay was - already opacity-based — just shorten its duration. The panel - drops its translate3d and animates opacity instead, so reduced- - motion users still get a soft transition rather than an abrupt - appearance. */ + /* Reduced-motion: cross-fade instead of slide. */ .mm-overlay { transition: opacity 240ms cubic-bezier(0.2, 0.8, 0.2, 1); } @@ -1211,8 +1193,6 @@ a.underline-brutal:hover .arrow { transform: none; opacity: 1; } - /* Header, rows, and the active mark settle into their final state - without the stagger/slide-in. */ .mm-panel[data-open="true"] .mm-head, .mm-panel[data-open="true"] .mm-row { animation: none; diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 700f9ac..0013fe3 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -117,14 +117,11 @@ export function Chrome() { }, []); const onDark = DARK_SECTIONS.has(active); - // Only the portalled toggle sits on top of the paper drawer while - // the menu is open (z-65 above the panel's z-60). Everything else - // in the nav — wordmark, labs span, the tinted backdrop — stays at - // z-50 behind the drawer and is only visible in the left strip the - // drawer doesn't cover, where it paints on top of the underlying - // section's own background. So the nav itself tracks `onDark` - // directly; only the toggle gets the menu-open paper override via - // the `toggleTone` prop forwarded to MobileMenu. + // 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`. const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper"; return ( diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 4efdc77..38a95f3 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -15,7 +15,7 @@ type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; type Props = { activeSection: SectionId; tone: "ink" | "paper"; - onOpenChange?: (open: boolean) => void; + onOpenChange: (open: boolean) => void; }; const SECTIONS: readonly { id: SectionId; label: string }[] = [ @@ -25,28 +25,33 @@ const SECTIONS: readonly { id: SectionId; label: string }[] = [ { id: "contact", label: "contact" }, ] as const; -const EXTERNAL: readonly { href: string; label: string; external: boolean }[] = - [ - { href: links.docs, label: "Docs", external: false }, - { href: links.blog, label: "Blog", external: false }, - { href: links.litepaper, label: "Litepaper", external: true }, - ] as const; +type DrawerLink = { + href: string; + label: string; + kind: "internal" | "external"; +}; + +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" }, +] as const; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; -// Hydration gate for the createPortal target — useSyncExternalStore is -// the React-18+ idiomatic way to read a different value on the server -// vs the client without tripping the `set-state-in-effect` lint rule. +// 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. const noopSubscribe = () => () => {}; const getHydratedSnapshot = () => true; const getServerSnapshot = () => false; export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { const [open, setOpen] = useState(false); - // Gate the portal until after hydration so the static export's - // server-rendered HTML matches what React first renders on the - // client (no portal target available at SSR time). + // Static export: document.body isn't available during prerender. Gate + // the portal until after hydration so the first client render matches + // the prerendered HTML. const isHydrated = useSyncExternalStore( noopSubscribe, getHydratedSnapshot, @@ -55,12 +60,9 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { const toggleRef = useRef(null); const panelRef = useRef(null); - // iOS Safari leaks scroll through `overflow: hidden` on the body — - // a swipe on a fixed overlay can still drag the documentElement. - // The robust fix is to pin the body via `position: fixed; top: -y`, - // which moves the layout viewport with us so there is nothing to - // scroll. We save every inline body style we override so the page - // is restored exactly to its prior state on close (or unmount). + // iOS Safari leaks scroll through `overflow: hidden` on the body. Pin + // the body via `position: fixed; top: -y` instead — the layout + // viewport moves with us so there's nothing to scroll. const prevBodyStyleRef = useRef<{ position: string; top: string; @@ -70,21 +72,15 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { overflow: string; } | null>(null); const scrollYRef = useRef(0); - // When the drawer closes because the user tapped an in-page anchor - // (`#section`), the cleanup must scroll to that target instead of - // restoring the user's prior scroll position — otherwise the body - // unlock would override the anchor navigation and the page would - // appear to stay put. + // When the drawer closes via an in-page anchor tap, the cleanup must + // scroll to that target instead of restoring the prior scroll + // position — otherwise the body unlock would override the anchor jump. const pendingAnchorRef = useRef(null); - // Notify the parent whenever open state flips so it can adjust the - // navbar's tone (the toggle sits inside the nav and must read - // against the paper drawer when the menu is open). useEffect(() => { - onOpenChange?.(open); + onOpenChange(open); }, [open, onOpenChange]); - // Body scroll lock + ESC + focus trap, all gated on `open`. useEffect(() => { if (!open) return; @@ -106,27 +102,21 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { style.width = "100%"; style.overflow = "hidden"; - // Cache the focus order once for the lifetime of this open cycle: - // - panel descendants (home + section rows + external links), in - // DOM order; - // - then the portalled toggle, so keyboard users can Tab to the - // close × instead of being stuck inside the panel with only - // ESC as an exit. - // The menu content doesn't change while the drawer is open, so - // there's no reason to re-query on every Tab keystroke. - const panel = panelRef.current; - const focusables: HTMLElement[] = panel - ? Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)) - : []; + // Menu content doesn't change while open, so no need to re-query on + // every Tab keystroke. The portalled toggle is appended manually so + // keyboard users can Tab from the last row to the close ×. + const panel = panelRef.current!; + const focusables: HTMLElement[] = Array.from( + panel.querySelectorAll(FOCUSABLE_SELECTOR), + ); if (toggleRef.current) focusables.push(toggleRef.current); - const focusFirst = () => { + // rAF before the initial focus so the panel's open transition is in + // flight first — otherwise iOS Safari scrolls the focused element + // into view through the transform. + const raf = requestAnimationFrame(() => { focusables[0]?.focus({ preventScroll: true }); - }; - // requestAnimationFrame so the panel's open transition is started - // before we move focus; otherwise iOS Safari can occasionally - // scroll the focused element into view through the transform. - const raf = requestAnimationFrame(focusFirst); + }); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -135,7 +125,6 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { return; } if (e.key !== "Tab") return; - if (focusables.length === 0) return; const first = focusables[0]!; const last = focusables[focusables.length - 1]!; const activeEl = document.activeElement as HTMLElement | null; @@ -153,48 +142,42 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { return () => { cancelAnimationFrame(raf); document.removeEventListener("keydown", onKey); - // Restore body styles exactly. Removing `position: fixed` snaps - // the page back to y=0 because the negative `top` was the only - // thing holding the viewport in place — so we have to scroll - // somewhere explicitly. If the drawer was closed by tapping an - // in-page anchor link, scroll to that target; otherwise return - // to the user's pre-open scroll position. - const prev = prevBodyStyleRef.current; - if (prev) { - const style = document.body.style; - style.position = prev.position; - style.top = prev.top; - style.left = prev.left; - style.right = prev.right; - style.width = prev.width; - style.overflow = prev.overflow; - prevBodyStyleRef.current = null; + // 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. + const prev = prevBodyStyleRef.current!; + const style = document.body.style; + style.position = prev.position; + style.top = prev.top; + style.left = prev.left; + style.right = prev.right; + style.width = prev.width; + style.overflow = prev.overflow; + prevBodyStyleRef.current = null; - const anchor = pendingAnchorRef.current; - pendingAnchorRef.current = null; - if (anchor) { - const el = document.getElementById(anchor); - if (el) { - // Update the URL hash without re-triggering a native - // anchor jump (history.replaceState doesn't scroll), then - // smooth-scroll via scrollIntoView — html already carries - // `motion-safe:scroll-smooth`, so this honours the user's - // reduced-motion preference automatically. - history.replaceState(null, "", `#${anchor}`); - el.scrollIntoView({ block: "start" }); - } else { - window.scrollTo(0, scrollYRef.current); - } + 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 { - window.scrollTo(0, scrollYRef.current); + // 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); } }; }, [open]); - // After closing, return focus to the toggle so keyboard users land - // exactly where they were. Skipped on the initial render (when - // `open` is already false) by gating on a ref of the previous state. + // Skipped on the initial render (when `open` is already false) via + // the wasOpenRef guard. const wasOpenRef = useRef(false); useEffect(() => { if (wasOpenRef.current && !open) { @@ -203,9 +186,9 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { wasOpenRef.current = open; }, [open]); - // Close the drawer when crossing into desktop. Without this the CSS - // hides the panel but `open` stays true, leaving body scroll locked - // and the nav stuck in forced-paper tone after a resize. + // Close on resize to desktop. Without this the CSS hides the panel + // but `open` stays true, leaving body scroll locked and the nav + // stuck in forced-paper tone. useEffect(() => { const mql = window.matchMedia("(min-width: 768px)"); const onChange = (e: MediaQueryListEvent) => { @@ -217,12 +200,9 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { const close = useCallback(() => setOpen(false), []); - // Single click handler for every link inside the drawer. For an - // in-page anchor (`#section`), we preventDefault so the browser - // doesn't start its own scroll while the body is still pinned, then - // stash the target id for the cleanup to scroll to after the body - // unlock. For external links (Docs / Litepaper) we let the browser - // handle navigation normally and just close the drawer. + // For an in-page anchor, preventDefault so the browser doesn't start + // its own scroll while the body is still pinned; stash the target id + // for the cleanup to consume after the body unlock. const handleClick = useCallback((e: React.MouseEvent) => { const href = e.currentTarget.getAttribute("href"); if (href?.startsWith("#")) { @@ -232,15 +212,11 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) { setOpen(false); }, []); - // Portal target: the .chrome-nav sets `backdrop-filter: blur(0)` as - // its base, which creates a containing block for `position: fixed` - // descendants. We also need the toggle to sit above the drawer (so - // the morphed × can close it) while leaving the wordmark inside the - // nav untouched — raising the nav's z-index would lift the wordmark - // above the drawer too, which it shouldn't be. Portalling everything - // (toggle + overlay + panel) to solves both: fixed positioning - // resolves against the viewport, and the toggle's z-index sits above - // the panel while the nav (and wordmark) stay underneath. + // 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. const drawer = ( <>