# 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 `` 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` `` 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 `` 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 }) =>
{children}
, 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() 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() 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 ( ) } interface BraidedCrownProps { scrollTilt: MotionValue } function BraidedCrown({ scrollTilt }: BraidedCrownProps) { const groupRef = useRef(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 ( {strands.map((s, i) => ( ))} ) } function Hero3DScene() { const prefersReducedMotion = useReducedMotion() const { scrollY } = useScroll() const scrollTilt = useTransform(scrollY, [0, 600], [0, 0.35]) if (prefersReducedMotion) { return (
) } return ( ) } 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 `` to `` 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() 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 `` as the first child of the card, right after the opening `
`: ```tsx {product.name} ``` Full updated render: ```tsx return (
{product.name}

{product.name}

${product.price}

{product.description}

{!product.inStock &&

Currently unavailable

}
) ``` - [ ] **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() 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 ` ) ``` - [ ] **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 (

Your hair, your crown

Protective Styles by Mira

Book Now Shop Products

About Mira

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.

Signature Styles

{services.map((service) => ( navigate('/book', { state: { serviceId } })} /> ))}

Shop Highlights

{products.filter((p) => p.inStock).slice(0, 3).map((product) => ( navigate('/shop')} /> ))}

Shop All

Client Love

{TESTIMONIALS.map((t) => (
{t.author}

“{t.quote}”

{t.author}
))}

Before You Book

Please take a moment to read the salon rules before booking your appointment.

) } 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 `
...
` block with: ```tsx
{CATEGORIES.map((c) => ( ))}
``` 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('all') const [inquiring, setInquiring] = useState(null) return (

Shop

{CATEGORIES.map((c) => ( ))}
{getProductsByCategory(category).map((product) => ( ))}
{inquiring && setInquiring(null)} />}
) } 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() 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() 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() 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 (
    {STEPS.map((step, index) => (
  1. {step.label}
  2. ))}
) } 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 (

Book an Appointment

{booking.state.step === 'confirmed' && (

{selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}. Mira will see you then.

)} {booking.state.step === 'service' && (
{services.map((service) => ( ))}
)} {(booking.state.step === 'date' || booking.state.step === 'slot') && ( <>
{ setDateInput(e.target.value) booking.selectDate(e.target.value) }} />
{dateInput && availableSlots.length === 0 && (

No times available on this date.

{nextAvailable && ( )}
)} {availableSlots.length > 0 && (
{availableSlots.map((slot) => ( ))}
)} )} {booking.state.step === 'contact' && (
{ e.preventDefault() booking.submit() }} >
booking.updateContact({ name: e.target.value })} /> {booking.errors.name && {booking.errors.name}}
booking.updateContact({ email: e.target.value })} /> {booking.errors.email && {booking.errors.email}}
booking.updateContact({ phone: e.target.value })} /> {booking.errors.phone && {booking.errors.phone}}