Repository navigation
feat(nav): phone-only slide-out menu drawer - #56
Conversation
On phones (<md) the navbar collapsed the section anchors but left a cramped Docs/Litepaper cluster competing with the wordmark, so compare/method/faq/contact were entirely inaccessible. Replace the right cluster on phones with a single toggle that opens a paper slide-out drawer holding all six destinations, with active-section indication, focus trap, body scroll lock, and a reduced-motion path. Desktop layout is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploying website with
|
| Latest commit: |
fb544c0
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://471ffea5.website-70y.pages.dev |
| Branch Preview URL: | https://feat-mobile-menu-drawer.website-70y.pages.dev |
There was a problem hiding this comment.
Pull request overview
Adds a phone-only (under 768px) slide-out navigation drawer to the site’s top Chrome nav, consolidating section anchors + Docs/Litepaper into a single mobile toggle while preserving the existing desktop navigation.
Changes:
- Introduces a new
MobileMenuclient component with overlay, focus trapping, ESC-to-close, and body scroll locking. - Integrates the mobile menu into
Chromeand hides the existing Docs/Litepaper links on mobile. - Adds global CSS for the drawer/toggle/overlay animations and a typed CSS custom property (
--mm-i) for stagger timing.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| types/css.d.ts | Types --mm-i custom property used for staggered row animations. |
| components/site/MobileMenu.tsx | Implements the mobile drawer UI and open/close behavior (focus/ESC/scroll lock). |
| components/site/Chrome.tsx | Mounts MobileMenu, tracks open state to force nav tone while the drawer is open, and hides Docs/Litepaper on mobile. |
| app/globals.css | Adds styles for the mobile menu toggle, overlay, panel, staggered entrance animations, and desktop gating via media query. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Code Review
This pull request introduces a mobile navigation menu featuring a slide-out drawer, overlay, and toggle button, along with logic for focus management and scroll locking. The reviewer identified a z-index conflict that prevents the toggle from closing the menu, suggested centralizing the SectionId type for better maintainability, and recommended a more reliable scroll-locking approach for iOS Safari. Additionally, the reviewer noted that the internal close button is redundant.
.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) <noreply@anthropic.com>
The hairline divider between the section anchors and the external links was reading as visual clutter. Remove the .mm-divider element and replace its ~28px of total margin with a 2.5rem top margin on the external list, giving Docs a touch more breathing room from Contact than the divider previously allowed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Portal the toggle (now alongside the overlay + panel) so its morphed
× can sit above the drawer (z-65 > panel z-60) while leaving the
wordmark untouched inside the nav — the wordmark stays at z-50 and
is naturally covered by the drawer, which is what the prior bare
`z-50` layout produced and what we want visually. Drop the redundant
in-panel × close button; the morphed toggle is the only close
affordance now. The toggle takes a `tone` prop from Chrome so its
ink/paper color tracks the navbar's tone (necessary now that it
doesn't inherit from the nav).
Mark the drawer with `inert={!open}` so descendants leave the tab
order while it's translated off-screen (`aria-hidden` alone didn't
do that), and listen to `matchMedia('(min-width: 768px)')` so a
resize past `md` while open releases the body scroll lock and the
forced-paper nav tone.
Replace the static `menu` header with a clickable `home` link that
jumps to #intro and closes the drawer on tap. The `decdn_`-style
green underscore that previously sat next to the header is gone;
the same shape now lives as a 14×3px whisper-green bar that slides
in next to the active section row — the wordmark's accent recurs
inside the drawer instead of being a one-off decoration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drawer was reading as snappy on phone; bump every transition and keyframe duration ~50–90% on the same easings. Header and rows settle into place more deliberately; the panel slide and overlay dim run closer to the page's existing brutal-rise pacing. - panel slide: 380ms → 720ms - overlay fade: 220ms → 480ms - toggle morph: 280ms → 460ms - toggle color: 260ms → 400ms - header rise: 520ms / 80ms → 760ms / 140ms - row stagger: 520ms · 55ms · 120ms → 760ms · 80ms · 220ms - mark fade: 280ms → 540ms Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F1: centralize the section vocabulary
Lift `SECTION_IDS`, `SectionId`, `NAV_SECTIONS`, and `DARK_SECTIONS`
out of Chrome.tsx + MobileMenu.tsx into a new `lib/sections.ts`. The
two components had drifted-prone copies of the same five strings.
No behavior change.
F2: tighten the iOS Safari scroll lock
`body { overflow: hidden }` leaks on iOS Safari — touches on a fixed
overlay can still drag the documentElement. Replace with the
position-fixed-on-body pattern: save `window.scrollY` and every body
style we override on open; pin `position: fixed; top: -y; left/right:
0; width: 100%; overflow: hidden`; on close restore the saved styles
and `scrollTo` back to the saved y so the page doesn't appear to jump.
Closes Gemini-bot MEDIUM #1 (SectionId duplication) and MEDIUM #3
(iOS scroll lock) from the PR #56 review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a mobile-responsive slide-out menu system, including a new MobileMenu component, centralized section definitions in lib/sections.ts, and updated navigation logic in Chrome.tsx to handle mobile states. Review feedback suggests refactoring the wordmark visibility logic to account for the mobile drawer state and optimizing the focus trap by caching focusable elements to avoid redundant DOM queries.
| style.width = prev.width; | ||
| style.overflow = prev.overflow; | ||
| prevBodyStyleRef.current = null; | ||
| window.scrollTo(0, scrollYRef.current); | ||
| } |
| if (e.shiftKey && activeEl === first) { | ||
| e.preventDefault(); | ||
| last.focus(); | ||
| } else if (!e.shiftKey && activeEl === last) { | ||
| e.preventDefault(); |
| .mm-overlay { | ||
| transition: opacity 0ms; | ||
| } | ||
| .mm-panel { | ||
| transition: transform 0ms; | ||
| } |
Six valid items from the latest Copilot + Gemini reviews, plus a walk-back on the wordmark-variant rule from the previous pass. - Wordmark now tracks the actual section (`onDark`), not the forced menu-open paper tone. The wordmark, labs span, and the chrome-nav's tinted backdrop sit at z-50 behind the drawer and are only visible in the left strip the drawer doesn't cover, so they paint on top of the section background — forcing them to paper made the ink-coloured wordmark invisible on dark sections. Only the portalled toggle (z-65, above the drawer) keeps the menu-open paper override via a renamed `toggleTone` prop on MobileMenu. - Focusable elements are cached once at the top of the open-effect and reused by both the Tab handler and `focusFirst`. The portalled toggle is appended as the last entry so keyboard users can Tab to the close × instead of being trapped inside the panel with only ESC as an exit. - The scroll-lock cleanup no longer clobbers in-page anchor navigation. A single `handleClick` reads the link's href; if it starts with `#` it preventDefaults and stashes the target id. The cleanup branches on that ref — anchor present → history.replaceState + el.scrollIntoView; otherwise the existing scroll restoration. - Reduced-motion path actually fades now (matching the PR description). Panel drops its translate and animates opacity 0→1 over 240ms; overlay's existing opacity transition is shortened to 240ms. Rows / header / mark still settle without stagger. - Stale block comment above the mobile-menu CSS block is rewritten — the toggle is described as portalled to <body> at z-65, wordmark explicitly noted as staying inside the nav at z-50. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the mobile-menu drawer branch up to date with main. Three files conflicted on the 3-way merge: - types/css.d.ts — kept --reveal-delay + --mm-i; dropped --wordmark-h (main migrated components/site/Close.tsx to a JS const, so the CSS var is no longer used anywhere). - app/globals.css — both sides appended new content at the file tail. Concatenated main's .prose block (MDX body styling) before our .mm-toggle / drawer block. - components/site/Chrome.tsx — adopted main's version verbatim. The MobileMenu integration is parked; a follow-up issue tracks the re-wiring on top of main's next/link + `/#section` nav. Side effects of parking the integration: - lib/sections.ts removed (was only consumed by the Chrome.tsx integration that no longer ships in this merge). - MobileMenu.tsx restored to inline section constants so it compiles standalone while not imported by Chrome.tsx.
Parking the integration in the merge commit (ae1fcc3) left the nav unusable <md: the desktop Docs/Blog/Litepaper links stayed visible and crowded the row while the section anchors (compare/method/faq/ contact) and the drawer toggle were both missing. Re-doing the integration on top of main's nav (which now uses next/link and `/#section` anchors so the navbar works from /blog/* routes): - Render MobileMenu in the right cluster, after Litepaper. - Add `mobileOpen` state + `handleMobileOpenChange` callback so the toggle's tone overrides `onDark` while the drawer is open. - Hide Docs/Blog/Litepaper <md so the drawer replaces them. The Tailwind utilities migrated on main (border-b border-current pb-[2px]) stay; litepaper keeps the inline borderBottom that main left untouched. Closes #87.
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 <body>, 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.
Summary
cubic-bezier(0.16, 1, 0.3, 1)easing). The active section is marked by a 14×3px whisper-green bar — the same shape as thedecdn_wordmark's underscore, recurring inside the drawer.inerton the panel while closed, body scroll lock,matchMedia('(min-width: 768px)')listener that releases the lock if the viewport crosses into desktop with the drawer still open.md:hidden) because the unlayered custom rules would otherwise beat the utilities layer and leak the toggle onto desktop.next/link+/#sectionnav (so the navbar — and the drawer's hash anchors — keep working from/blog/*routes) and the new Blog link is mirrored into the drawer'sEXTERNALlist.Notable mechanics
<body>..chrome-navsetsbackdrop-filter: blur(0) saturate(1)as its base (so the blur-on-scroll transition is smooth). Any non-nonebackdrop-filtercreates a containing block forposition: fixeddescendants, so the panel'stop: 0; bottom: 0was resolving against the ~70px nav strip instead of the viewport. The overlay, panel, and toggle all portal todocument.bodyso their fixed positioning resolves against the viewport. The portal is hydration-gated withuseSyncExternalStoreso the static-export SSR and the first client render agree.toneprop from Chrome so its color matches the nav's tone (ink against paper sections, paper against ink ones).onDarkdirectly. Only the portalled toggle (the lone element actually sitting on top of the paper drawer) gets themobileOpen ? "paper" : onDark ? "ink" : "paper"tone.position: fixed; top: -scrollY; left:0; right:0; width:100%; overflow:hiddenon open. The cleanup checks apendingAnchorRefset by a singlehandleClickon every drawer link: if the href starts with#, the handlerpreventDefaults and stashes the target id; the cleanup then runshistory.replaceState+el.scrollIntoView({ block: "start" })instead of restoring the saved scroll position, so tapping a section row scrolls to that section rather than snapping back to where the drawer was opened. iOS Safari can no longer drag the documentElement through the overlay either.Test plan
home) and rows settle in with a deliberate cascade — no snap.#compareor#faqand confirm the wordmark (white variant) reads against the black strip on the left.home→ drawer closes and page smooth-scrolls to #intro.inert)./blog/*: open the drawer, tap a section row — the page navigates to/#section(not/blog/foo/#section) and scrolls to it.🤖 Generated with Claude Code