diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index ed89f8b..10734e9 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ next-env.d.ts !/.vscode !/.gitattributes !/.gitignore +!/.dockerignore !/biome.json *storybook.log diff --git a/docs/plans/2026-05-07-url-driven-section-routing-design.md b/docs/plans/2026-05-07-url-driven-section-routing-design.md deleted file mode 100644 index 56528be..0000000 --- a/docs/plans/2026-05-07-url-driven-section-routing-design.md +++ /dev/null @@ -1,86 +0,0 @@ -# URL-Driven Section Routing — Design - -**Date:** 2026-05-07 -**Status:** Approved - -## Goal - -Replace the single-page client-state accordion with multi-page URL-driven routing. Each portfolio section gets its own static URL. The sections list remains visible at all times; clicking a section heading navigates to its page. - -## Route Structure - -Delete `app/page.tsx`. Create `app/[[...slug]]/page.tsx` (optional catchall). - -| URL | Active section | -|---|---| -| `/` | `sections[0].slug` (first section, URL stays `/`) | -| `/intro` | `intro` | -| `/bio` | `bio` | -| `/skills` | `skills` | -| `/experience` | `experience` | -| `/projects` | `projects` | - -`generateStaticParams` emits one entry per section plus the root: -```ts -[{}, { slug: ['intro'] }, { slug: ['bio'] }, ...] -``` - -## Component Changes - -### `SectionAccordion` (entity) - -- Replace `onClick: () => void` prop with `href: string` -- Inactive state: render `` instead of `` -- No `'use client'` needed (already a server component) - -### `SectionsAccordion` (widget) - -- Remove `'use client'` directive and `useState` -- Add `activeSlug: string` prop (passed from page server component) -- Pass `href={`/${section.slug}`}` to each `SectionAccordion` -- Keep `children` slot pattern for RSC content - -### `SidebarNav` (widget) - -- Remove `IntersectionObserver` and `scrollToSection` -- Add `usePathname()` hook for active detection -- Active rule: `pathname === `/${item.id}`` or `(pathname === '/' && item is first)` -- Items become `` instead of `` -- Keep `'use client'` (required for `usePathname`) - -### `MobileNav` (widget) - -- Section items become `` that also close the menu on navigate -- Use `usePathname` in a `useEffect` to close menu on route change (replaces manual close-on-click) - -## Data Flow - -``` -[[...slug]]/page.tsx (RSC) - ├─ fetch sections[] - ├─ activeSlug = params?.slug?.[0] ?? sections[0].slug - ├─ notFound() if activeSlug not in sections - ├─ SidebarNav items={navItems} ← usePathname for active state - └─ SectionsAccordion sections activeSlug - ├─ SectionAccordion href="/" isActive=true → SectionFactory content - ├─ SectionAccordion href="/bio" → Link - └─ SectionAccordion href="/skills" → Link -``` - -No client state in the section list. `SidebarNav` remains client-only for `usePathname`. - -## Error Handling - -- Unknown slug → `notFound()` at page level (404 static page) -- Empty sections list → `notFound()` at page level - -## Testing - -- `SectionsAccordion`: drop interaction (click/activate) tests; replace with prop-driven assertions — correct `isActive` and `href` per section given `activeSlug` -- `SidebarNav`: drop `IntersectionObserver` mock; mock `usePathname`; assert active link class -- `MobileNav`: items become links; assert close-on-navigate via `usePathname` effect -- `[[...slug]]/page.tsx`: no unit tests (pure orchestration of tested components) - -## No New Dependencies - -`next/link` and `next/navigation` already present. diff --git a/docs/plans/2026-05-07-url-driven-section-routing.md b/docs/plans/2026-05-07-url-driven-section-routing.md deleted file mode 100644 index 37d8bd0..0000000 --- a/docs/plans/2026-05-07-url-driven-section-routing.md +++ /dev/null @@ -1,882 +0,0 @@ -# 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