diff --git a/docs/plans/2026-05-07-url-driven-section-routing.md b/docs/plans/2026-05-07-url-driven-section-routing.md new file mode 100644 index 0000000..37d8bd0 --- /dev/null +++ b/docs/plans/2026-05-07-url-driven-section-routing.md @@ -0,0 +1,882 @@ +# 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 `` 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 `` with ``. 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: Content here, +}; + +describe('SectionAccordion', () => { + describe('collapsed state (isActive=false)', () => { + it('renders a section element with the given id', () => { + const { container } = render(); + expect(container.querySelector('section#about')).toBeInTheDocument(); + }); + + it('renders a link with number and title', () => { + render(); + expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument(); + }); + + it('link points to the correct href', () => { + render(); + expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about'); + }); + + it('does not render children', () => { + render(); + expect(screen.queryByText('Content here')).not.toBeInTheDocument(); + }); + + it('does not render a button', () => { + render(); + 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(); + expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument(); + }); + + it('renders children', () => { + render(); + expect(screen.getByText('Content here')).toBeInTheDocument(); + }); + + it('does not render a link', () => { + render(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('content wrapper has animate-fadeIn class', () => { + const { container } = render(); + 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 ( + + {isActive ? ( + + + + {number}. {title} + + + {children} + + ) : ( + + + {number}. {title} + + + )} + + ); +} +``` + +**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( + + Bio content + , + ); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio'); + }); + + it('renders active section children', () => { + render( + + Bio content + , + ); + expect(screen.getByText('Bio content')).toBeInTheDocument(); + }); + }); + + describe('inactive section rendering', () => { + it('renders inactive sections as links', () => { + render( + + Bio content + , + ); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(2); + }); + + it('inactive links point to correct hrefs', () => { + render( + + Bio content + , + ); + 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( + + Bio content + , + ); + expect(screen.getAllByText('Bio content')).toHaveLength(1); + }); + }); + + describe('first section default', () => { + it('shows first section as active when activeSlug matches first', () => { + render( + + Intro content + , + ); + 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 ( + + {sections.map((section) => ( + + {activeSlug === section.slug ? children : null} + + ))} + + ); +} +``` + +**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 `` 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(); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + + it('renders "Index" heading', () => { + render(); + expect(screen.getByText('Index')).toBeInTheDocument(); + }); + + it('renders "Digital Monograph" subtitle', () => { + render(); + expect(screen.getByText('Digital Monograph')).toBeInTheDocument(); + }); + + it('renders each item label and number', () => { + render(); + 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(); + expect(screen.getByText('Quick Links')).toBeInTheDocument(); + }); + + it('renders Email quick link', () => { + render(); + expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument(); + }); + + it('renders a link for each item', () => { + render(); + 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(); + 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(); + 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(); + 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 ( + + + + Index + + Digital Monograph + + + + {items.map((item) => ( + + + {item.number} + {item.label} + + + ))} + + + Quick Links + + + Email + + + LinkedIn + + + Instagram + + + Are.na + + + + + + ); +} +``` + +**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 `` 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(); + expect(screen.getByText('allmy.work')).toBeInTheDocument(); + }); + + it('renders toggle button with text "Menu" initially', () => { + render(); + expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument(); + }); + + it('menu items are hidden initially', () => { + render(); + expect(screen.queryByRole('link', { name: /About/i })).not.toBeInTheDocument(); + }); + }); + + describe('interactions', () => { + it('click toggle shows item links and changes label to "Close"', async () => { + render(); + 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(); + 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(); + 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 ( + + + allmy.work + setIsOpen((prev) => !prev)} + className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay" + > + {isOpen ? 'Close' : 'Menu'} + + + {isOpen && ( + + {items.map((item) => ( + setIsOpen(false)} + className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3" + > + + {item.number} + + {item.label} + + + + ))} + + )} + + ); +} +``` + +**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('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('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 ( + + + + + + + + + + ); +} +``` + +**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.
Content here
Digital Monograph
Quick Links