Files
portfolio/docs/plans/2026-05-07-url-driven-section-routing.md
T

883 lines
25 KiB
Markdown
Raw Normal View History

# URL-Driven Section Routing Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the single-page client-state accordion with URL-driven routing — each section gets its own static URL (`/intro`, `/bio`, etc.), clicking a section heading navigates between pages.
**Architecture:** `app/[[...slug]]/page.tsx` is the single RSC that handles all routes. It resolves the active slug from URL params (defaulting to first section at `/`), then passes `activeSlug` down to `SectionsAccordion` (now a server component). `SectionAccordion` entity renders inactive sections as `<Link>` elements. `SidebarNav` uses `usePathname()` for active state.
**Tech Stack:** Next.js 16 App Router, React 19, Vitest + RTL, TypeScript strict, Biome, `next/link`, `next/navigation`.
---
## Task 0: Commit next.config.ts fix
The `output: 'export'` was already made conditional on `NODE_ENV === 'production'` to allow route handlers in dev. Commit it.
**Files:**
- Already modified: `next.config.ts`
**Step 1: Verify file is correct**
`next.config.ts` should read:
```ts
import type { NextConfig } from 'next';
const isExport = process.env.NODE_ENV === 'production';
const nextConfig: NextConfig = {
/* output: 'export' only applies at build time — enabling it in dev mode
* breaks route handlers (incompatible with force-dynamic in Next.js 16) */
...(isExport ? { output: 'export' } : {}),
images: { unoptimized: true },
};
export default nextConfig;
```
**Step 2: Commit**
```bash
git add next.config.ts
git commit -m "fix: make output export build-only so dev route handlers work"
```
---
## Task 1: Update SectionAccordion entity — onClick → href (TDD)
Replace the inactive `<button onClick>` with `<Link href>`. The entity already has tests — update them first.
**Files:**
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx`
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`
**Step 1: Update the tests**
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx` entirely:
```tsx
import { render, screen } from '@testing-library/react';
import { SectionAccordion } from './SectionAccordion';
const defaultProps = {
number: '01',
title: 'About',
id: 'about',
isActive: false,
href: '/about',
children: <p>Content here</p>,
};
describe('SectionAccordion', () => {
describe('collapsed state (isActive=false)', () => {
it('renders a section element with the given id', () => {
const { container } = render(<SectionAccordion {...defaultProps} />);
expect(container.querySelector('section#about')).toBeInTheDocument();
});
it('renders a link with number and title', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
});
it('link points to the correct href', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
});
it('does not render children', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
});
it('does not render a button', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
describe('active state (isActive=true)', () => {
const activeProps = { ...defaultProps, isActive: true };
it('renders an h1 with number and title', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
});
it('renders children', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByText('Content here')).toBeInTheDocument();
});
it('does not render a link', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('content wrapper has animate-fadeIn class', () => {
const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/entities/Section/ui/SectionAccordion --run
```
Expected: FAIL — tests expecting `link` role but component still renders `button`.
**Step 3: Update the component**
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`:
```tsx
import Link from 'next/link';
import type { ReactNode } from 'react';
interface SectionAccordionProps {
/**
* Display number prefix (e.g. "01")
*/
number: string;
/**
* Section title
*/
title: string;
/**
* HTML id for anchor navigation
*/
id: string;
/**
* Whether this section is expanded
*/
isActive: boolean;
/**
* Navigation URL for the collapsed heading link
*/
href: string;
/**
* Section content, shown when active
*/
children: ReactNode;
}
/**
* Accordion-style section that collapses to a navigation link when inactive.
*/
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
return (
<section id={id} className="scroll-mt-8">
{isActive ? (
<div className="mb-12">
<div className="mb-16">
<h1
className="font-heading font-black text-5xl leading-[1.2] mb-0"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
>
{number}. {title}
</h1>
</div>
<div className="animate-fadeIn">{children}</div>
</div>
) : (
<Link
href={href}
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
>
<h2
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
>
{number}. {title}
</h2>
</Link>
)}
</section>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/entities/Section/ui/SectionAccordion --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/entities/Section/ui/SectionAccordion/
git commit -m "feat: SectionAccordion inactive state uses Link href instead of button onClick"
```
---
## Task 2: Update SectionsAccordion widget — drop client state, add activeSlug prop (TDD)
The widget becomes a server component. `activeSlug` is passed as a prop from the page. `children` is a single RSC slot for the active section content only.
**Files:**
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`
**Step 1: Rewrite the tests**
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import type { SectionRecord } from '$entities/Section';
import { SectionsAccordion } from './SectionsAccordion';
const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
const sections: SectionRecord[] = [
{ ...baseRecord, id: '1', slug: 'intro', title: 'Intro', number: '01', order: 1 },
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
];
describe('SectionsAccordion', () => {
describe('active section rendering', () => {
it('renders the active section as h1', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
});
it('renders active section children', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getByText('Bio content')).toBeInTheDocument();
});
});
describe('inactive section rendering', () => {
it('renders inactive sections as links', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
});
it('inactive links point to correct hrefs', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
});
it('does not render children for inactive sections', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getAllByText('Bio content')).toHaveLength(1);
});
});
describe('first section default', () => {
it('shows first section as active when activeSlug matches first', () => {
render(
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/widgets/SectionsAccordion --run
```
Expected: FAIL — component still uses `useState` and doesn't accept `activeSlug` prop.
**Step 3: Rewrite the component**
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`:
```tsx
import type { ReactNode } from 'react';
import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section';
type Props = {
/**
* Ordered section metadata — drives navigation labels and IDs
*/
sections: SectionRecord[];
/**
* Slug of the currently active section
*/
activeSlug: string;
/**
* Content for the active section — rendered inside the expanded accordion item
*/
children: ReactNode;
};
/**
* Renders all portfolio sections as an accordion list.
* Active section is determined by URL (activeSlug from page params).
* Inactive sections render as navigation links.
*/
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
return (
<div className="space-y-2">
{sections.map((section) => (
<SectionAccordion
key={section.slug}
id={section.slug}
number={section.number}
title={section.title}
isActive={activeSlug === section.slug}
href={`/${section.slug}`}
>
{activeSlug === section.slug ? children : null}
</SectionAccordion>
))}
</div>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/widgets/SectionsAccordion --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/widgets/SectionsAccordion/
git commit -m "refactor: SectionsAccordion server component, activeSlug prop replaces useState"
```
---
## Task 3: Update SidebarNav — IntersectionObserver → usePathname (TDD)
Items become `<Link>` elements. Active state driven by `usePathname()`. The first section is also active at `/`.
**Files:**
- Modify: `src/widgets/Navigation/ui/SidebarNav.test.tsx`
- Modify: `src/widgets/Navigation/ui/SidebarNav.tsx`
**Step 1: Rewrite the tests**
Replace `src/widgets/Navigation/ui/SidebarNav.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import type { NavItem } from '../model/types';
import { SidebarNav } from './SidebarNav';
vi.mock('next/navigation', () => ({
usePathname: vi.fn(),
}));
import { usePathname } from 'next/navigation';
const ITEMS: NavItem[] = [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
];
describe('SidebarNav', () => {
describe('rendering', () => {
beforeEach(() => {
vi.mocked(usePathname).mockReturnValue('/bio');
});
it('renders a nav element', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('renders "Index" heading', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Index')).toBeInTheDocument();
});
it('renders "Digital Monograph" subtitle', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
});
it('renders each item label and number', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Bio')).toBeInTheDocument();
expect(screen.getByText('01')).toBeInTheDocument();
expect(screen.getByText('Work')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument();
});
it('renders "Quick Links" section', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Quick Links')).toBeInTheDocument();
});
it('renders Email quick link', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
});
it('renders a link for each item', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
});
});
describe('active state', () => {
it('marks matching pathname item as active', () => {
vi.mocked(usePathname).mockReturnValue('/bio');
render(<SidebarNav items={ITEMS} />);
const activeLink = screen.getByRole('link', { name: /Bio/i });
expect(activeLink).not.toHaveClass('opacity-40');
});
it('marks non-matching item as inactive', () => {
vi.mocked(usePathname).mockReturnValue('/bio');
render(<SidebarNav items={ITEMS} />);
const inactiveLink = screen.getByRole('link', { name: /Work/i });
expect(inactiveLink).toHaveClass('opacity-40');
});
it('marks first item active at root path', () => {
vi.mocked(usePathname).mockReturnValue('/');
render(<SidebarNav items={ITEMS} />);
const firstLink = screen.getByRole('link', { name: /Bio/i });
expect(firstLink).not.toHaveClass('opacity-40');
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
```
Expected: FAIL — component still uses `IntersectionObserver` and renders buttons, not links.
**Step 3: Rewrite the component**
Replace `src/widgets/Navigation/ui/SidebarNav.tsx`:
```tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CONTACT_LINKS, cn } from '$shared/lib';
import type { NavItem } from '../model/types';
interface Props {
/**
* Navigation items to render
*/
items: NavItem[];
}
/**
* Fixed sidebar navigation, visible on lg+ screens.
* Active section determined by current URL pathname.
*/
export function SidebarNav({ items }: Props) {
const pathname = usePathname();
/**
* An item is active when its slug matches the current pathname,
* or when the pathname is root and it is the first item.
*/
function isActive(item: NavItem): boolean {
if (pathname === `/${item.id}`) return true;
if (pathname === '/' && items[0]?.id === item.id) return true;
return false;
}
return (
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
<div className="px-8 py-12 space-y-2">
<div className="mb-12">
<h2>Index</h2>
<div className="brutal-border-top pt-4">
<p className="text-sm opacity-60">Digital Monograph</p>
</div>
</div>
{items.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
className={cn(
'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
isActive(item)
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
: 'opacity-40 shadow-none hover:opacity-60',
)}
>
<div className="flex items-baseline gap-4">
<span className="text-sm opacity-60">{item.number}</span>
<span className="font-heading text-xl font-black">{item.label}</span>
</div>
</Link>
))}
<div className="mt-12 pt-12 brutal-border-top">
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
<div className="space-y-3">
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
Email
</a>
<a href={CONTACT_LINKS.linkedin} className="block">
LinkedIn
</a>
<a href={CONTACT_LINKS.instagram} className="block">
Instagram
</a>
<a href={CONTACT_LINKS.arena} className="block">
Are.na
</a>
</div>
</div>
</div>
</nav>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/widgets/Navigation/ui/SidebarNav.tsx src/widgets/Navigation/ui/SidebarNav.test.tsx
git commit -m "refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver"
```
---
## Task 4: Update MobileNav — section buttons → Link (TDD)
Section items become `<Link>` elements that close the menu `onClick`. The `scrollToSection` function is removed.
**Files:**
- Modify: `src/widgets/Navigation/ui/MobileNav.test.tsx`
- Modify: `src/widgets/Navigation/ui/MobileNav.tsx`
**Step 1: Update the tests**
Replace `src/widgets/Navigation/ui/MobileNav.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { NavItem } from '../model/types';
import { MobileNav } from './MobileNav';
const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }];
describe('MobileNav', () => {
describe('rendering', () => {
it('renders title "allmy.work"', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.getByText('allmy.work')).toBeInTheDocument();
});
it('renders toggle button with text "Menu" initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
});
it('menu items are hidden initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.queryByRole('link', { name: /About/i })).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('click toggle shows item links and changes label to "Close"', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /About/i })).toBeInTheDocument();
});
it('item links point to correct href', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('link', { name: /About/i })).toHaveAttribute('href', '/about');
});
it('click item link closes the menu', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
await userEvent.click(screen.getByRole('link', { name: /About/i }));
expect(screen.queryByText('Close')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
```
Expected: FAIL — component still uses `button` for items, not `link`.
**Step 3: Update the component**
Replace `src/widgets/Navigation/ui/MobileNav.tsx`:
```tsx
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { cn } from '$shared/lib';
import type { NavItem } from '../model/types';
interface Props {
/**
* Navigation items to render
*/
items: NavItem[];
}
/**
* Mobile navigation overlay, hidden on lg+ screens.
* Section items are links that close the menu on navigate.
*/
export function MobileNav({ items }: Props) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
<div className="px-6 py-4 flex items-center justify-between">
<h4>allmy.work</h4>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
>
{isOpen ? 'Close' : 'Menu'}
</button>
</div>
{isOpen && (
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
{items.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
onClick={() => setIsOpen(false)}
className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3"
>
<div className={cn('flex items-baseline gap-3')}>
<span className="text-sm opacity-60 font-body">{item.number}</span>
<span
className="font-heading text-lg font-black"
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
>
{item.label}
</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/widgets/Navigation/ui/MobileNav.tsx src/widgets/Navigation/ui/MobileNav.test.tsx
git commit -m "refactor: MobileNav section items use Link instead of scrollToSection"
```
---
## Task 5: Create [[...slug]]/page.tsx and delete app/page.tsx
Wire everything together with `generateStaticParams` for SSG.
**Files:**
- Create: `app/[[...slug]]/page.tsx`
- Delete: `app/page.tsx`
**Step 1: Create the route file**
Create `app/[[...slug]]/page.tsx`:
```tsx
import { notFound } from 'next/navigation';
import type { SectionRecord } from '$entities/Section';
import { getCollection } from '$shared/api';
import type { NavItem } from '$widgets/Navigation';
import { MobileNav, SidebarNav } from '$widgets/Navigation';
import { SectionFactory } from '$widgets/SectionFactory';
import { SectionsAccordion } from '$widgets/SectionsAccordion';
/**
* Generates static params for all section pages plus the root.
*/
export async function generateStaticParams() {
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
return [
{},
...sections.map((s) => ({ slug: [s.slug] })),
];
}
/**
* Portfolio page — handles all section routes via optional catchall.
*
* `/` → first section shown as active
* `/{slug}` → that section shown as active
*/
export default async function SectionPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
if (!sections.length) notFound();
const activeSlug = slug?.[0] ?? sections[0].slug;
if (!sections.find((s) => s.slug === activeSlug)) notFound();
const navItems: NavItem[] = sections.map((s) => ({
id: s.slug,
label: s.title,
number: s.number,
}));
return (
<div className="min-h-screen lg:flex">
<SidebarNav items={navItems} />
<main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
<MobileNav items={navItems} />
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
<SectionFactory slug={activeSlug} />
</SectionsAccordion>
</main>
</div>
);
}
```
**Step 2: Delete app/page.tsx**
```bash
rm app/page.tsx
```
**Step 3: TypeScript check**
```bash
yarn tsc --noEmit
```
Expected: no errors.
**Step 4: Commit**
```bash
git add app/
git commit -m "feat: URL-driven section routing via optional catchall with generateStaticParams"
```
---
## Task 6: Final Verification
**Step 1: Full test suite**
```bash
yarn test --run
```
Expected: all tests PASS.
**Step 2: TypeScript check**
```bash
yarn tsc --noEmit
```
Expected: no errors.
**Step 3: Lint check**
```bash
yarn check
```
Expected: no errors. If auto-fixable issues: `yarn check:fix` then re-run.
**Step 4: Dev server smoke test**
```bash
yarn dev
```
Open `http://localhost:3000` — should show first section (intro) as active, others as links. Clicking a section link should change the URL and show that section's content.