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 authored and GitHub committed 2026-07-20 20:39:10 +09:00
1 parent 0eaaa54e25
commit abf0a2f248
9 files changed
+123 -62

No files matched your search

+2 -3
View File
@@ -4,7 +4,6 @@
* toggle. The sidebar itself lives in the layout (router.tsx); this bar only flips * toggle. The sidebar itself lives in the layout (router.tsx); this bar only flips
* its open state. * its open state.
*/ */
import { Link } from "react-router";
import { S } from "../lib/strings"; import { S } from "../lib/strings";
import { REPO_URL, SITE_URL } from "../lib/links"; import { REPO_URL, SITE_URL } from "../lib/links";
import { GitHubIcon, MenuIcon, XIcon } from "./icons"; 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" />} {menuOpen ? <XIcon className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button> </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" /> <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="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"> <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} {S.docsBadge}
</span> </span>
</Link> </a>
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<a <a
+6 -13
View File
@@ -1,21 +1,14 @@
/** Site footer: brand + product/resource link columns + copyright. */ /** 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 { S } from "../lib/strings";
import { DOCS_URL, LICENSE_URL, RELEASES_URL, REPO_URL } from "../lib/links"; import { DOCS_URL, LICENSE_URL, RELEASES_URL, REPO_URL } from "../lib/links";
export function Footer() { export function Footer() {
const { pathname } = useLocation(); const anchor = (id: string, label: string) => (
const onHome = pathname === "/"; <Link to={`/#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100">
const anchor = (id: string, label: string) => {label}
onHome ? ( </Link>
<a href={`#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100"> );
{label}
</a>
) : (
<Link to={`/#${id}`} className="hover:text-gray-900 dark:hover:text-gray-100">
{label}
</Link>
);
return ( return (
<footer className="border-t border-gray-200 dark:border-gray-800"> <footer className="border-t border-gray-200 dark:border-gray-800">
+56 -35
View File
@@ -1,28 +1,28 @@
/** /**
* Sticky top navigation: logo + section anchors + blog link + language/theme toggles + * Sticky top navigation: logo + section anchors + blog link + language/theme toggles +
* GitHub. Desktop links share a sliding hover pill (position animated between items); * GitHub. Desktop links share a sliding hover pill, while the selected section or route
* section anchors use plain hashes on the home page (native smooth scroll) and route * keeps its own active background; section links route through "/#id" so the Router
* back to "/#id" from other pages; a disclosure menu covers small screens. * and URL hash stay in sync while native smooth scrolling remains enabled. A disclosure
* menu covers small screens.
*/ */
import { useState } from "react"; import { useState } from "react";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { S } from "../lib/strings"; import { S } from "../lib/strings";
import { DOCS_URL, REPO_URL } from "../lib/links"; import { DOCS_URL, REPO_URL } from "../lib/links";
import { getActiveNavItem, SECTION_IDS } from "../lib/nav-state";
import { GitHubIcon, MenuIcon, XIcon } from "./icons"; import { GitHubIcon, MenuIcon, XIcon } from "./icons";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LangToggle } from "./lang-toggle"; import { LangToggle } from "./lang-toggle";
const SECTION_IDS = ["highlights", "quickstart", "benchmark", "contract", "features"] as const;
interface Indicator { interface Indicator {
left: number; left: number;
width: number; width: number;
} }
export function Nav() { export function Nav() {
const { pathname } = useLocation(); const { pathname, hash } = useLocation();
const onHome = pathname === "/"; const activeItem = getActiveNavItem(pathname, hash);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [indicator, setIndicator] = useState<Indicator | null>(null); const [indicator, setIndicator] = useState<Indicator | null>(null);
@@ -34,11 +34,16 @@ export function Nav() {
features: S.nav.features, features: S.nav.features,
}; };
// Mobile menu links keep their own hover backgrounds; desktop links rely on the pill. const activeLinkCls =
const mobileLinkCls = "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";
"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 inactiveLinkCls = "text-gray-600 dark:text-gray-400";
const deskLinkCls = const mobileInactiveLinkCls = `${inactiveLinkCls} hover:bg-gray-50 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100`;
"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 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 slideTo = (e: MouseEvent<HTMLElement>) => {
const el = e.currentTarget; const el = e.currentTarget;
@@ -47,22 +52,30 @@ export function Nav() {
const desktopLinks = ( const desktopLinks = (
<> <>
{SECTION_IDS.map((id) => {SECTION_IDS.map((id) => {
onHome ? ( const active = activeItem === id;
<a key={id} href={`#${id}`} className={deskLinkCls} onMouseEnter={slideTo}> return (
{sectionLabel[id]} <Link
</a> key={id}
) : ( to={`/#${id}`}
<Link key={id} to={`/#${id}`} className={deskLinkCls} onMouseEnter={slideTo}> className={deskLinkCls(active)}
onMouseEnter={slideTo}
aria-current={active ? "location" : undefined}
>
{sectionLabel[id]} {sectionLabel[id]}
</Link> </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} {S.nav.blog}
</Link> </Link>
{/* Docs is a sibling SPA under /docs/ — a plain anchor, not a router 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} {S.nav.docs}
</a> </a>
</> </>
@@ -70,21 +83,29 @@ export function Nav() {
const mobileLinks = ( const mobileLinks = (
<> <>
{SECTION_IDS.map((id) => {SECTION_IDS.map((id) => {
onHome ? ( const active = activeItem === id;
<a key={id} href={`#${id}`} className={mobileLinkCls} onClick={() => setOpen(false)}> return (
{sectionLabel[id]} <Link
</a> key={id}
) : ( to={`/#${id}`}
<Link key={id} to={`/#${id}`} className={mobileLinkCls} onClick={() => setOpen(false)}> className={mobileLinkCls(active)}
onClick={() => setOpen(false)}
aria-current={active ? "location" : undefined}
>
{sectionLabel[id]} {sectionLabel[id]}
</Link> </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} {S.nav.blog}
</Link> </Link>
<a href={DOCS_URL} className={mobileLinkCls} onClick={() => setOpen(false)}> <a href={DOCS_URL} className={mobileLinkCls(false)} onClick={() => setOpen(false)}>
{S.nav.docs} {S.nav.docs}
</a> </a>
</> </>
@@ -103,7 +124,7 @@ export function Nav() {
aria-label="Primary" aria-label="Primary"
onMouseLeave={() => setIndicator(null)} 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 <span
aria-hidden="true" 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" 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>(); const ref = useReveal<HTMLElement>();
return ( return (
<section id={id} ref={ref} className={`px-4 py-16 sm:px-6 sm:py-24 ${className}`}> <section ref={ref} className={`px-4 py-16 sm:px-6 sm:py-24 ${className}`}>
<div className="mx-auto max-w-6xl"> <div id={id} className={`mx-auto max-w-6xl ${id ? "section-anchor" : ""}`}>
{(eyebrow || title || subtitle) && ( {(eyebrow || title || subtitle) && (
<div className="mx-auto mb-10 max-w-3xl text-center sm:mb-14"> <div className="mx-auto mb-10 max-w-3xl text-center sm:mb-14">
{eyebrow && ( {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. */ /** Closing call-to-action: the productivity-engine pitch + install / docs buttons. */
import { Link } from "react-router";
import { S } from "../lib/strings"; import { S } from "../lib/strings";
import { DOCS_URL } from "../lib/links"; import { DOCS_URL } from "../lib/links";
import { Section } from "../components/section"; import { Section } from "../components/section";
@@ -15,13 +16,13 @@ export function Cta() {
{S.cta.subtitle} {S.cta.subtitle}
</p> </p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-3"> <div className="mt-7 flex flex-wrap items-center justify-center gap-3">
<a <Link
href="#quickstart" 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" 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} {S.cta.install}
<ArrowRightIcon className="h-4 w-4" /> <ArrowRightIcon className="h-4 w-4" />
</a> </Link>
<a <a
href={DOCS_URL} 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" 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. * The rotating word is a stacked inline-grid so line width never jumps.
*/ */
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { Link } from "react-router";
import { S } from "../lib/strings"; import { S } from "../lib/strings";
import { INSTALL_CMD, REPO_URL } from "../lib/links"; import { INSTALL_CMD, REPO_URL } from "../lib/links";
import { CopyButton } from "../components/copy-button"; 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" className="anim-rise mt-8 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "200ms" }} style={{ animationDelay: "200ms" }}
> >
<a <Link
href="#quickstart" 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" 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} {S.hero.ctaPrimary}
<ArrowRightIcon className="h-4 w-4" /> <ArrowRightIcon className="h-4 w-4" />
</a> </Link>
<a <a
href={REPO_URL} href={REPO_URL}
target="_blank" target="_blank"
+3 -3
View File
@@ -79,9 +79,9 @@
.dark * { .dark * {
scrollbar-color: rgb(232 234 237 / 0.2) transparent; scrollbar-color: rgb(232 234 237 / 0.2) transparent;
} }
/* Anchor sections scroll below the sticky header. */ /* Anchor targets start at the section content, below the sticky header and its breathing room. */
section[id] { .section-anchor {
scroll-margin-top: 4.5rem; 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();
});
});