Skip to content

Commit e2bc619

Browse files
Hem Gabhawalaclaude
andcommitted
feat: phase 2.1 — sticky glass navbar, active-section highlight, mobile drawer
Typed nav config with documented anchor-id contract, IntersectionObserver active-section hook, desktop links with motion underline at xl+, Sheet drawer below, skip link, global smooth scroll + scroll-padding offset, temporary full-height section scaffold on the home page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 50115c0 commit e2bc619

15 files changed

Lines changed: 404 additions & 18 deletions

File tree

app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
3+
import { SiteHeader } from "@/components/layout/site-header";
34
import { MotionProvider } from "@/components/providers/motion-provider";
45
import { ThemeProvider } from "@/components/providers/theme-provider";
56
import { env } from "@/lib/env";
@@ -56,7 +57,10 @@ export default function RootLayout({
5657
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
5758
>
5859
<ThemeProvider>
59-
<MotionProvider>{children}</MotionProvider>
60+
<MotionProvider>
61+
<SiteHeader />
62+
{children}
63+
</MotionProvider>
6064
</ThemeProvider>
6165
</body>
6266
</html>

app/page.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
import { Container } from "@/components/layout/container";
2-
import { Separator } from "@/components/ui/separator";
3-
import { ThemeToggle } from "@/components/shared/theme-toggle";
2+
import { getNavSections, getProfile } from "@/lib/data";
43

54
/**
6-
* TEMPORARY smoke-test page (Phase 1C) — exercises the full Phase 1 stack:
7-
* Next.js App Router + design tokens + shadcn primitives + dark mode.
8-
* Replaced by the real Hero/sections in Phase 2.
5+
* TEMPORARY section scaffold (Phase 2.1) — one full-height placeholder per
6+
* anchor in the nav contract (data/navigation.ts), so the navbar's smooth
7+
* scroll and active-section highlight are exercisable end to end before the
8+
* real sections exist. Each placeholder is replaced in Phases 2.2+; the
9+
* anchor ids must not change.
910
*/
1011
export default function Home() {
12+
const sections = getNavSections();
13+
const { name, role } = getProfile();
14+
1115
return (
12-
<Container className="flex min-h-dvh flex-col items-center justify-center gap-6 text-center">
13-
<p className="font-mono text-sm text-muted-foreground">
14-
Phase 1 smoke test — replaced in Phase 2
15-
</p>
16-
<h1 className="text-4xl font-semibold tracking-tight">Hem Gabhawala</h1>
17-
<p className="max-w-md text-base text-muted-foreground">
18-
Cybersecurity portfolio under construction. Design tokens, dark mode,
19-
and primitives are live — see /dev/tokens for the full reference.
20-
</p>
21-
<Separator className="max-w-48" />
22-
<ThemeToggle />
23-
</Container>
16+
<main id="main">
17+
{sections.map(({ id, label }, index) => (
18+
<section
19+
key={id}
20+
id={id}
21+
className="flex min-h-dvh flex-col items-center justify-center border-b"
22+
>
23+
<Container className="text-center">
24+
{index === 0 ? (
25+
<h1 className="text-4xl font-semibold tracking-tight">
26+
{name}{role}
27+
</h1>
28+
) : (
29+
<h2 className="text-2xl font-semibold tracking-tight">{label}</h2>
30+
)}
31+
<p className="mt-3 font-mono text-sm text-muted-foreground">
32+
{label} placeholder — replaced in Phase 2.2+
33+
</p>
34+
</Container>
35+
</section>
36+
))}
37+
</main>
2438
);
2539
}

components/layout/mobile-nav.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import Link from "next/link";
5+
import { Menu } from "lucide-react";
6+
7+
import { Button } from "@/components/ui/button";
8+
import {
9+
Sheet,
10+
SheetContent,
11+
SheetHeader,
12+
SheetTitle,
13+
SheetTrigger,
14+
} from "@/components/ui/sheet";
15+
import type { NavSection } from "@/lib/types";
16+
import { cn } from "@/lib/utils";
17+
18+
/**
19+
* Below-xl navigation drawer. Radix Dialog (via Sheet) provides the focus
20+
* trap while open, focus restore to the trigger on close, and Escape/overlay
21+
* dismissal; open/close animation is the Sheet's CSS slide, which the global
22+
* reduced-motion rule flattens for users who opt out. Controlled `open` so a
23+
* link click closes the drawer before the smooth scroll runs.
24+
*/
25+
export function MobileNav({
26+
sections,
27+
activeId,
28+
}: {
29+
sections: NavSection[];
30+
activeId: string | null;
31+
}) {
32+
const [open, setOpen] = React.useState(false);
33+
34+
return (
35+
<Sheet open={open} onOpenChange={setOpen}>
36+
<SheetTrigger asChild>
37+
<Button
38+
variant="ghost"
39+
size="icon"
40+
className="xl:hidden"
41+
aria-label="Open navigation menu"
42+
>
43+
<Menu aria-hidden />
44+
</Button>
45+
</SheetTrigger>
46+
<SheetContent side="right" aria-describedby={undefined}>
47+
<SheetHeader>
48+
<SheetTitle className="text-sm text-muted-foreground">
49+
Navigation
50+
</SheetTitle>
51+
</SheetHeader>
52+
<nav aria-label="Primary" className="overflow-y-auto">
53+
<ul className="flex flex-col gap-1 px-4 pb-6">
54+
{sections.map(({ id, label }) => {
55+
const isActive = id === activeId;
56+
return (
57+
<li key={id}>
58+
<Link
59+
href={`/#${id}`}
60+
onClick={() => setOpen(false)}
61+
aria-current={isActive ? "location" : undefined}
62+
className={cn(
63+
"block rounded-md px-3 py-2.5 text-base transition-colors",
64+
isActive
65+
? "bg-accent font-medium text-accent-foreground"
66+
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
67+
)}
68+
>
69+
{label}
70+
</Link>
71+
</li>
72+
);
73+
})}
74+
</ul>
75+
</nav>
76+
</SheetContent>
77+
</Sheet>
78+
);
79+
}

components/layout/nav-links.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { motion } from "motion/react";
5+
6+
import { transitionFast } from "@/lib/motion";
7+
import type { NavSection } from "@/lib/types";
8+
import { cn } from "@/lib/utils";
9+
10+
/**
11+
* Desktop link list with the animated active-section indicator. Presentational
12+
* only — active state is computed once in SiteNav and passed down. The shared
13+
* `layoutId` makes the underline glide between links as the active section
14+
* changes (disabled automatically under reduced motion via MotionConfig).
15+
*/
16+
export function NavLinks({
17+
sections,
18+
activeId,
19+
}: {
20+
sections: NavSection[];
21+
activeId: string | null;
22+
}) {
23+
return (
24+
<ul className="flex items-center">
25+
{sections.map(({ id, label }) => {
26+
const isActive = id === activeId;
27+
return (
28+
<li key={id} className="relative">
29+
<Link
30+
href={`/#${id}`}
31+
aria-current={isActive ? "location" : undefined}
32+
className={cn(
33+
"block px-2.5 py-2 text-sm whitespace-nowrap transition-colors",
34+
isActive
35+
? "text-primary"
36+
: "text-muted-foreground hover:text-foreground",
37+
)}
38+
>
39+
{label}
40+
</Link>
41+
{isActive && (
42+
<motion.span
43+
aria-hidden
44+
layoutId="active-section-indicator"
45+
transition={transitionFast}
46+
className="absolute inset-x-2.5 bottom-0 h-0.5 rounded-full bg-primary"
47+
/>
48+
)}
49+
</li>
50+
);
51+
})}
52+
</ul>
53+
);
54+
}

components/layout/site-header.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import Link from "next/link";
2+
3+
import { Container } from "@/components/layout/container";
4+
import { SiteNav } from "@/components/layout/site-nav";
5+
import { ThemeToggle } from "@/components/shared/theme-toggle";
6+
import { getNavSections, getProfile } from "@/lib/data";
7+
8+
/**
9+
* Sticky site header — server shell; all interactivity lives in the client
10+
* leaves it composes (SiteNav, ThemeToggle). `sticky` keeps the header in
11+
* normal flow, so it causes no layout shift; anchored sections clear it via
12+
* the global scroll-padding-top (see styles/globals.css and the anchor
13+
* contract in data/navigation.ts).
14+
*
15+
* The header row runs wider (max-w-6xl) than the max-w-5xl content column —
16+
* deliberate, Vercel-style: 11 nav links need the extra room, and a wider
17+
* frame around a narrower column is part of the enterprise idiom.
18+
*/
19+
export function SiteHeader() {
20+
const sections = getNavSections();
21+
const { name } = getProfile();
22+
23+
return (
24+
<header className="sticky top-0 z-50 glass border-x-0 border-t-0">
25+
<a
26+
href="#main"
27+
className="sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:top-20 focus-visible:left-6 focus-visible:z-50 focus-visible:rounded-md focus-visible:bg-primary focus-visible:px-4 focus-visible:py-2 focus-visible:text-sm focus-visible:font-medium focus-visible:text-primary-foreground"
28+
>
29+
Skip to content
30+
</a>
31+
<Container className="flex h-16 max-w-6xl items-center justify-between gap-6">
32+
<Link
33+
href="/#hero"
34+
className="text-sm font-semibold tracking-tight whitespace-nowrap"
35+
>
36+
{name}
37+
</Link>
38+
<SiteNav sections={sections}>
39+
<ThemeToggle />
40+
</SiteNav>
41+
</Container>
42+
</header>
43+
);
44+
}

components/layout/site-nav.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
3+
import { useActiveSection } from "@/lib/hooks/use-active-section";
4+
import type { NavSection } from "@/lib/types";
5+
6+
import { MobileNav } from "./mobile-nav";
7+
import { NavLinks } from "./nav-links";
8+
9+
/**
10+
* The one client owner of navigation state: a single IntersectionObserver
11+
* (useActiveSection) feeds both the desktop list and the mobile drawer, so
12+
* the two can never disagree. `children` is a slot for the ThemeToggle —
13+
* passed in from the server shell so this component stays ignorant of it.
14+
*
15+
* Desktop links appear at xl (1280px+): 11 sections don't fit the header row
16+
* at md/lg without truncating labels, so tablets get the drawer. The hidden
17+
* variant uses display:none, which also removes the inactive nav from the
18+
* accessibility tree — only one "Primary" nav is ever exposed.
19+
*/
20+
export function SiteNav({
21+
sections,
22+
children,
23+
}: {
24+
sections: NavSection[];
25+
children?: React.ReactNode;
26+
}) {
27+
const activeId = useActiveSection(sections.map((s) => s.id));
28+
29+
return (
30+
<>
31+
<nav aria-label="Primary" className="hidden xl:block">
32+
<NavLinks sections={sections} activeId={activeId} />
33+
</nav>
34+
<div className="flex items-center gap-1">
35+
{children}
36+
<MobileNav sections={sections} activeId={activeId} />
37+
</div>
38+
</>
39+
);
40+
}

data/navigation.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { NavSection } from "@/lib/types";
2+
3+
/**
4+
* ANCHOR ID CONTRACT (Phase 2.1) — read before building any section.
5+
*
6+
* These ids are the single source of truth for in-page navigation. Every
7+
* section component built in Phases 2.2+ MUST render a top-level element
8+
* with the matching `id` (e.g. `<section id="hero">`), in this order.
9+
*
10+
* - The navbar's active-section highlight observes exactly these ids; a
11+
* section that renders a different id will never highlight.
12+
* - Jump-to-section offset under the sticky navbar is handled globally via
13+
* `scroll-padding-top` on `html` (styles/globals.css) — sections must NOT
14+
* add their own `scroll-mt-*`.
15+
* - The navbar height is h-16 (4rem); `scroll-padding-top` is 5rem to leave
16+
* breathing room below it.
17+
* - "Home" points at the Hero section — there is no separate home anchor.
18+
*
19+
* Changing an id here is a breaking change for every section that anchors
20+
* to it; do it only in a change that updates both sides.
21+
*/
22+
export const navSections = [
23+
{ id: "hero", label: "Home" },
24+
{ id: "about", label: "About" },
25+
{ id: "journey", label: "Journey" },
26+
{ id: "skills", label: "Skills" },
27+
{ id: "experience", label: "Experience" },
28+
{ id: "projects", label: "Projects" },
29+
{ id: "certifications", label: "Certifications" },
30+
{ id: "tryhackme", label: "TryHackMe" },
31+
{ id: "github", label: "GitHub" },
32+
{ id: "blog", label: "Blog" },
33+
{ id: "contact", label: "Contact" },
34+
] satisfies NavSection[];

lib/data/data.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
22
import { blogPosts } from "@/data/blog";
33
import { certificates } from "@/data/certificates";
44
import { experience } from "@/data/experience";
5+
import { navSections } from "@/data/navigation";
56
import { profile } from "@/data/profile";
67
import { projects } from "@/data/projects";
78
import {
89
blogPostsSchema,
910
certificatesSchema,
1011
experiencesSchema,
12+
navSectionsSchema,
1113
profileSchema,
1214
projectsSchema,
1315
} from "@/lib/validations";
@@ -52,6 +54,12 @@ describe("static data matches schemas", () => {
5254
});
5355
});
5456

57+
it("nav sections (incl. unique anchor ids)", () => {
58+
const result = navSectionsSchema.safeParse(navSections);
59+
expect(result.error?.issues ?? []).toEqual([]);
60+
expect(result.success).toBe(true);
61+
});
62+
5563
it("includes the PRD-named projects", () => {
5664
const titles = projects.map((p) => p.title);
5765
expect(titles).toEqual(

lib/data/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { blogPosts } from "@/data/blog";
22
import { certificates } from "@/data/certificates";
33
import { experience } from "@/data/experience";
4+
import { navSections } from "@/data/navigation";
45
import { profile } from "@/data/profile";
56
import { projects } from "@/data/projects";
67
import type {
78
BlogPost,
89
Certificate,
910
Experience,
11+
NavSection,
1012
Profile,
1113
Project,
1214
} from "@/lib/types";
@@ -23,6 +25,11 @@ export function getProfile(): Profile {
2325
return profile;
2426
}
2527

28+
/** In-page sections in scroll order — see data/navigation.ts for the anchor id contract. */
29+
export function getNavSections(): NavSection[] {
30+
return [...navSections];
31+
}
32+
2633
export function getProjects(): Project[] {
2734
return [...projects];
2835
}

0 commit comments

Comments
 (0)