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:
1 parent
0eaaa54e25
commit
abf0a2f248
9 files changed
+123
-62
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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. */
|
/** 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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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