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

52 KiB
Raw Blame History

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

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
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
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
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:

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:

.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:

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
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:

import Hero3D from '../components/Hero3D'

to:

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
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):

    expect(screen.getByRole('img', { name: 'Silk Edge Control' })).toHaveAttribute('src', '/x.jpg')

The full first test becomes:

  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}>:

      <img src={product.image} alt={product.name} className={styles.image} />

Full updated render:

  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):

.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
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:

  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:

  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
.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
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:

  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:

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):

.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
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:

      <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:

import { motion } from 'framer-motion'

Full updated src/pages/Shop.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:

.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
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:

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:

.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:

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:

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:

expect(screen.getByText(/you're booked/i)).toBeInTheDocument()

change it to:

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
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:

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:

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:

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:

.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
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
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.