Files
braidsbymira/docs/superpowers/plans/2026-08-03-frontend-visual-overhaul.md
2026-08-03 22:37:09 +00:00

1464 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend Visual Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the flat, image-less Phase 1 UI with a real WebGL 3D hero, generated product/style/portrait photography, and tighter, more animated layouts across all four existing pages (Home, Shop, Book, Rules).
**Architecture:** Additive changes on top of the existing React 19 + TypeScript + Vite + framer-motion + react-router-dom stack. A new `@react-three/fiber` scene replaces the current CSS-only `Hero3D` on Home. Product/service images are generated once as static files and wired into the already-existing (but previously unused) `image` fields on `Product`/`Service`. No new routes, no new data shapes, no backend.
**Tech Stack:** Adds `three`, `@react-three/fiber`, `@react-three/drei` for the 3D hero. Everything else reuses the existing Vitest + React Testing Library + framer-motion toolchain.
## Global Constraints
- Palette stays cream `#F7F3EC` / `#EFE7D8`, charcoal `#3A332E`, gold `#B08D57` — no new colors, no rebrand.
- Typography stays Georgia/serif for body/labels, the existing script font for headline moments — no new fonts.
- 3D treatment is scoped to the Home hero only — not product cards, not other pages (explicit user decision).
- All motion continues to respect `prefers-reduced-motion` (existing `useReducedMotion` pattern from `framer-motion`), including the new 3D scene, which falls back to a static element and skips mounting the WebGL canvas entirely for those users.
- No backend/network calls — imagery is static files under `public/`, generated once and committed.
- `three`/`@react-three/fiber`/`@react-three/drei` must only be imported from the Home hero component so they don't bloat the Shop/Book/Rules bundles (Vite's existing route-based code splitting handles this automatically as long as the import stays local to that one component).
---
### Task 1: Install 3D dependencies
**Files:**
- Modify: `package.json`, `package-lock.json`
**Interfaces:**
- Produces: `three`, `@react-three/fiber`, `@react-three/drei` available as npm dependencies for Task 3.
- [ ] **Step 1: Install the packages**
```bash
npm install three @react-three/fiber @react-three/drei
```
- [ ] **Step 2: Verify the install didn't break the existing build**
Run: `npm run build`
Expected: succeeds with zero TypeScript errors (these packages ship their own types, no `@types/three` needed for the API surface used in this plan).
- [ ] **Step 3: Verify the existing test suite is unaffected**
Run: `npx vitest run`
Expected: all existing tests still pass (44/44 as of the last Phase 1 merge).
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json
git commit -m "chore: add three, @react-three/fiber, @react-three/drei for the 3D hero"
```
---
### Task 2: Generate product, style, portrait, and testimonial imagery
**Files:**
- Create: `public/products/edge-control.jpg`
- Create: `public/products/braid-oil.jpg`
- Create: `public/products/leave-in.jpg`
- Create: `public/products/kanekalon-black.jpg`
- Create: `public/products/kanekalon-brown.jpg`
- Create: `public/products/locs-extension.jpg`
- Create: `public/products/rat-tail-comb.jpg`
- Create: `public/products/edge-brush.jpg`
- Create: `public/products/gold-cuffs.jpg`
- Create: `public/products/satin-bonnet.jpg`
- Create: `public/styles/knotless.jpg`
- Create: `public/styles/boho.jpg`
- Create: `public/styles/locs.jpg`
- Create: `public/styles/cornrows.jpg`
- Create: `public/about/mira-portrait.jpg`
- Create: `public/testimonials/amara.jpg`
- Create: `public/testimonials/keisha.jpg`
- Create: `public/testimonials/nadia.jpg`
**Interfaces:**
- Produces: static image files at the exact paths above. These paths already match the `image` fields hardcoded in `src/data/products.ts` and `src/data/services.ts` from Phase 1 (e.g. `products.ts` already has `image: '/products/edge-control.jpg'`) — **do not rename files or change the data files**, the filenames here are load-bearing for Tasks 4 and 5.
This task has no code to write and no tests to run — it's content generation. Use whatever AI image-generation tool is available in your environment (search your available tools for an image generation capability before starting). Generate each image individually or in batches if your tool supports batch generation, then save/move each result to its exact destination path listed above.
- [ ] **Step 1: Generate the 10 product images**
Use these prompts (adapt wording to your tool's requirements, but preserve the subject, framing, and palette guidance in each):
1. `public/products/edge-control.jpg` — "Product photography of a small round jar of hair edge control gel, cream and gold label, soft studio lighting, on a plain warm cream (#F7F3EC) background, minimalist beauty product shot"
2. `public/products/braid-oil.jpg` — "Product photography of a small amber glass dropper bottle labeled as a hair oil, soft studio lighting, on a plain warm cream background, minimalist beauty product shot"
3. `public/products/leave-in.jpg` — "Product photography of a white pump bottle of leave-in hair conditioner, cream and gold label, soft studio lighting, on a plain warm cream background, minimalist beauty product shot"
4. `public/products/kanekalon-black.jpg` — "Product photography of a neatly coiled bundle of jet black synthetic braiding hair extensions, soft studio lighting, on a plain warm cream background"
5. `public/products/kanekalon-brown.jpg` — "Product photography of a neatly coiled bundle of warm chestnut brown synthetic braiding hair extensions, soft studio lighting, on a plain warm cream background"
6. `public/products/locs-extension.jpg` — "Product photography of pre-looped faux locs hair extensions arranged neatly, dark brown, soft studio lighting, on a plain warm cream background"
7. `public/products/rat-tail-comb.jpg` — "Product photography of a steel rat-tail hair parting comb, minimalist, soft studio lighting, on a plain warm cream background"
8. `public/products/edge-brush.jpg` — "Product photography of a small dual-sided hair edge brush, dark handle, soft studio lighting, on a plain warm cream background"
9. `public/products/gold-cuffs.jpg` — "Product photography of a set of six small gold adjustable hair cuffs for braids, soft studio lighting, on a plain warm cream background"
10. `public/products/satin-bonnet.jpg` — "Product photography of a folded charcoal-brown satin hair sleep bonnet, soft studio lighting, on a plain warm cream background"
- [ ] **Step 2: Generate the 4 signature-style images**
1. `public/styles/knotless.jpg` — "Editorial beauty portrait of a woman with long knotless box braids, warm natural lighting, neutral cream studio background, elegant and calm expression"
2. `public/styles/boho.jpg` — "Editorial beauty portrait of a woman with boho knotless braids featuring loose curly pieces woven in, warm natural lighting, neutral cream studio background"
3. `public/styles/locs.jpg` — "Editorial beauty portrait of a woman with natural-looking faux locs, warm natural lighting, neutral cream studio background"
4. `public/styles/cornrows.jpg` — "Editorial beauty portrait of a woman with sleek straight-back cornrows, warm natural lighting, neutral cream studio background"
- [ ] **Step 3: Generate the About Mira portrait**
`public/about/mira-portrait.jpg` — "Warm editorial portrait of a confident Black hairstylist in her salon, soft natural window light, neutral warm tones, three-quarter view, professional and approachable"
- [ ] **Step 4: Generate the 3 testimonial avatars**
1. `public/testimonials/amara.jpg` — "Small circular-crop friendly headshot portrait of a smiling Black woman, warm natural lighting, neutral cream background"
2. `public/testimonials/keisha.jpg` — "Small circular-crop friendly headshot portrait of a smiling Black woman, different from a previous portrait, warm natural lighting, neutral cream background"
3. `public/testimonials/nadia.jpg` — "Small circular-crop friendly headshot portrait of a smiling Black woman, different from previous portraits, warm natural lighting, neutral cream background"
- [ ] **Step 5: Verify every file exists and is a valid image**
```bash
for f in public/products/edge-control.jpg public/products/braid-oil.jpg public/products/leave-in.jpg \
public/products/kanekalon-black.jpg public/products/kanekalon-brown.jpg public/products/locs-extension.jpg \
public/products/rat-tail-comb.jpg public/products/edge-brush.jpg public/products/gold-cuffs.jpg \
public/products/satin-bonnet.jpg public/styles/knotless.jpg public/styles/boho.jpg public/styles/locs.jpg \
public/styles/cornrows.jpg public/about/mira-portrait.jpg public/testimonials/amara.jpg \
public/testimonials/keisha.jpg public/testimonials/nadia.jpg; do
file "$f"
done
```
Expected: every line reports a valid JPEG (or PNG — if your tool outputs PNG, save with a `.jpg` extension is fine as long as `<img>` tags render it correctly, but prefer actually generating/converting to JPEG for smaller file size; either way every file must exist and be non-zero bytes).
- [ ] **Step 6: Commit**
```bash
git add public/products public/styles public/about public/testimonials
git commit -m "content: add generated product, style, portrait, and testimonial imagery"
```
---
### Task 3: `Hero3DScene` — WebGL 3D hero component
**Files:**
- Create: `src/components/Hero3DScene.tsx`, `src/components/Hero3DScene.module.css`
- Test: `src/components/Hero3DScene.test.tsx`
- Modify: `src/pages/Home.tsx` (swap `Hero3D` for `Hero3DScene`)
- Modify: `src/pages/Home.test.tsx` (if it references the old testid — check and update)
- Delete: `src/components/Hero3D.tsx`, `src/components/Hero3D.module.css`, `src/components/Hero3D.test.tsx` (fully replaced by `Hero3DScene`)
**Interfaces:**
- Produces: `Hero3DScene` component (no props) — renders a `data-testid="hero-3d-scene"` container. Under normal motion, renders an `@react-three/fiber` `<Canvas>` containing a rotating procedural braided-crown mesh that also tilts with scroll position. Under `prefers-reduced-motion: reduce`, renders a static gradient-circle fallback (same visual fallback the old `Hero3D` used) with class `static`, and does **not** mount the `<Canvas>` at all.
- [ ] **Step 1: Write the failing test**
`src/components/Hero3DScene.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, afterEach } from 'vitest'
vi.mock('@react-three/fiber', () => ({
Canvas: ({ children }: { children?: React.ReactNode }) => <div data-testid="r3f-canvas">{children}</div>,
useFrame: () => {},
}))
function mockMatchMedia(reduced: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: reduced && query.includes('prefers-reduced-motion'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}))
}
describe('Hero3DScene', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the static fallback and no canvas when reduced motion is preferred', async () => {
vi.resetModules()
mockMatchMedia(true)
const { default: Hero3DScene } = await import('./Hero3DScene')
render(<Hero3DScene />)
const wrapper = screen.getByTestId('hero-3d-scene')
expect(wrapper.className).toMatch(/static/)
expect(screen.queryByTestId('r3f-canvas')).not.toBeInTheDocument()
})
it('renders the WebGL canvas when motion is allowed', async () => {
vi.resetModules()
mockMatchMedia(false)
const { default: Hero3DScene } = await import('./Hero3DScene')
render(<Hero3DScene />)
expect(screen.getByTestId('hero-3d-scene')).toBeInTheDocument()
expect(screen.getByTestId('r3f-canvas')).toBeInTheDocument()
})
})
```
Note: this uses `vi.resetModules()` + dynamic `import()` inside each test, mirroring the pattern already established in this codebase for `Hero3D.test.tsx` — framer-motion's `useReducedMotion()` caches its result in a module-level singleton on first call within a file, so a static top-level import would make the second test see the first test's cached value. Mocking `@react-three/fiber`'s `Canvas`/`useFrame` avoids needing a real WebGL context in the jsdom test environment.
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/components/Hero3DScene.test.tsx`
Expected: FAIL (module doesn't exist)
- [ ] **Step 3: Implement `Hero3DScene`**
`src/components/Hero3DScene.module.css`:
```css
.wrapper {
width: 280px;
height: 280px;
margin: 0 auto;
}
.wrapper canvas {
outline: none;
}
.fallbackMotif {
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #e6c98f, var(--color-gold));
}
```
`src/components/Hero3DScene.tsx`:
```tsx
import { useMemo, useRef } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useReducedMotion, useScroll, useTransform, type MotionValue } from 'framer-motion'
import styles from './Hero3DScene.module.css'
const GOLD = '#B08D57'
const STRAND_COUNT = 6
interface BraidStrandProps {
radius: number
angleOffset: number
}
function BraidStrand({ radius, angleOffset }: BraidStrandProps) {
const geometry = useMemo(() => {
const points: THREE.Vector3[] = []
const segments = 20
for (let i = 0; i <= segments; i++) {
const t = i / segments
const angle = angleOffset + t * Math.PI * 0.6
const r = radius * (1 - t * 0.3)
points.push(new THREE.Vector3(Math.cos(angle) * r, t * 1.6 - 0.8, Math.sin(angle) * r))
}
const curve = new THREE.CatmullRomCurve3(points)
return new THREE.TubeGeometry(curve, 40, 0.05, 8, false)
}, [radius, angleOffset])
return (
<mesh geometry={geometry}>
<meshStandardMaterial color={GOLD} metalness={0.6} roughness={0.35} />
</mesh>
)
}
interface BraidedCrownProps {
scrollTilt: MotionValue<number>
}
function BraidedCrown({ scrollTilt }: BraidedCrownProps) {
const groupRef = useRef<THREE.Group>(null)
useFrame((_state, delta) => {
if (!groupRef.current) return
groupRef.current.rotation.y += delta * 0.25
groupRef.current.rotation.x = scrollTilt.get()
})
const strands = useMemo(
() =>
Array.from({ length: STRAND_COUNT }, (_, i) => ({
radius: 1.1,
angleOffset: (i / STRAND_COUNT) * Math.PI * 2,
})),
[],
)
return (
<group ref={groupRef}>
{strands.map((s, i) => (
<BraidStrand key={i} radius={s.radius} angleOffset={s.angleOffset} />
))}
</group>
)
}
function Hero3DScene() {
const prefersReducedMotion = useReducedMotion()
const { scrollY } = useScroll()
const scrollTilt = useTransform(scrollY, [0, 600], [0, 0.35])
if (prefersReducedMotion) {
return (
<div className={`${styles.wrapper} static`} data-testid="hero-3d-scene">
<div className={styles.fallbackMotif} />
</div>
)
}
return (
<div className={styles.wrapper} data-testid="hero-3d-scene" aria-hidden="true">
<Canvas camera={{ position: [0, 0, 4], fov: 45 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[2, 3, 4]} intensity={1.1} />
<directionalLight position={[-2, -1, -3]} intensity={0.3} />
<BraidedCrown scrollTilt={scrollTilt} />
</Canvas>
</div>
)
}
export default Hero3DScene
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/components/Hero3DScene.test.tsx`
Expected: PASS (2 tests)
- [ ] **Step 5: Delete the old `Hero3D` component**
```bash
rm src/components/Hero3D.tsx src/components/Hero3D.module.css src/components/Hero3D.test.tsx
```
- [ ] **Step 6: Wire `Hero3DScene` into `Home.tsx`**
In `src/pages/Home.tsx`, change:
```tsx
import Hero3D from '../components/Hero3D'
```
to:
```tsx
import Hero3DScene from '../components/Hero3DScene'
```
and change `<Hero3D />` to `<Hero3DScene />` in the hero section.
- [ ] **Step 7: Check and fix `Home.test.tsx` for any reference to the old component**
Run: `grep -n "Hero3D" src/pages/Home.test.tsx`
If it finds nothing, no change needed. If it finds a reference (e.g. a testid check), update it to `hero-3d-scene`.
- [ ] **Step 8: Run the full suite and build**
Run: `npx vitest run && npm run build`
Expected: all tests pass, build succeeds with zero TypeScript errors.
- [ ] **Step 9: Commit**
```bash
git add -A
git commit -m "feat: replace CSS hero motif with a real WebGL 3D braided-crown scene"
```
---
### Task 4: `ProductCard` renders its image
**Files:**
- Modify: `src/components/ProductCard.tsx`
- Modify: `src/components/ProductCard.module.css`
- Modify: `src/components/ProductCard.test.tsx`
**Interfaces:**
- Consumes: `Product.image` (already exists on the type from Phase 1, e.g. `/products/edge-control.jpg`, now backed by real files from Task 2)
- No signature changes — `ProductCard`'s props (`{ product, onInquire }`) are unchanged; this task only adds rendering of the existing `image` field.
- [ ] **Step 1: Update the test to assert the image renders**
Modify `src/components/ProductCard.test.tsx`, adding this assertion inside the first test (after the existing `$12` check):
```tsx
expect(screen.getByRole('img', { name: 'Silk Edge Control' })).toHaveAttribute('src', '/x.jpg')
```
The full first test becomes:
```tsx
it('renders product details and calls onInquire when clicked', async () => {
const onInquire = vi.fn()
render(<ProductCard product={product} onInquire={onInquire} />)
expect(screen.getByText('Silk Edge Control')).toBeInTheDocument()
expect(screen.getByText('$12')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Silk Edge Control' })).toHaveAttribute('src', '/x.jpg')
await userEvent.click(screen.getByRole('button', { name: /ask about this/i }))
expect(onInquire).toHaveBeenCalledWith(product)
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/components/ProductCard.test.tsx`
Expected: FAIL (no image role found)
- [ ] **Step 3: Add the image to `ProductCard`**
In `src/components/ProductCard.tsx`, add an `<img>` as the first child of the card, right after the opening `<div className={styles.card}>`:
```tsx
<img src={product.image} alt={product.name} className={styles.image} />
```
Full updated render:
```tsx
return (
<div className={styles.card}>
<img src={product.image} alt={product.name} className={styles.image} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<p>{product.description}</p>
{!product.inStock && <p className={styles.badge}>Currently unavailable</p>}
<button
type="button"
className={styles.button}
disabled={!product.inStock}
onClick={() => onInquire(product)}
>
Ask about this
</button>
</div>
)
```
- [ ] **Step 4: Add image styling to `ProductCard.module.css`**
Add this rule (keep all existing rules unchanged):
```css
.image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 3px;
margin-bottom: var(--space-sm);
transition: transform 0.3s ease;
}
.card:hover .image {
transform: scale(1.05);
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `npx vitest run src/components/ProductCard.test.tsx`
Expected: PASS (2 tests)
- [ ] **Step 6: Commit**
```bash
git add src/components/ProductCard.tsx src/components/ProductCard.module.css src/components/ProductCard.test.tsx
git commit -m "feat: render product images with hover-zoom in ProductCard"
```
---
### Task 5: `ServiceCard` renders its image
**Files:**
- Modify: `src/components/ServiceCard.tsx`
- Modify: `src/components/ServiceCard.module.css`
- Modify: `src/components/ServiceCard.test.tsx`
**Interfaces:**
- Consumes: `Service.image` (already exists on the type, e.g. `/styles/knotless.jpg`, now backed by real files from Task 2)
- No signature changes — `ServiceCard`'s props (`{ service, onSelect? }`) are unchanged.
- [ ] **Step 1: Update the test to assert the image renders**
Modify `src/components/ServiceCard.test.tsx`'s first test:
```tsx
it('renders service details', () => {
render(<ServiceCard service={service} />)
expect(screen.getByText('Knotless Braids')).toBeInTheDocument()
expect(screen.getByText(/from \$150/i)).toBeInTheDocument()
expect(screen.getByText('4h')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Knotless Braids' })).toHaveAttribute('src', '/x.jpg')
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/components/ServiceCard.test.tsx`
Expected: FAIL (no image role found)
- [ ] **Step 3: Add the image to `ServiceCard`**
`ServiceCard` renders as a `<button>`, so the image goes inside it:
```tsx
return (
<button type="button" className={styles.card} onClick={() => onSelect?.(service.id)}>
<img src={service.image} alt={service.name} className={styles.image} />
<h3>{service.name}</h3>
<p>{formatDuration(service.durationMinutes)}</p>
<p>From ${service.priceFrom}</p>
</button>
)
```
- [ ] **Step 4: Add image styling to `ServiceCard.module.css`**
```css
.image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 3px;
margin-bottom: var(--space-sm);
transition: transform 0.3s ease;
}
.card:hover .image {
transform: scale(1.05);
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `npx vitest run src/components/ServiceCard.test.tsx`
Expected: PASS (2 tests)
- [ ] **Step 6: Commit**
```bash
git add src/components/ServiceCard.tsx src/components/ServiceCard.module.css src/components/ServiceCard.test.tsx
git commit -m "feat: render service images with hover-zoom in ServiceCard"
```
---
### Task 6: Home page polish — spacing, portrait, real Shop Highlights, testimonial avatars
**Files:**
- Modify: `src/pages/Home.tsx`
- Modify: `src/pages/Home.module.css`
- Modify: `src/pages/Home.test.tsx`
**Interfaces:**
- Consumes: `Hero3DScene` (Task 3), `ProductCard` (Task 4), `ServiceCard` (Task 5 — already used), `products`/`services` data (unchanged)
- No new exports — this task only changes `Home`'s internal rendering.
- [ ] **Step 1: Update `Home.test.tsx` for the new Shop Highlights markup**
The current Home test suite (from Phase 1) doesn't assert on Shop Highlights' internal structure beyond the "Shop All" link, so no test changes are needed there. Add one new test to `src/pages/Home.test.tsx` verifying the About section's portrait image renders:
```tsx
it('renders the About Mira portrait', () => {
renderHome()
expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument()
})
```
Add this as a new `it()` block inside the existing `describe('Home page', ...)` block, alongside the existing tests.
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/pages/Home.test.tsx`
Expected: FAIL (no image with accessible name matching "mira")
- [ ] **Step 3: Add testimonial avatar data and update `Home.tsx`**
Replace the whole `TESTIMONIALS` constant and the JSX with the version below. Full replacement of `src/pages/Home.tsx`:
```tsx
import { Link, useNavigate } from 'react-router-dom'
import { motion, useScroll, useTransform } from 'framer-motion'
import Hero3DScene from '../components/Hero3DScene'
import ScrollReveal from '../components/ScrollReveal'
import ServiceCard from '../components/ServiceCard'
import ProductCard from '../components/ProductCard'
import { services } from '../data/services'
import { products } from '../data/products'
import styles from './Home.module.css'
const TESTIMONIALS = [
{ quote: 'Mira made me feel like royalty in her chair. My braids lasted 8 weeks!', author: 'Amara T.', avatar: '/testimonials/amara.jpg' },
{ quote: 'The most gentle, precise braider I have ever been to.', author: 'Keisha R.', avatar: '/testimonials/keisha.jpg' },
{ quote: 'Booking was so easy and the results speak for themselves.', author: 'Nadia F.', avatar: '/testimonials/nadia.jpg' },
]
function Home() {
const navigate = useNavigate()
const { scrollYProgress } = useScroll()
const portraitY = useTransform(scrollYProgress, [0, 1], [0, -40])
return (
<div>
<section className={styles.hero}>
<Hero3DScene />
<h1 className={styles.headline}>Your hair, your crown</h1>
<p className={styles.tagline}>Protective Styles by Mira</p>
<div className={styles.ctas}>
<Link to="/book">Book Now</Link>
<Link to="/shop">Shop Products</Link>
</div>
</section>
<ScrollReveal>
<section className={styles.altSection}>
<div className={styles.aboutGrid}>
<motion.img
src="/about/mira-portrait.jpg"
alt="Mira, owner and stylist at Braids by Mira"
className={styles.portrait}
style={{ y: portraitY }}
/>
<div>
<h2>About Mira</h2>
<p>
With over a decade behind the chair, Mira treats every install as an art form —
gentle on the scalp, precise at the root, and finished with a crown you'll want
to show off.
</p>
</div>
</div>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.section}>
<h2>Signature Styles</h2>
<div className={styles.row}>
{services.map((service) => (
<ServiceCard
key={service.id}
service={service}
onSelect={(serviceId) => navigate('/book', { state: { serviceId } })}
/>
))}
</div>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.altSection}>
<h2>Shop Highlights</h2>
<div className={styles.row}>
{products.filter((p) => p.inStock).slice(0, 3).map((product) => (
<ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
))}
</div>
<p><Link to="/shop">Shop All</Link></p>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.section}>
<h2>Client Love</h2>
<div className={styles.row}>
{TESTIMONIALS.map((t) => (
<blockquote key={t.author} className={styles.testimonial}>
<img src={t.avatar} alt={t.author} className={styles.avatar} />
<p>&ldquo;{t.quote}&rdquo;</p>
<cite>{t.author}</cite>
</blockquote>
))}
</div>
</section>
</ScrollReveal>
<section className={styles.banner}>
<h2>Before You Book</h2>
<p>
Please take a moment to read the <Link to="/rules">salon rules</Link> before booking your appointment.
</p>
</section>
</div>
)
}
export default Home
```
- [ ] **Step 4: Update `Home.module.css`**
Add these rules (keep all existing rules, including the media query at the bottom, unchanged — add these before the media query):
```css
.aboutGrid {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xl);
flex-wrap: wrap;
text-align: left;
max-width: 800px;
margin: 0 auto;
}
.aboutGrid h2,
.aboutGrid p {
text-align: left;
}
.portrait {
width: 220px;
height: 220px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
.testimonial {
display: flex;
flex-direction: column;
align-items: center;
max-width: 260px;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
margin-bottom: var(--space-sm);
}
```
Also tighten the dead-whitespace gaps: change `.section` and `.altSection`'s base rule (via `.section`, since `.altSection` composes it) from `padding: var(--space-xl) var(--space-lg);` to `padding: var(--space-lg) var(--space-lg);` — this reduces the top/bottom padding on every content section without touching the hero or the dark banner (which have their own separate rules).
- [ ] **Step 5: Run the test to verify it passes**
Run: `npx vitest run src/pages/Home.test.tsx`
Expected: PASS
- [ ] **Step 6: Run the full suite and build**
Run: `npx vitest run && npm run build`
Expected: all tests pass, build succeeds.
- [ ] **Step 7: Commit**
```bash
git add src/pages/Home.tsx src/pages/Home.module.css src/pages/Home.test.tsx
git commit -m "feat: add portrait with parallax, real product cards, and testimonial avatars to Home"
```
---
### Task 7: Shop — sliding active-filter indicator and tighter grid
**Files:**
- Modify: `src/pages/Shop.tsx`
- Modify: `src/pages/Shop.module.css`
- Modify: `src/pages/Shop.test.tsx` (only if the active-class assertion needs updating — check first)
**Interfaces:**
- No prop/type changes — `Shop` remains a route page component with no props.
- [ ] **Step 1: Check whether `Shop.test.tsx` asserts on the `active` class directly**
Run: `grep -n "active" src/pages/Shop.test.tsx`
If nothing is found, no test change is needed for this step (the existing tests check filtered product visibility, not the class name, so they'll keep passing). If something is found, note it — you'll need to keep that assertion working with the new markup below (the button element still exists with the same test ids/roles, only its internal content changes).
- [ ] **Step 2: Replace the filter button rendering in `Shop.tsx`**
Replace the `<div className={styles.filters}>...</div>` block with:
```tsx
<div className={styles.filters}>
{CATEGORIES.map((c) => (
<button
key={c}
type="button"
className={styles.filterButton}
onClick={() => setCategory(c)}
>
{c === category && (
<motion.span
layoutId="active-filter-pill"
className={styles.activePill}
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
/>
)}
<span className={styles.filterLabel}>{c.replace('-', ' ')}</span>
</button>
))}
</div>
```
Add the import at the top of the file:
```tsx
import { motion } from 'framer-motion'
```
Full updated `src/pages/Shop.tsx`:
```tsx
import { useState } from 'react'
import { motion } from 'framer-motion'
import ProductCard from '../components/ProductCard'
import InquiryModal from '../components/InquiryModal'
import { getProductsByCategory, type Product } from '../data/products'
import styles from './Shop.module.css'
const CATEGORIES: (Product['category'] | 'all')[] = ['all', 'hair-care', 'extensions', 'tools', 'accessories']
function Shop() {
const [category, setCategory] = useState<Product['category'] | 'all'>('all')
const [inquiring, setInquiring] = useState<Product | null>(null)
return (
<div className={styles.page}>
<h1>Shop</h1>
<div className={styles.filters}>
{CATEGORIES.map((c) => (
<button
key={c}
type="button"
className={styles.filterButton}
onClick={() => setCategory(c)}
>
{c === category && (
<motion.span
layoutId="active-filter-pill"
className={styles.activePill}
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
/>
)}
<span className={styles.filterLabel}>{c.replace('-', ' ')}</span>
</button>
))}
</div>
<div className={styles.grid}>
{getProductsByCategory(category).map((product) => (
<ProductCard key={product.id} product={product} onInquire={setInquiring} />
))}
</div>
{inquiring && <InquiryModal product={inquiring} onClose={() => setInquiring(null)} />}
</div>
)
}
export default Shop
```
Note: the previous `styles.active` class (from the earlier Phase 1 fix) is replaced by this layered pill approach — the button no longer gets a class swap; instead an absolutely-positioned `motion.span` slides between buttons using framer-motion's shared `layoutId`.
- [ ] **Step 3: Update `Shop.module.css`**
Replace the `.filters button` and `.filters button.active` rules with:
```css
.filterButton {
position: relative;
background: none;
border: 1px solid var(--color-gold);
padding: 0.5rem 1rem;
cursor: pointer;
text-transform: capitalize;
overflow: hidden;
color: var(--color-charcoal);
}
.activePill {
position: absolute;
inset: 0;
background: var(--color-charcoal);
z-index: 0;
}
.filterLabel {
position: relative;
z-index: 1;
}
.filterButton:has(.activePill) .filterLabel {
color: var(--color-cream);
}
```
Also tighten grid spacing — change `.grid`'s `gap: var(--space-lg);` to `gap: var(--space-md);`.
- [ ] **Step 4: Run the full suite**
Run: `npx vitest run`
Expected: all tests pass (filter/inquiry behavior tests still pass since the button elements and their click handlers are unchanged, only their internal markup differs).
- [ ] **Step 5: Run the build**
Run: `npm run build`
Expected: succeeds with zero TypeScript errors.
- [ ] **Step 6: Commit**
```bash
git add src/pages/Shop.tsx src/pages/Shop.module.css
git commit -m "feat: animate Shop's active category filter with a sliding pill indicator"
```
---
### Task 8: Book — step progress indicator and animated step transitions
**Files:**
- Create: `src/components/StepProgress.tsx`, `src/components/StepProgress.module.css`
- Test: `src/components/StepProgress.test.tsx`
- Modify: `src/pages/Book.tsx`
- Modify: `src/pages/Book.module.css`
**Interfaces:**
- Consumes: `BookingStep` type from `src/hooks/useBooking.ts` (`'service' | 'date' | 'slot' | 'contact' | 'confirmed'`)
- Produces: `StepProgress` component — props `{ currentStep: BookingStep }` — renders an ordered list of 4 labeled steps (Service, Date & Time, Contact, Confirmed), marking every step up to and including the current one as visually active. `'date'` and `'slot'` both map to the "Date & Time" step.
- [ ] **Step 1: Write the failing test for `StepProgress`**
`src/components/StepProgress.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import StepProgress from './StepProgress'
describe('StepProgress', () => {
it('marks steps up to and including the current one as active', () => {
render(<StepProgress currentStep="slot" />)
const items = screen.getAllByRole('listitem')
expect(items[0].className).toMatch(/active/) // Service
expect(items[1].className).toMatch(/active/) // Date & Time (slot maps here)
expect(items[2].className).not.toMatch(/active/) // Contact
expect(items[3].className).not.toMatch(/active/) // Confirmed
})
it('marks all steps active when confirmed', () => {
render(<StepProgress currentStep="confirmed" />)
const items = screen.getAllByRole('listitem')
items.forEach((item) => expect(item.className).toMatch(/active/))
})
it('marks only the first step active when on the service step', () => {
render(<StepProgress currentStep="service" />)
const items = screen.getAllByRole('listitem')
expect(items[0].className).toMatch(/active/)
expect(items[1].className).not.toMatch(/active/)
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/components/StepProgress.test.tsx`
Expected: FAIL (module doesn't exist)
- [ ] **Step 3: Implement `StepProgress`**
`src/components/StepProgress.module.css`:
```css
.steps {
display: flex;
justify-content: center;
gap: var(--space-lg);
list-style: none;
padding: 0;
margin: 0 0 var(--space-lg);
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
opacity: 0.4;
transition: opacity 0.3s ease;
}
.step.active {
opacity: 1;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-gold);
}
.label {
font-size: 0.7rem;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--color-charcoal);
}
```
`src/components/StepProgress.tsx`:
```tsx
import type { BookingStep } from '../hooks/useBooking'
import styles from './StepProgress.module.css'
const STEPS = [
{ key: 'service', label: 'Service' },
{ key: 'date', label: 'Date & Time' },
{ key: 'contact', label: 'Contact' },
{ key: 'confirmed', label: 'Confirmed' },
] as const
interface StepProgressProps {
currentStep: BookingStep
}
function stepIndex(step: BookingStep): number {
if (step === 'slot') return 1
return STEPS.findIndex((s) => s.key === step)
}
function StepProgress({ currentStep }: StepProgressProps) {
const activeIndex = stepIndex(currentStep)
return (
<ol className={styles.steps}>
{STEPS.map((step, index) => (
<li key={step.key} className={`${styles.step} ${index <= activeIndex ? styles.active : ''}`}>
<span className={styles.dot} />
<span className={styles.label}>{step.label}</span>
</li>
))}
</ol>
)
}
export default StepProgress
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/components/StepProgress.test.tsx`
Expected: PASS (3 tests)
- [ ] **Step 5: Wire `StepProgress` into `Book.tsx` and animate step transitions**
Replace the entire contents of `src/pages/Book.tsx` with:
```tsx
import { useLocation } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import useBooking from '../hooks/useBooking'
import ServiceCard from '../components/ServiceCard'
import StepProgress from '../components/StepProgress'
import { services } from '../data/services'
import { getSlotsForDate, getNextAvailableDate } from '../data/availability'
import styles from './Book.module.css'
function Book() {
const location = useLocation()
const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId
const booking = useBooking(preselectedServiceId)
const [dateInput, setDateInput] = useState('')
const slots = useMemo(() => (dateInput ? getSlotsForDate(dateInput) : []), [dateInput])
const availableSlots = slots.filter((s) => s.available)
const nextAvailable = useMemo(
() => (dateInput && availableSlots.length === 0 ? getNextAvailableDate(dateInput) : null),
[dateInput, availableSlots.length],
)
const selectedService = services.find((s) => s.id === booking.state.serviceId)
return (
<div className={styles.page}>
<h1>Book an Appointment</h1>
<StepProgress currentStep={booking.state.step} />
<AnimatePresence mode="wait">
<motion.div
key={booking.state.step}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={{ duration: 0.25 }}
>
{booking.state.step === 'confirmed' && (
<p>
{selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}.
Mira will see you then.
</p>
)}
{booking.state.step === 'service' && (
<div className={styles.services}>
{services.map((service) => (
<ServiceCard key={service.id} service={service} onSelect={booking.selectService} />
))}
</div>
)}
{(booking.state.step === 'date' || booking.state.step === 'slot') && (
<>
<div className={styles.field}>
<label htmlFor="booking-date">Choose a date</label>
<input
id="booking-date"
type="date"
value={dateInput}
min={new Date().toISOString().slice(0, 10)}
onChange={(e) => {
setDateInput(e.target.value)
booking.selectDate(e.target.value)
}}
/>
</div>
{dateInput && availableSlots.length === 0 && (
<div>
<p>No times available on this date.</p>
{nextAvailable && (
<button
type="button"
onClick={() => {
setDateInput(nextAvailable)
booking.selectDate(nextAvailable)
}}
>
Try {nextAvailable} instead
</button>
)}
</div>
)}
{availableSlots.length > 0 && (
<div className={styles.slots}>
{availableSlots.map((slot) => (
<button key={slot.time} type="button" onClick={() => booking.selectSlot(slot)}>
{slot.time}
</button>
))}
</div>
)}
</>
)}
{booking.state.step === 'contact' && (
<form
onSubmit={(e) => {
e.preventDefault()
booking.submit()
}}
>
<div className={styles.field}>
<label htmlFor="contact-name">Name</label>
<input
id="contact-name"
value={booking.state.contact.name}
onChange={(e) => booking.updateContact({ name: e.target.value })}
/>
{booking.errors.name && <span className={styles.error}>{booking.errors.name}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-email">Email</label>
<input
id="contact-email"
value={booking.state.contact.email}
onChange={(e) => booking.updateContact({ email: e.target.value })}
/>
{booking.errors.email && <span className={styles.error}>{booking.errors.email}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-phone">Phone</label>
<input
id="contact-phone"
value={booking.state.contact.phone}
onChange={(e) => booking.updateContact({ phone: e.target.value })}
/>
{booking.errors.phone && <span className={styles.error}>{booking.errors.phone}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-notes">Notes</label>
<textarea
id="contact-notes"
value={booking.state.contact.notes}
onChange={(e) => booking.updateContact({ notes: e.target.value })}
/>
</div>
<button type="submit">Confirm Booking</button>
</form>
)}
</motion.div>
</AnimatePresence>
</div>
)
}
export default Book
```
Important behavioral note: the confirmation heading `<h1>You're booked!</h1>` from the old early-return branch is gone because the page now always renders the shared `<h1>Book an Appointment</h1>` plus `<StepProgress>` at the top, with only the body content swapping per step (including the confirmed step). **This changes what `Book.test.tsx`'s confirmation assertion should look for.** Check `src/pages/Book.test.tsx` for `screen.getByText(/you're booked/i)` — if present, update it to match the new confirmation copy, which is now just the paragraph text, e.g. change the assertion to check for the same paragraph text without expecting a `"You're booked!"` heading. Concretely, if the test currently does:
```tsx
expect(screen.getByText(/you're booked/i)).toBeInTheDocument()
```
change it to:
```tsx
expect(screen.getByText(/mira will see you then/i)).toBeInTheDocument()
```
- [ ] **Step 6: Add `StepProgress` spacing to `Book.module.css`**
No new rules strictly required (StepProgress brings its own margin), but double-check `.page`'s existing `max-width: 640px; margin: 0 auto;` still reads well with the progress indicator — no change needed unless visual inspection in Step 8 says otherwise.
- [ ] **Step 7: Run the full suite**
Run: `npx vitest run`
Expected: all tests pass, including the updated confirmation assertion in `Book.test.tsx`.
- [ ] **Step 8: Run the build**
Run: `npm run build`
Expected: succeeds with zero TypeScript errors.
- [ ] **Step 9: Commit**
```bash
git add src/components/StepProgress.tsx src/components/StepProgress.module.css src/components/StepProgress.test.tsx \
src/pages/Book.tsx src/pages/Book.test.tsx
git commit -m "feat: add step progress indicator and animated transitions to Book"
```
---
### Task 9: Rules — icons, stagger, and tighter typography rhythm
**Files:**
- Create: `src/components/RuleIcon.tsx`
- Test: `src/components/RuleIcon.test.tsx`
- Modify: `src/pages/Rules.tsx`
- Modify: `src/pages/Rules.module.css`
**Interfaces:**
- Produces: `RuleIcon` component — props `{ ruleId: string }` — renders a small inline SVG icon. Falls back to a default icon for an unrecognized `ruleId` (never throws).
- [ ] **Step 1: Write the failing test for `RuleIcon`**
`src/components/RuleIcon.test.tsx`:
```tsx
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import RuleIcon from './RuleIcon'
describe('RuleIcon', () => {
it('renders an svg for a known rule id', () => {
const { container } = render(<RuleIcon ruleId="deposit" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('falls back to a default icon for an unknown rule id without throwing', () => {
const { container } = render(<RuleIcon ruleId="not-a-real-rule" />)
expect(container.querySelector('svg path')).toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/components/RuleIcon.test.tsx`
Expected: FAIL (module doesn't exist)
- [ ] **Step 3: Implement `RuleIcon`**
`src/components/RuleIcon.tsx`:
```tsx
interface RuleIconProps {
ruleId: string
}
const ICON_PATHS: Record<string, string> = {
deposit: 'M4 7h16M4 7a2 2 0 00-2 2v8a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2M4 7l2-4h12l2 4M9 13h1v3H9zM14 13h1v3h-1z',
cancellation: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
guests: 'M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m5-8.13a4 4 0 110 8 4 4 0 010-8zm6 4a4 4 0 10-3.2-6.4M5.2 5.6A4 4 0 108.4 12',
'hair-prep': 'M12 3c-2 3-5 5-5 9a5 5 0 0010 0c0-4-3-6-5-9zM12 12v9',
'health-safety': 'M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z',
}
function RuleIcon({ ruleId }: RuleIconProps) {
const d = ICON_PATHS[ruleId] ?? ICON_PATHS.deposit
return (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={d} />
</svg>
)
}
export default RuleIcon
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/components/RuleIcon.test.tsx`
Expected: PASS (2 tests)
- [ ] **Step 5: Wire `RuleIcon` into `Rules.tsx` with stagger**
Replace `src/pages/Rules.tsx` entirely:
```tsx
import { Link } from 'react-router-dom'
import ScrollReveal from '../components/ScrollReveal'
import RuleIcon from '../components/RuleIcon'
import { rules } from '../data/rules'
import styles from './Rules.module.css'
function Rules() {
return (
<div className={styles.page}>
<h1>Salon Rules & Policies</h1>
<p>Please read through before booking your appointment.</p>
{rules.map((rule, index) => (
<ScrollReveal key={rule.id} delay={index * 0.08}>
<div className={styles.section}>
<RuleIcon ruleId={rule.id} />
<div>
<h2>{rule.title}</h2>
<p>{rule.body}</p>
</div>
</div>
</ScrollReveal>
))}
<div className={styles.cta}>
<p>By booking, you agree to these terms.</p>
<Link to="/book">Book Now</Link>
</div>
</div>
)
}
export default Rules
```
- [ ] **Step 6: Update `Rules.module.css` for the icon layout**
Replace the `.section` rule (find it in the file — the existing rule sets `margin-bottom: var(--space-lg);`) with:
```css
.section {
display: flex;
align-items: flex-start;
gap: var(--space-md);
margin-bottom: var(--space-md);
color: var(--color-gold);
}
.section h2 {
color: var(--color-gold);
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
margin: 0 0 0.35rem;
}
.section p {
color: var(--color-charcoal);
margin: 0;
}
```
Remove the old standalone `.section h2` rule if it duplicates the one above (check the file — Phase 1's `Rules.module.css` had `.section h2` as a separate rule; consolidate into the block above, don't leave two conflicting `.section h2` rules).
- [ ] **Step 7: Run the full suite**
Run: `npx vitest run`
Expected: all tests pass (existing `Rules.test.tsx` checks title/body text and the `/book` link, both still present in the new markup).
- [ ] **Step 8: Run the build**
Run: `npm run build`
Expected: succeeds with zero TypeScript errors.
- [ ] **Step 9: Commit**
```bash
git add src/components/RuleIcon.tsx src/components/RuleIcon.test.tsx src/pages/Rules.tsx src/pages/Rules.module.css
git commit -m "feat: add per-rule icons and staggered scroll-in to Rules page"
```
---
### Task 10: Final verification and manual browser check
**Files:**
- No new files — this task is verification only.
- [ ] **Step 1: Run the full automated test suite**
Run: `npx vitest run`
Expected: every test file passes, including all changes from Tasks 3–9.
- [ ] **Step 2: Type-check and build**
Run: `npm run build`
Expected: `tsc -b && vite build` succeeds with zero errors.
- [ ] **Step 3: Lint**
Run: `npm run lint`
Expected: zero errors.
- [ ] **Step 4: Manual verification in the browser**
Run: `npm run dev`, then open the printed local URL (or drive it with Playwright browser tools if available) and check:
- `/` — the hero now shows a real rotating 3D braided-crown scene (not a flat circle); scroll down and confirm the About portrait, Signature Style images, and Shop Highlight product images all render; confirm testimonial avatars render; confirm the dead-whitespace gaps from before are visibly smaller
- `/shop` — product images render in the grid; clicking a category filter animates a sliding pill behind the active button instead of an instant color swap
- `/book` — the 4-step progress indicator appears at the top and updates as you move through service → date → slot → contact → confirmation; each step transition fades/slides instead of snapping instantly; service picker cards show style images
- `/rules` — each rule section shows a small icon next to its title; sections animate in with a slight stagger as you scroll
- Resize to a narrow (375px) viewport and confirm nothing overflows horizontally, especially the new About portrait layout on Home and the step progress indicator on Book
- Enable "reduce motion" (OS-level or via injecting a `matchMedia` override) and reload `/` — confirm the hero falls back to the static gradient circle with no WebGL canvas mounted, and Rules sections no longer stagger-animate
- [ ] **Step 5: Report any visual issues found**
If manual verification finds a layout or visual bug, fix it directly in the relevant page/component file, re-run the full suite and build, and note the fix. Do not leave known visual bugs unaddressed at the end of this task.
- [ ] **Step 6: Commit any fixes from Step 5**
```bash
git add -A
git commit -m "polish: fix visual issues found during manual verification"
```
(Skip this commit if Step 5 found nothing to fix.)
---
## Self-Review Notes
- **Spec coverage:** 3D hero (Task 3), imagery generation (Task 2) and wiring (Tasks 4, 5, 6), Home spacing/portrait/highlights/testimonials (Task 6), Shop sliding filter + grid spacing (Task 7), Book step progress + animated transitions (Task 8), Rules icons + stagger + typography (Task 9), performance/a11y (canvas `aria-hidden`, route-scoped R3F imports, reduced-motion fallback — Task 3; image `alt` text — Tasks 4–6), final verification (Task 10). All spec sections are covered.
- **Type consistency:** `Hero3DScene` (Task 3), `ProductCard`/`ServiceCard` (Tasks 4–5), `StepProgress`/`BookingStep` (Task 8), `RuleIcon` (Task 9) are each defined once and referenced by the same name/signature in every later task that uses them.
- **No placeholders:** every step has literal file contents, exact prompts (Task 2), and exact commands.