Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The box-shadow transition is added here, but it appears to be missing from the prefers-reduced-motion override for .chrome-nav (located later in the file around line 842). This will cause the shadow to snap instantly for users with reduced motion enabled, which contradicts the PR description's claim that shadow transitions 'complete cleanly'. Consider adding box-shadow 360ms ... to that block to ensure a smooth fade-in for all users.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0a29a6a — added box-shadow 360ms to the reduced-motion .chrome-nav transition list (alongside background-color). The PR-description claim was wrong as written; the override at the bottom of the file was indeed gutting the shadow under reduced-motion.

color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

Expand All @@ -735,6 +736,27 @@ a.underline-brutal:hover .arrow {
border-bottom-color: color-mix(in srgb, var(--paper) 26%, transparent);
}

/* Drawer open: lift the nav above the overlay (55) and below the
panel (60), and swap the translucent backdrop for an opaque
section-toned bar. The shadow is load-bearing — without it, paper
nav on paper section (or ink on ink) reads as no nav at all. */
.chrome-nav[data-mobile-open="true"] {
backdrop-filter: none;
-webkit-backdrop-filter: none;
z-index: 58;
}
.chrome-nav[data-mobile-open="true"][data-tone="paper"] {
background-color: var(--paper);
border-bottom-color: color-mix(in srgb, var(--ink) 24%, transparent);
box-shadow: 0 10px 22px -12px color-mix(in srgb, var(--ink) 26%, transparent);
}
.chrome-nav[data-mobile-open="true"][data-tone="ink"] {
background-color: var(--ink);
border-bottom-color: color-mix(in srgb, var(--paper) 26%, transparent);
box-shadow: 0 10px 22px -12px
color-mix(in srgb, var(--paper) 22%, transparent);
}

/* Safari <17 etc.: if backdrop-filter isn't supported, collapse to a
fully opaque bar so text stays legible instead of floating over
content. */
Expand Down Expand Up @@ -811,7 +833,10 @@ a.underline-brutal:hover .arrow {

@media (prefers-reduced-motion: reduce) {
.chrome-nav {
transition: background-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition:
background-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
Comment thread
yigitdot marked this conversation as resolved.
border-bottom-color 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 360ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nav-item .nav-label,
.nav-item .nav-underline {
Expand Down
9 changes: 4 additions & 5 deletions components/site/Chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,9 @@ export function Chrome() {
}, []);

const onDark = DARK_SECTIONS.has(active);
// The toggle is portalled to <body> (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 <body> (see MobileMenu.tsx) and lives
// on top of the paper panel while the drawer is open — force it to
// paper then so its × reads against the white panel.
const toggleTone = mobileOpen ? "paper" : onDark ? "ink" : "paper";

return (
Expand All @@ -130,6 +128,7 @@ export function Chrome() {
aria-label="Primary"
data-scrolled={scrolled ? "true" : undefined}
data-tone={onDark ? "ink" : "paper"}
data-mobile-open={mobileOpen ? "true" : undefined}
className={`chrome-nav fixed inset-x-0 top-0 z-50 py-5 ${
onDark ? "text-paper" : "text-ink"
}`}
Expand Down
120 changes: 95 additions & 25 deletions components/site/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,64 @@ type DrawerLink = {
};

const EXTERNAL: readonly DrawerLink[] = [
{ kind: "internal", href: links.docs, label: "Docs" },
{ kind: "internal", href: links.blog, label: "Blog" },
{ kind: "external", href: links.litepaper, label: "Litepaper" },
{ kind: "internal", href: links.docs, label: "docs" },
{ kind: "internal", href: links.blog, label: "blog" },
{ kind: "external", href: links.litepaper, label: "litepaper" },
Comment thread
yigitdot marked this conversation as resolved.
] as const;

const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';

// `<html>` scroll-behavior is forced to `auto` for the lifetime of
// the rAF: without it, each per-frame `window.scrollTo` defers to
// `motion-safe:scroll-smooth` and queues yet another browser
// smooth-scroll per tick, which fights the rAF and reads as a
// lumpy "slow then sudden" scroll. The loop is single-flight — a
// new call cancels any in-flight rAF and eagerly restores its
// captured scroll-behavior so we never snapshot the polluted
// "auto" value (which would otherwise survive past the helper and
// permanently override the page's CSS scroll-smooth).
let scrollAnchorRaf = 0;
let scrollAnchorRestore: (() => void) | null = null;

function scrollToAnchor(el: HTMLElement) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.scrollIntoView({ block: "start" });
return;
}
if (scrollAnchorRaf !== 0) {
cancelAnimationFrame(scrollAnchorRaf);
scrollAnchorRestore?.();
scrollAnchorRaf = 0;
scrollAnchorRestore = null;
}
const startY = window.scrollY;
const marginTop = parseFloat(getComputedStyle(el).scrollMarginTop);
const targetY = startY + el.getBoundingClientRect().top - marginTop;
const distance = targetY - startY;
if (distance === 0) return;
const html = document.documentElement;
const prevScrollBehavior = html.style.scrollBehavior;
html.style.scrollBehavior = "auto";
scrollAnchorRestore = () => {
html.style.scrollBehavior = prevScrollBehavior;
};
const duration = 600;
const startTime = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - startTime) / duration);
window.scrollTo(0, startY + distance * t);
if (t < 1) {
scrollAnchorRaf = requestAnimationFrame(tick);
} else {
scrollAnchorRestore?.();
scrollAnchorRestore = null;
scrollAnchorRaf = 0;
}
};
scrollAnchorRaf = requestAnimationFrame(tick);
}

// Hydration gate for the createPortal target. Reads false on the
// server and true after hydration, without a useState+useEffect
// round-trip that would trip the react-hooks/set-state-in-effect rule.
Expand Down Expand Up @@ -142,9 +192,8 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
return () => {
cancelAnimationFrame(raf);
document.removeEventListener("keydown", onKey);
// Removing `position: fixed` snaps the page back to y=0 because
// the negative `top` was the only thing holding the viewport in
// place — scroll somewhere explicitly after restoring styles.
// Removing `position: fixed` leaves the viewport at y=0 — the
// negative `top` was the only thing holding it.
const prev = prevBodyStyleRef.current!;
const style = document.body.style;
style.position = prev.position;
Expand All @@ -157,21 +206,35 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {

const anchor = pendingAnchorRef.current;
pendingAnchorRef.current = null;
if (anchor) {
const el = document.getElementById(anchor);
if (el) {
// history.replaceState doesn't scroll; smooth-scroll via
// scrollIntoView. `motion-safe:scroll-smooth` on <html>
// honours reduced-motion automatically.
history.replaceState(null, "", `#${anchor}`);
el.scrollIntoView({ block: "start" });
} else {
// Anchor target lives on another route (drawer opened from
// /blog/*). Let the browser navigate to home + hash.
window.location.assign(`/#${anchor}`);
}
} else {
window.scrollTo(0, scrollYRef.current);
const targetEl = anchor ? document.getElementById(anchor) : null;

if (anchor && !targetEl) {
// Drawer opened from a different route (e.g. /blog/*); a
// full reload to /#anchor lets the new page handle the hash
// scroll deterministically (Next 16 App Router's soft-nav
// hash behaviour under output: "export" isn't documented).
// Skip the local restore — the page is about to unload.
window.location.assign(`/#${anchor}`);
return;
}
Comment thread
yigitdot marked this conversation as resolved.

// Restore the prior scroll position synchronously. We bypass
// `<html>`'s motion-safe:scroll-smooth manually instead of
// passing `behavior: "instant"` — Safari only honoured the
// "instant" enum from 18.4; older versions silently fall back
// to "auto", which re-introduces the smooth scroll from y=0
// we're trying to suppress.
const html = document.documentElement;
const prevScrollBehavior = html.style.scrollBehavior;
html.style.scrollBehavior = "auto";
window.scrollTo(0, scrollYRef.current);
html.style.scrollBehavior = prevScrollBehavior;

if (anchor && targetEl) {
// replaceState updates the URL without scrolling;
// scrollToAnchor does the visible scroll.
history.replaceState(null, "", `#${anchor}`);
scrollToAnchor(targetEl);
}
};
}, [open]);
Expand Down Expand Up @@ -214,9 +277,10 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {

// Portal target: toggle/overlay/panel all live in <body> 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 = (
<>
<button
Expand Down Expand Up @@ -301,7 +365,13 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
</ul>

<footer className="mm-foot">
<span className="meta">decdn / labs</span>
<span className="meta">
decdn
<span aria-hidden="true" className="text-whisper">
_
</span>
labs
</span>
</footer>
</aside>
</>
Expand Down