Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions docs/plans/10-mobile-navigation/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Mobile Navigation Design

## Problem

The current navbar uses a horizontal layout with `justify-between` and fixed `gap-8` spacing. With the addition of more nav links (7+), the mobile experience becomes cramped and unusable.

## Solution

Implement a hamburger menu pattern on mobile with a full-screen overlay.

## Design Decisions

### Breakpoint Strategy

| Viewport | Behavior |
|----------|----------|
| < 768px (below `md`) | Hamburger menu |
| ≥ 768px (`md` and up) | Full horizontal nav |

### Mobile Header Layout

```
[Logo] [Let's Talk] [☰]
```

- Logo on the left
- "Let's Talk" CTA visible outside menu (primary conversion action)
- Hamburger icon on the right

### Desktop Header Layout

```
[Logo] [Products] [Services] [About] [Blog] ... [Let's Talk]
```

### Mobile Menu Style

**Full-screen overlay** chosen over slide-out drawer or dropdown:

- With 7+ links, generous spacing is needed
- Creates focus by hiding page content
- Simpler to implement
- Matches bold brand aesthetic

**Visual structure:**
```
┌─────────────────────────────┐
│ [Logo] [✕] │ ← Header persists, X closes
├─────────────────────────────┤
│ │
│ Products │
│ Services │
│ About │
│ Blog │
│ Careers │
│ Case Studies │
│ │
└─────────────────────────────┘
```

### Visual Styling

**Menu background:** `bg-background` (same dark background as site)

**Nav link styling in menu:**
- Large tappable text (`text-2xl` or `text-3xl`)
- Generous vertical spacing (`py-4` per link)
- Centered alignment
- Uppercase + tracking-wide (consistent with desktop)
- Hover/active: `text-accent` color shift

**Hamburger icon:**
- Three horizontal lines (classic ☰)
- Transforms to X when open (animated)
- Size: 24x24px with 44x44px minimum tap target

### Animation

**Opening:**
1. Overlay fades in (150ms)
2. Links stagger in from top (50ms delay each)

**Closing:**
1. Quick fade out (100ms)
2. No stagger (feels snappier)

### Behavior

**Close triggers:**
- X button click
- Nav link click
- Escape key press
- Route change (automatic)

**Scroll lock:** Body scroll disabled when menu is open

### Accessibility

- `aria-expanded` on hamburger button
- `aria-hidden` on menu when closed
- Focus moves to first link when menu opens
- Focus returns to hamburger when menu closes
- Escape key closes menu
- Focus trapped inside menu while open

## Component Structure

```
src/components/navigation/
├── Navbar.tsx # Updated with mobile logic
├── MobileMenuButton.tsx # Animated hamburger/X icon
├── MobileMenu.tsx # Full-screen overlay
├── NavLink.tsx # Existing
└── navLinks.ts # Shared link configuration
```

**Component responsibilities:**

| Component | Responsibility |
|-----------|----------------|
| `Navbar` | Layout, state management, breakpoint logic |
| `MobileMenuButton` | Hamburger/X icon with CSS animation |
| `MobileMenu` | Full-screen overlay with links |
| `navLinks.ts` | Shared link data for desktop & mobile |

## State Management

Simple React `useState`:

```tsx
const [isMenuOpen, setIsMenuOpen] = useState(false)
```

Scroll lock via `useEffect`:

```tsx
useEffect(() => {
document.body.style.overflow = isMenuOpen ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [isMenuOpen])
```

## Edge Cases

| Case | Handling |
|------|----------|
| Long link text | Centered + vertical stack, length doesn't matter |
| Many links (10+) | Menu scrolls if content exceeds viewport |
| Fast open/close | CSS transitions handle interrupts gracefully |
| Resize while open | Menu closes when viewport crosses `md` breakpoint |

## Not Included (YAGNI)

- Nested submenus / dropdowns
- Search in mobile menu
- Social links in menu
- Backdrop blur (solid background is simpler)
104 changes: 104 additions & 0 deletions docs/plans/10-mobile-navigation/implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Mobile Navigation Implementation Plan

## Overview

Implement hamburger menu for mobile navigation per design.md.

## Prerequisites

- Read existing `Navbar.tsx` implementation
- Understand current nav link structure

## Tasks

### 1. Create shared nav links configuration

**File:** `src/components/navigation/navLinks.ts`

```typescript
export const navLinks = [
{ href: '/products', label: 'Products' },
{ href: '/services', label: 'Services' },
// ... additional links
] as const
```

This centralizes link data for both desktop and mobile navigation.

### 2. Create MobileMenuButton component

**File:** `src/components/navigation/MobileMenuButton.tsx`

- Animated hamburger icon that transforms to X
- Uses CSS transitions for smooth animation
- Props: `isOpen`, `onClick`
- Includes proper `aria-expanded` and `aria-label`

### 3. Create MobileMenu component

**File:** `src/components/navigation/MobileMenu.tsx`

- Full-screen overlay with `fixed inset-0`
- Renders nav links from shared config
- Handles:
- Escape key to close
- Click on link to close
- Focus trap
- Scroll lock
- Stagger animation for links on open

### 4. Update Navbar component

**File:** `src/components/navigation/Navbar.tsx`

- Add `isMenuOpen` state
- Conditionally render desktop nav (hidden on mobile)
- Conditionally render MobileMenuButton (hidden on desktop)
- Render MobileMenu when open
- Close menu on route change

### 5. Add close-on-resize behavior

In Navbar, add effect to close menu when viewport crosses `md` breakpoint:

```typescript
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 768px)')
const handleChange = () => {
if (mediaQuery.matches) setIsMenuOpen(false)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
```

### 6. Write tests

- Unit tests for MobileMenuButton (renders, toggles aria-expanded)
- Unit tests for MobileMenu (renders links, calls onClose)
- E2E test for mobile menu flow (open, navigate, close)

### 7. Visual review

- Test in Ladle at various viewport sizes
- Verify animation timing feels right
- Check focus states and keyboard navigation

## File Checklist

- [ ] `src/components/navigation/navLinks.ts`
- [ ] `src/components/navigation/MobileMenuButton.tsx`
- [ ] `src/components/navigation/MobileMenuButton.test.tsx`
- [ ] `src/components/navigation/MobileMenu.tsx`
- [ ] `src/components/navigation/MobileMenu.test.tsx`
- [ ] `src/components/navigation/Navbar.tsx` (update)
- [ ] `e2e/mobile-navigation.spec.ts`

## Verification

1. `pnpm typecheck` - no type errors
2. `pnpm lint` - no lint errors
3. `pnpm test` - all tests pass
4. `pnpm e2e` - E2E tests pass
5. `just ladle` - visual review
6. Manual test on actual mobile device or emulator
99 changes: 99 additions & 0 deletions e2e/mobile-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test'

test.describe('Mobile Navigation', () => {
test.use({ viewport: { width: 375, height: 667 } }) // iPhone SE size

test('shows hamburger menu on mobile', async ({ page }) => {
await page.goto('/')

// Hamburger button should be visible
const menuButton = page.getByRole('button', { name: /open menu/i })
await expect(menuButton).toBeVisible()

// Desktop nav links should be hidden
const desktopNav = page.locator('.md\\:flex').filter({ hasText: 'Products' })
await expect(desktopNav).toBeHidden()
})

test('opens mobile menu when hamburger is clicked', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })

const menuButton = page.getByRole('button', { name: /open menu/i })
await expect(menuButton).toBeVisible()
await menuButton.click()

// Mobile menu should be visible with nav links
const mobileNav = page.getByRole('navigation', { name: /mobile navigation/i })
await expect(mobileNav).toBeVisible()
await expect(mobileNav.getByRole('link', { name: 'Products' })).toBeVisible()
await expect(mobileNav.getByRole('link', { name: 'Services' })).toBeVisible()

// Button should now say close
await expect(page.getByRole('button', { name: /close menu/i })).toBeVisible()
})

test('closes mobile menu when X is clicked', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })

// Open menu
await page.getByRole('button', { name: /open menu/i }).click()
await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeVisible()

// Close menu
await page.getByRole('button', { name: /close menu/i }).click()
await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeHidden()
})

test('closes mobile menu when nav link is clicked', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })

// Open menu
await page.getByRole('button', { name: /open menu/i }).click()
const mobileNav = page.getByRole('navigation', { name: /mobile navigation/i })
await expect(mobileNav).toBeVisible()

// Click a nav link
await mobileNav.getByRole('link', { name: 'Products' }).click()

// Should navigate and close menu
await expect(page).toHaveURL('/products')
await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeHidden()
})

test("Let's Talk button is visible outside menu on mobile", async ({ page }) => {
await page.goto('/')

// CTA should be visible in header without opening menu
const header = page.locator('header')
await expect(header.getByRole('link', { name: /let's talk/i })).toBeVisible()
})

test('closes mobile menu when Escape is pressed', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' })

// Open menu
await page.getByRole('button', { name: /open menu/i }).click()
await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeVisible()

// Press Escape
await page.keyboard.press('Escape')
await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeHidden()
})
})

test.describe('Desktop Navigation', () => {
test.use({ viewport: { width: 1280, height: 720 } })

test('shows full navigation on desktop', async ({ page }) => {
await page.goto('/')

// Desktop nav links should be visible
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Products' })).toBeVisible()
await expect(header.getByRole('link', { name: 'Services' })).toBeVisible()
await expect(header.getByRole('link', { name: /let's talk/i })).toBeVisible()

// Hamburger button should be hidden
await expect(page.getByRole('button', { name: /open menu/i })).toBeHidden()
})
})
5 changes: 4 additions & 1 deletion e2e/products.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ test.describe('Products Pages', () => {
})

test('navbar has Products link', async ({ page }) => {
// Set desktop viewport - on mobile, Products is in hamburger menu
await page.setViewportSize({ width: 1280, height: 720 })
await page.goto('/')

const productsLink = page.getByRole('link', { name: 'Products', exact: true })
const header = page.locator('header')
const productsLink = header.getByRole('link', { name: 'Products', exact: true })
await expect(productsLink).toBeVisible()
await productsLink.click()
await expect(page).toHaveURL('/products')
Expand Down
Loading
Loading