Skip to content

feat(nav): phone-only slide-out menu drawer - #56

Merged
thiras merged 10 commits into
mainfrom
feat/mobile-menu-drawer
May 11, 2026
Merged

thiras merged 10 commits into
mainfrom
feat/mobile-menu-drawer

Conversation

@yigitdot

@yigitdot yigitdot commented May 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • On phones (<768px) the wordmark stays visible; the Docs/Blog/Litepaper cluster is hidden behind a toggle that opens a right-side paper drawer containing home (→ #intro) + compare / method / faq / contact + Docs + Blog + Litepaper.
  • Drawer reuses existing aesthetic primitives (hairline 1px rules, brutal-rise stagger entrance, 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 the decdn_ wordmark's underscore, recurring inside the drawer.
  • Choreography is intentionally slow: 720ms panel slide, 760ms header / row rise with 80ms stagger, 540ms mark fade, 480ms overlay dim, 460ms toggle morph. Same easings as the page's brutal-rise cascade so the drawer reads as part of the existing motion vocabulary rather than a generic mobile menu.
  • Reduced-motion path: panel + overlay fade in/out over 240ms (no slide, no stagger, no row rise) — preserves a soft transition instead of an abrupt appearance.
  • Accessibility: focus trap (panel descendants + the portalled toggle, cached once on open), ESC closes + returns focus to toggle, ARIA expanded/controls/hidden, inert on 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.
  • Phone-only visibility is gated via a CSS media query (not Tailwind's md:hidden) because the unlayered custom rules would otherwise beat the utilities layer and leak the toggle onto desktop.
  • Rebased onto main's next/link + /#section nav (so the navbar — and the drawer's hash anchors — keep working from /blog/* routes) and the new Blog link is mirrored into the drawer's EXTERNAL list.
  • Closes Mobile UI: phone-specific design and settings #14 (first slice — the mobile-nav piece).

Notable mechanics

  • Drawer portalled to <body>. .chrome-nav sets backdrop-filter: blur(0) saturate(1) as its base (so the blur-on-scroll transition is smooth). Any non-none backdrop-filter creates a containing block for position: fixed descendants, so the panel's top: 0; bottom: 0 was resolving against the ~70px nav strip instead of the viewport. The overlay, panel, and toggle all portal to document.body so their fixed positioning resolves against the viewport. The portal is hydration-gated with useSyncExternalStore so the static-export SSR and the first client render agree.
  • Toggle z-index vs the wordmark. The toggle is portalled (z-65, above the panel at z-60) so its morphed × can close the drawer, but the wordmark stays inside the nav at z-50 — it's covered by the drawer when open and reappears when it closes, no opacity tricks. The toggle takes a tone prop from Chrome so its color matches the nav's tone (ink against paper sections, paper against ink ones).
  • Only the toggle gets the menu-open paper override. The wordmark, labs span, and chrome-nav's tinted backdrop stay at z-50 behind the drawer; they're only visible in the left strip the drawer doesn't cover, where they paint on the section background. So they track onDark directly. Only the portalled toggle (the lone element actually sitting on top of the paper drawer) gets the mobileOpen ? "paper" : onDark ? "ink" : "paper" tone.
  • No in-panel close button. The portalled toggle is the sole × close affordance. It is included as the last entry in the cached focus order so keyboard users can Tab to it from the last menu row.
  • Anchor-aware scroll lock. Body is pinned via position: fixed; top: -scrollY; left:0; right:0; width:100%; overflow:hidden on open. The cleanup checks a pendingAnchorRef set by a single handleClick on every drawer link: if the href starts with #, the handler preventDefaults and stashes the target id; the cleanup then runs history.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

  • iPhone viewport (375×812, 430×932): wordmark left, only the hamburger on the right; tap → drawer slides in from the right at 720ms.
  • Drawer occupies the full right-side column.
  • Header (home) and rows settle in with a deliberate cascade — no snap.
  • Wordmark stays visible on dark sections with the drawer open — open the drawer on #compare or #faq and confirm the wordmark (white variant) reads against the black strip on the left.
  • Tap home → drawer closes and page smooth-scrolls to #intro.
  • Tap a section row (compare / method / faq / contact) → drawer closes and page scrolls to that section (not back to the pre-open scroll position).
  • Tap overlay closes; ESC closes; focus returns to the toggle.
  • Tab cycles inside the drawer (focus trap); the cycle reaches the toggle as the final stop so the morphed × is keyboard-reachable. Tab from the toggle wraps back to the first menu row. Tab from the toggle does NOT enter the drawer while it is closed (inert).
  • iOS Safari: scroll the hero halfway, open the drawer, attempt to swipe-scroll on the overlay or the panel — body must not scroll. Close the drawer via overlay/ESC/toggle → page returns to its pre-open scroll position (no jump to top).
  • Active-section row shows the whisper-green underscore bar; the bar slides in as you scroll through sections behind the open drawer.
  • Docs / Blog / Litepaper rows reachable from the drawer; Blog and Docs navigate in-app, Litepaper opens the PDF in a new tab.
  • From /blog/*: open the drawer, tap a section row — the page navigates to /#section (not /blog/foo/#section) and scrolls to it.
  • Open the drawer at 375px wide, then resize past 768px — drawer auto-closes, body scroll re-enables, nav tone reverts to its section-aware behavior.
  • Desktop (≥768px): hamburger gone, Docs/Blog/Litepaper visible, no behavior change.
  • DevTools "Emulate prefers-reduced-motion: reduce" → drawer fades in/out over 240ms (no slide); header, rows, and the active mark appear in place without stagger.

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 11, 2026 •

Copy link
Copy Markdown

Deploying website with  Cloudflare Pages  Cloudflare Pages

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

View logs

Copilot AI review requested due to automatic review settings May 11, 2026 08:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MobileMenu client component with overlay, focus trapping, ESC-to-close, and body scroll locking.
  • Integrates the mobile menu into Chrome and 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.

Comment thread components/site/MobileMenu.tsx
Comment thread components/site/MobileMenu.tsx Outdated
Comment thread app/globals.css
Comment thread app/globals.css Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/globals.css
Comment thread components/site/MobileMenu.tsx
Comment thread components/site/MobileMenu.tsx Outdated
Comment thread components/site/MobileMenu.tsx Outdated
yigitdot and others added 5 commits May 11, 2026 12:03
.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>
@yigitdot

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/site/Chrome.tsx Outdated
Comment thread components/site/MobileMenu.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 4 comments.

Comment thread components/site/MobileMenu.tsx Outdated
Comment on lines +147 to +151
style.width = prev.width;
style.overflow = prev.overflow;
prevBodyStyleRef.current = null;
window.scrollTo(0, scrollYRef.current);
}
Comment on lines +123 to +127
if (e.shiftKey && activeEl === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && activeEl === last) {
e.preventDefault();
Comment thread app/globals.css
Comment on lines +1044 to +1049
.mm-overlay {
transition: opacity 0ms;
}
.mm-panel {
transition: transform 0ms;
}
Comment thread app/globals.css Outdated
yigitdot and others added 2 commits May 11, 2026 14:44
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.
yigitdot added 2 commits May 11, 2026 21:35
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.
@thiras
thiras merged commit 9659721 into main May 11, 2026
7 checks passed
@thiras
thiras deleted the feat/mobile-menu-drawer branch May 11, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile UI: phone-specific design and settings

3 participants