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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user