fix(site): correct navigation state and anchor scrolling (#6)

- make the docs brand link return to the landing site
- keep landing navigation active state synchronized across routes and section links
- tighten sticky-header anchor scroll spacing
This commit is contained in:
Youhai
2026-07-20 19:39:10 +08:00
committed by GitHub
parent 0eaaa54e25
commit abf0a2f248
9 changed files with 123 additions and 62 deletions
+2 -3
View File
@@ -4,7 +4,6 @@
* toggle. The sidebar itself lives in the layout (router.tsx); this bar only flips
* its open state.
*/
import { Link } from "react-router";
import { S } from "../lib/strings";
import { REPO_URL, SITE_URL } from "../lib/links";
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
@@ -25,13 +24,13 @@ export function Nav({ menuOpen, onToggleMenu }: { menuOpen: boolean; onToggleMen
{menuOpen ? <XIcon className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
<Link to="/" className="flex items-center gap-2">
<a href={SITE_URL} className="flex items-center gap-2">
<img src={`${import.meta.env.BASE_URL}penguin-logo.svg`} alt="" className="h-7 w-7" />
<span className="text-[15px] font-semibold tracking-tight">{S.siteName}</span>
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:border-brand-900 dark:bg-brand-950 dark:text-brand-300">
{S.docsBadge}
</span>
</Link>
</a>
<div className="ml-auto flex items-center gap-1">
<a
+2 -9
View File
@@ -1,17 +1,10 @@
/** Site footer: brand + product/resource link columns + copyright. */
import { Link, useLocation } from "react-router";
import { Link } from "react-router";
import { S } from "../lib/strings";
import { DOCS_URL, LICENSE_URL, RELEASES_URL, REPO_URL } from "../lib/links";
export function Footer() {
const { pathname } = useLocation();
const onHome = pathname === "/";
const anchor = (id: string, label: string) =>
onHome ? (
<a href={`#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100">
{label}
</a>
) : (
const anchor = (id: string, label: string) => (
<Link to={`/#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100">
{label}
</Link>
+56 -35
View File
@@ -1,28 +1,28 @@
/**
* Sticky top navigation: logo + section anchors + blog link + language/theme toggles +
* GitHub. Desktop links share a sliding hover pill (position animated between items);
* section anchors use plain hashes on the home page (native smooth scroll) and route
* back to "/#id" from other pages; a disclosure menu covers small screens.
* GitHub. Desktop links share a sliding hover pill, while the selected section or route
* keeps its own active background; section links route through "/#id" so the Router
* and URL hash stay in sync while native smooth scrolling remains enabled. A disclosure
* menu covers small screens.
*/
import { useState } from "react";
import type { MouseEvent } from "react";
import { Link, useLocation } from "react-router";
import { S } from "../lib/strings";
import { DOCS_URL, REPO_URL } from "../lib/links";
import { getActiveNavItem, SECTION_IDS } from "../lib/nav-state";
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
import { ThemeToggle } from "./theme-toggle";
import { LangToggle } from "./lang-toggle";
const SECTION_IDS = ["highlights", "quickstart", "benchmark", "contract", "features"] as const;
interface Indicator {
left: number;
width: number;
}
export function Nav() {
const { pathname } = useLocation();
const onHome = pathname === "/";
const { pathname, hash } = useLocation();
const activeItem = getActiveNavItem(pathname, hash);
const [open, setOpen] = useState(false);
const [indicator, setIndicator] = useState<Indicator | null>(null);
@@ -34,11 +34,16 @@ export function Nav() {
features: S.nav.features,
};
// Mobile menu links keep their own hover backgrounds; desktop links rely on the pill.
const mobileLinkCls =
"rounded-md px-2.5 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100";
const deskLinkCls =
"relative z-10 rounded-md px-2.5 py-1.5 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100";
const activeLinkCls =
"bg-black text-white hover:bg-black hover:text-white dark:bg-black dark:text-white dark:ring-1 dark:ring-gray-600 dark:hover:bg-black dark:hover:text-white";
const inactiveLinkCls = "text-gray-600 dark:text-gray-400";
const mobileInactiveLinkCls = `${inactiveLinkCls} hover:bg-gray-50 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100`;
const deskInactiveLinkCls = `${inactiveLinkCls} hover:text-gray-900 dark:hover:text-gray-100`;
// Mobile links keep their own backgrounds; desktop links also share a sliding hover pill.
const mobileLinkCls = (active: boolean) =>
`rounded-md px-2.5 py-1.5 text-sm transition-colors ${active ? activeLinkCls : mobileInactiveLinkCls}`;
const deskLinkCls = (active: boolean) =>
`relative z-10 rounded-md px-2.5 py-1.5 text-sm transition-colors ${active ? activeLinkCls : deskInactiveLinkCls}`;
const slideTo = (e: MouseEvent<HTMLElement>) => {
const el = e.currentTarget;
@@ -47,22 +52,30 @@ export function Nav() {
const desktopLinks = (
<>
{SECTION_IDS.map((id) =>
onHome ? (
<a key={id} href={`#${id}`} className={deskLinkCls} onMouseEnter={slideTo}>
{sectionLabel[id]}
</a>
) : (
<Link key={id} to={`/#${id}`} className={deskLinkCls} onMouseEnter={slideTo}>
{SECTION_IDS.map((id) => {
const active = activeItem === id;
return (
<Link
key={id}
to={`/#${id}`}
className={deskLinkCls(active)}
onMouseEnter={slideTo}
aria-current={active ? "location" : undefined}
>
{sectionLabel[id]}
</Link>
),
)}
<Link to="/blog" className={deskLinkCls} onMouseEnter={slideTo}>
);
})}
<Link
to="/blog"
className={deskLinkCls(activeItem === "blog")}
onMouseEnter={slideTo}
aria-current={activeItem === "blog" ? "page" : undefined}
>
{S.nav.blog}
</Link>
{/* Docs is a sibling SPA under /docs/ — a plain anchor, not a router Link. */}
<a href={DOCS_URL} className={deskLinkCls} onMouseEnter={slideTo}>
<a href={DOCS_URL} className={deskLinkCls(false)} onMouseEnter={slideTo}>
{S.nav.docs}
</a>
</>
@@ -70,21 +83,29 @@ export function Nav() {
const mobileLinks = (
<>
{SECTION_IDS.map((id) =>
onHome ? (
<a key={id} href={`#${id}`} className={mobileLinkCls} onClick={() => setOpen(false)}>
{sectionLabel[id]}
</a>
) : (
<Link key={id} to={`/#${id}`} className={mobileLinkCls} onClick={() => setOpen(false)}>
{SECTION_IDS.map((id) => {
const active = activeItem === id;
return (
<Link
key={id}
to={`/#${id}`}
className={mobileLinkCls(active)}
onClick={() => setOpen(false)}
aria-current={active ? "location" : undefined}
>
{sectionLabel[id]}
</Link>
),
)}
<Link to="/blog" className={mobileLinkCls} onClick={() => setOpen(false)}>
);
})}
<Link
to="/blog"
className={mobileLinkCls(activeItem === "blog")}
onClick={() => setOpen(false)}
aria-current={activeItem === "blog" ? "page" : undefined}
>
{S.nav.blog}
</Link>
<a href={DOCS_URL} className={mobileLinkCls} onClick={() => setOpen(false)}>
<a href={DOCS_URL} className={mobileLinkCls(false)} onClick={() => setOpen(false)}>
{S.nav.docs}
</a>
</>
@@ -103,7 +124,7 @@ export function Nav() {
aria-label="Primary"
onMouseLeave={() => setIndicator(null)}
>
{/* Sliding hover pill: moves between links, fades when the pointer leaves. */}
{/* Sliding hover pill: moves between links; active links retain their own background. */}
<span
aria-hidden="true"
className="absolute top-1/2 h-8 -translate-y-1/2 rounded-md bg-gray-100 transition-[left,width,opacity] duration-200 ease-out dark:bg-gray-800"
+2 -2
View File
@@ -19,8 +19,8 @@ export function Section({
}) {
const ref = useReveal<HTMLElement>();
return (
<section id={id} ref={ref} className={`px-4 py-16 sm:px-6 sm:py-24 ${className}`}>
<div className="mx-auto max-w-6xl">
<section ref={ref} className={`px-4 py-16 sm:px-6 sm:py-24 ${className}`}>
<div id={id} className={`mx-auto max-w-6xl ${id ? "section-anchor" : ""}`}>
{(eyebrow || title || subtitle) && (
<div className="mx-auto mb-10 max-w-3xl text-center sm:mb-14">
{eyebrow && (
+26
View File
@@ -0,0 +1,26 @@
/**
* Pure route state for the landing page navigation. Section links are represented by
* hashes on the home page; Blog is a normal application route.
*/
export const SECTION_IDS = [
"highlights",
"quickstart",
"benchmark",
"contract",
"features",
] as const;
export type SectionId = (typeof SECTION_IDS)[number];
export type ActiveNavItem = SectionId | "blog" | null;
function isSectionId(value: string): value is SectionId {
return SECTION_IDS.some((id) => id === value);
}
export function getActiveNavItem(pathname: string, hash: string): ActiveNavItem {
if (pathname === "/blog" || pathname.startsWith("/blog/")) return "blog";
if (pathname !== "/") return null;
const id = hash.startsWith("#") ? hash.slice(1) : hash;
return isSectionId(id) ? id : null;
}
+4 -3
View File
@@ -1,4 +1,5 @@
/** Closing call-to-action: the productivity-engine pitch + install / docs buttons. */
import { Link } from "react-router";
import { S } from "../lib/strings";
import { DOCS_URL } from "../lib/links";
import { Section } from "../components/section";
@@ -15,13 +16,13 @@ export function Cta() {
{S.cta.subtitle}
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
<a
href="#quickstart"
<Link
to="/#quickstart"
className="inline-flex h-11 items-center gap-2 rounded-lg bg-gray-900 px-5 text-sm font-medium text-white transition-colors hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
>
{S.cta.install}
<ArrowRightIcon className="h-4 w-4" />
</a>
</Link>
<a
href={DOCS_URL}
className="inline-flex h-11 items-center rounded-lg border border-gray-200 bg-white px-5 text-sm font-medium text-gray-900 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800"
+4 -3
View File
@@ -5,6 +5,7 @@
* The rotating word is a stacked inline-grid so line width never jumps.
*/
import { Fragment, useEffect, useState } from "react";
import { Link } from "react-router";
import { S } from "../lib/strings";
import { INSTALL_CMD, REPO_URL } from "../lib/links";
import { CopyButton } from "../components/copy-button";
@@ -89,13 +90,13 @@ export function Hero() {
className="anim-rise mt-8 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "200ms" }}
>
<a
href="#quickstart"
<Link
to="/#quickstart"
className="inline-flex h-11 items-center gap-2 rounded-lg bg-gray-900 px-5 text-sm font-medium text-white transition-colors hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
>
{S.hero.ctaPrimary}
<ArrowRightIcon className="h-4 w-4" />
</a>
</Link>
<a
href={REPO_URL}
target="_blank"
+3 -3
View File
@@ -79,9 +79,9 @@
.dark * {
scrollbar-color: rgb(232 234 237 / 0.2) transparent;
}
/* Anchor sections scroll below the sticky header. */
section[id] {
scroll-margin-top: 4.5rem;
/* Anchor targets start at the section content, below the sticky header and its breathing room. */
.section-anchor {
scroll-margin-top: 5.5rem;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { getActiveNavItem } from "../src/lib/nav-state";
describe("landing navigation active state", () => {
it("tracks known section hashes on the home page", () => {
expect(getActiveNavItem("/", "#features")).toBe("features");
expect(getActiveNavItem("/", "#quickstart")).toBe("quickstart");
});
it("tracks the Blog route and blog posts", () => {
expect(getActiveNavItem("/blog", "")).toBe("blog");
expect(getActiveNavItem("/blog/release-notes", "")).toBe("blog");
});
it("does not mark unknown hashes or unrelated routes active", () => {
expect(getActiveNavItem("/", "#unknown")).toBeNull();
expect(getActiveNavItem("/blog", "#features")).toBe("blog");
expect(getActiveNavItem("/other", "#features")).toBeNull();
});
});