diff --git a/docs/superpowers/plans/2026-08-03-braids-by-mira-phase1.md b/docs/superpowers/plans/2026-08-03-braids-by-mira-phase1.md new file mode 100644 index 0000000..49709fc --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-braids-by-mira-phase1.md @@ -0,0 +1,2605 @@ +# Braids by Mira — Phase 1 (Public Site) 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:** Build the public-facing Braids by Mira site — Home, Shop (product showcase), Book (mock-data booking flow), and Rules pages — in the "Elegant Editorial" visual style with scroll-driven motion, on top of the existing Vite + React 19 + TypeScript scaffold. + +**Architecture:** A single-page React app with client-side routing (`react-router-dom`) rendering four routed pages inside a shared `Layout` (Nav + Footer). All content (products, services, availability, rules copy) comes from typed local mock-data modules under `src/data/`, shaped so a future backend can replace them without touching component code. Motion (scroll reveals, hero rotation, hover tilt) is built with `framer-motion` and respects `prefers-reduced-motion`. + +**Tech Stack:** Vite, React 19, TypeScript, react-router-dom v7, framer-motion, Vitest + React Testing Library + jsdom, plain CSS (CSS variables + CSS Modules, no utility framework). + +## Global Constraints + +- Palette: cream `#F7F3EC` (primary bg), `#EFE7D8` (secondary bg), charcoal `#3A332E` (ink), antique gold `#B08D57` (accent) — from the spec's Visual System section. +- Type: Georgia/serif for labels/nav/body; a script webfont ("Great Vibes") for headline moments only. +- No cart/checkout — Shop uses an "Ask about this" inquiry modal instead, per spec. +- No real backend/network calls anywhere in Phase 1 — all data is local mock data; booking/inquiry "submission" just transitions UI state to a confirmation screen. +- All motion must respect `prefers-reduced-motion` (instant fades instead of parallax/rotation/stagger). +- Every component/hook task includes tests written before implementation (Vitest + React Testing Library). + +--- + +### Task 1: Tooling setup — Vitest, routing, motion, and CSS tokens + +**Files:** +- Modify: `package.json` (new deps) +- Create: `vite.config.ts` (add `test` config) +- Create: `src/test/setup.ts` +- Create: `src/styles/tokens.css` +- Modify: `index.html` (title, Google Fonts link for "Great Vibes") +- Modify: `src/index.css` (replace default theme vars with tokens import + reset) +- Delete: `src/App.css`, `src/assets/react.svg`, `src/assets/vite.svg`, `src/assets/hero.png` +- Modify: `src/App.tsx` (temporary minimal router shell, replaced page-by-page in later tasks) +- Test: `src/App.test.tsx` + +**Interfaces:** +- Produces: CSS variables available globally — `--color-cream`, `--color-cream-alt`, `--color-charcoal`, `--color-gold`, `--font-serif`, `--font-script` (defined in `src/styles/tokens.css`, imported once from `src/index.css`). +- Produces: `App.tsx` exports default `App` component that renders a `BrowserRouter` with routes `/`, `/shop`, `/book`, `/rules`, each currently rendering a placeholder `
` with a unique text marker (`data-testid="page-home"` etc.) — later tasks swap each placeholder for the real page component. + +- [ ] **Step 1: Install dependencies** + +```bash +npm install react-router-dom framer-motion +npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom +``` + +- [ ] **Step 2: Add Vitest config to `vite.config.ts`** + +```ts +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +}) +``` + +- [ ] **Step 3: Create the test setup file** + +`src/test/setup.ts`: + +```ts +import '@testing-library/jest-dom/vitest' +``` + +- [ ] **Step 4: Add a `test` script to `package.json`** + +Add under `"scripts"`: `"test": "vitest run"` + +- [ ] **Step 5: Create the design token stylesheet** + +`src/styles/tokens.css`: + +```css +:root { + --color-cream: #F7F3EC; + --color-cream-alt: #EFE7D8; + --color-charcoal: #3A332E; + --color-gold: #B08D57; + + --font-serif: Georgia, 'Times New Roman', serif; + --font-script: 'Great Vibes', cursive; + + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 2rem; + --space-xl: 4rem; +} +``` + +- [ ] **Step 6: Replace `src/index.css` with the reset + token import** + +```css +@import './styles/tokens.css'; + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + background: var(--color-cream); + color: var(--color-charcoal); + font-family: var(--font-serif); + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; +} +``` + +- [ ] **Step 7: Delete unused boilerplate files** + +```bash +rm src/App.css src/assets/react.svg src/assets/vite.svg src/assets/hero.png +``` + +- [ ] **Step 8: Add the Google Fonts link to `index.html`** and update the title + +In ``, replace the existing `` and add before it: + +```html +<link rel="preconnect" href="https://fonts.googleapis.com"> +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> +<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet"> +<title>Braids by Mira +``` + +- [ ] **Step 9: Write the failing router shell test** + +`src/App.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect } from 'vitest' +import App from './App' + +describe('App routing', () => { + it('renders the home placeholder at /', () => { + render() + expect(screen.getByTestId('page-home')).toBeInTheDocument() + }) + + it('renders the shop placeholder at /shop', () => { + render() + expect(screen.getByTestId('page-shop')).toBeInTheDocument() + }) + + it('renders the book placeholder at /book', () => { + render() + expect(screen.getByTestId('page-book')).toBeInTheDocument() + }) + + it('renders the rules placeholder at /rules', () => { + render() + expect(screen.getByTestId('page-rules')).toBeInTheDocument() + }) +}) +``` + +Note: to make `App` accept `initialEntries` for testing, `App.tsx` will take an optional prop and use `MemoryRouter` in tests vs `BrowserRouter` in production — see Step 11. + +- [ ] **Step 10: Run the test to verify it fails** + +Run: `npx vitest run src/App.test.tsx` +Expected: FAIL (`App` doesn't exist yet / no matching testid) + +- [ ] **Step 11: Write the minimal router shell** + +`src/App.tsx`: + +```tsx +import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom' + +interface AppProps { + initialEntries?: string[] +} + +function AppRoutes() { + return ( + + } /> + } /> + } /> + } /> + + ) +} + +function App({ initialEntries }: AppProps) { + if (initialEntries) { + return ( + + + + ) + } + return ( + + + + ) +} + +export default App +``` + +- [ ] **Step 12: Run the test to verify it passes** + +Run: `npx vitest run src/App.test.tsx` +Expected: PASS (4 tests) + +- [ ] **Step 13: Verify the app still builds and runs** + +Run: `npm run build` +Expected: build succeeds with no TypeScript errors + +- [ ] **Step 14: Commit** + +```bash +git add -A +git commit -m "chore: set up Vitest, routing, and design tokens" +``` + +--- + +### Task 2: Mock data modules — products, services, availability, rules + +**Files:** +- Create: `src/data/products.ts` +- Create: `src/data/services.ts` +- Create: `src/data/availability.ts` +- Create: `src/data/rules.ts` +- Test: `src/data/products.test.ts` +- Test: `src/data/availability.test.ts` + +**Interfaces:** +- Produces: `Product` type + `products: Product[]` + `getProductsByCategory(category: Product['category'] | 'all'): Product[]` from `src/data/products.ts` +- Produces: `Service` type + `services: Service[]` from `src/data/services.ts` +- Produces: `TimeSlot` type + `getSlotsForDate(date: string): TimeSlot[]` + `getNextAvailableDate(fromDate: string): string | null` from `src/data/availability.ts` +- Produces: `RuleSection` type + `rules: RuleSection[]` from `src/data/rules.ts` + +- [ ] **Step 1: Write failing tests for products data** + +`src/data/products.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { products, getProductsByCategory } from './products' + +describe('products data', () => { + it('has at least 6 products with required fields', () => { + expect(products.length).toBeGreaterThanOrEqual(6) + for (const p of products) { + expect(p.id).toBeTruthy() + expect(p.name).toBeTruthy() + expect(p.price).toBeGreaterThan(0) + expect(typeof p.inStock).toBe('boolean') + } + }) + + it('filters by category', () => { + const hairCare = getProductsByCategory('hair-care') + expect(hairCare.length).toBeGreaterThan(0) + expect(hairCare.every((p) => p.category === 'hair-care')).toBe(true) + }) + + it('returns all products for "all"', () => { + expect(getProductsByCategory('all')).toHaveLength(products.length) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/data/products.test.ts` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 3: Implement products data** + +`src/data/products.ts`: + +```ts +export interface Product { + id: string + name: string + category: 'hair-care' | 'extensions' | 'tools' | 'accessories' + price: number + image: string + description: string + inStock: boolean +} + +export const products: Product[] = [ + { id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care', price: 12, image: '/products/edge-control.jpg', description: 'Long-lasting hold with a natural shine, no flaking.', inStock: true }, + { id: 'braid-oil', name: 'Nourishing Braid Oil', category: 'hair-care', price: 16, image: '/products/braid-oil.jpg', description: 'Lightweight scalp oil that soothes and adds shine between washes.', inStock: true }, + { id: 'leave-in', name: 'Detangling Leave-In', category: 'hair-care', price: 14, image: '/products/leave-in.jpg', description: 'Slip-rich leave-in conditioner for gentle detangling.', inStock: true }, + { id: 'kanekalon-black', name: 'Kanekalon Braiding Hair — Jet Black', category: 'extensions', price: 8, image: '/products/kanekalon-black.jpg', description: 'Premium synthetic braiding hair, soft texture, low shedding.', inStock: true }, + { id: 'kanekalon-brown', name: 'Kanekalon Braiding Hair — Chestnut', category: 'extensions', price: 8, image: '/products/kanekalon-brown.jpg', description: 'Warm brown tone, blends seamlessly for natural styles.', inStock: false }, + { id: 'locs-extension', name: 'Faux Locs Extensions', category: 'extensions', price: 22, image: '/products/locs-extension.jpg', description: 'Pre-looped faux locs for a natural, lightweight install.', inStock: true }, + { id: 'rat-tail-comb', name: 'Steel Rat-Tail Comb', category: 'tools', price: 6, image: '/products/rat-tail-comb.jpg', description: 'Precision parting comb with a fine metal tail.', inStock: true }, + { id: 'edge-brush', name: 'Mini Edge Brush', category: 'tools', price: 5, image: '/products/edge-brush.jpg', description: 'Dual-sided brush for laying edges and smoothing baby hairs.', inStock: true }, + { id: 'gold-cuffs', name: 'Gold Hair Cuffs (Set of 6)', category: 'accessories', price: 10, image: '/products/gold-cuffs.jpg', description: 'Adjustable cuffs to accessorize braids and locs.', inStock: true }, + { id: 'satin-bonnet', name: 'Satin Sleep Bonnet', category: 'accessories', price: 9, image: '/products/satin-bonnet.jpg', description: 'Protects braids and locs overnight, reduces frizz.', inStock: true }, +] + +export function getProductsByCategory(category: Product['category'] | 'all'): Product[] { + if (category === 'all') return products + return products.filter((p) => p.category === category) +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run src/data/products.test.ts` +Expected: PASS + +- [ ] **Step 5: Implement services data (no test file — simple static data, covered indirectly by Home/Book tests in later tasks)** + +`src/data/services.ts`: + +```ts +export interface Service { + id: string + name: string + durationMinutes: number + priceFrom: number + description: string + image: string +} + +export const services: Service[] = [ + { id: 'knotless', name: 'Knotless Braids', durationMinutes: 240, priceFrom: 150, description: 'Tension-free braids with a natural, seamless root.', image: '/styles/knotless.jpg' }, + { id: 'boho', name: 'Boho Braids', durationMinutes: 270, priceFrom: 180, description: 'Knotless braids with curly pieces woven in for a soft, romantic look.', image: '/styles/boho.jpg' }, + { id: 'locs', name: 'Faux Locs', durationMinutes: 300, priceFrom: 200, description: 'Lightweight, natural-looking locs installed with care.', image: '/styles/locs.jpg' }, + { id: 'cornrows', name: 'Cornrows', durationMinutes: 120, priceFrom: 90, description: 'Classic sleek cornrows, straight-back or with a custom design.', image: '/styles/cornrows.jpg' }, +] +``` + +- [ ] **Step 6: Write failing tests for availability data** + +`src/data/availability.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { getSlotsForDate, getNextAvailableDate } from './availability' + +describe('availability data', () => { + it('returns a list of time slots for a given date', () => { + const slots = getSlotsForDate('2026-08-10') + expect(slots.length).toBeGreaterThan(0) + for (const s of slots) { + expect(s.date).toBe('2026-08-10') + expect(typeof s.time).toBe('string') + expect(typeof s.available).toBe('boolean') + } + }) + + it('is deterministic for the same date', () => { + expect(getSlotsForDate('2026-08-10')).toEqual(getSlotsForDate('2026-08-10')) + }) + + it('finds the next available date within 14 days when none is available immediately', () => { + // Sundays (day 0) are fully booked in the mock generator + const next = getNextAvailableDate('2026-08-09') // a Sunday + expect(next).not.toBeNull() + expect(next).not.toBe('2026-08-09') + }) + + it('returns null if no available date is found within the search window', () => { + expect(getNextAvailableDate('1999-01-01', 0)).toBeNull() + }) +}) +``` + +- [ ] **Step 7: Run to verify it fails** + +Run: `npx vitest run src/data/availability.test.ts` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 8: Implement availability data** + +`src/data/availability.ts`: + +```ts +export interface TimeSlot { + date: string // ISO date, e.g. "2026-08-10" + time: string // e.g. "10:00 AM" + available: boolean +} + +const ALL_TIMES = ['9:00 AM', '11:00 AM', '1:00 PM', '3:00 PM', '5:00 PM'] + +function seedFromDate(date: string): number { + let seed = 0 + for (let i = 0; i < date.length; i++) seed += date.charCodeAt(i) + return seed +} + +export function getSlotsForDate(date: string): TimeSlot[] { + const day = new Date(`${date}T00:00:00`).getDay() + if (day === 0) { + // Sundays: closed, no slots at all + return [] + } + const seed = seedFromDate(date) + return ALL_TIMES.map((time, i) => ({ + date, + time, + available: (seed + i) % 3 !== 0, + })) +} + +export function getNextAvailableDate(fromDate: string, searchWindowDays = 14): string | null { + const start = new Date(`${fromDate}T00:00:00`) + for (let i = 1; i <= searchWindowDays; i++) { + const candidate = new Date(start) + candidate.setDate(start.getDate() + i) + const iso = candidate.toISOString().slice(0, 10) + if (getSlotsForDate(iso).some((s) => s.available)) { + return iso + } + } + return null +} +``` + +- [ ] **Step 9: Run to verify it passes** + +Run: `npx vitest run src/data/availability.test.ts` +Expected: PASS + +- [ ] **Step 10: Implement rules data (no dedicated test — content is exercised by the Rules page test in Task 9)** + +`src/data/rules.ts`: + +```ts +export interface RuleSection { + id: string + title: string + body: string +} + +export const rules: RuleSection[] = [ + { id: 'deposit', title: 'Deposit', body: 'A non-refundable deposit of $30 is required to secure your appointment and will be deducted from your final total. Appointments are not confirmed until the deposit is received.' }, + { id: 'cancellation', title: 'Cancellation & Lateness', body: 'Please give at least 48 hours notice to reschedule. Arrivals more than 15 minutes late may need to be rescheduled and will forfeit their deposit, as it affects every client booked after you.' }, + { id: 'guests', title: 'Guests & Kids', body: 'To keep the space calm and on schedule, please come to your appointment alone unless the guest is also being serviced. Children who are not being serviced should not accompany you.' }, + { id: 'hair-prep', title: 'Hair Prep', body: 'Arrive with hair freshly washed, blow-dried or stretched, and fully detangled unless a wash service has been booked. Extra prep time may be added to your total if hair arrives unprepped.' }, + { id: 'health-safety', title: 'Health & Safety', body: 'Please reschedule if you are unwell. Let Mira know in advance of any scalp sensitivities, allergies, or conditions that may affect your service.' }, +] +``` + +- [ ] **Step 11: Run the full data test suite** + +Run: `npx vitest run src/data` +Expected: all PASS + +- [ ] **Step 12: Commit** + +```bash +git add src/data +git commit -m "feat: add mock data for products, services, availability, and rules" +``` + +--- + +### Task 3: `ScrollReveal` component + +**Files:** +- Create: `src/components/ScrollReveal.tsx` +- Test: `src/components/ScrollReveal.test.tsx` + +**Interfaces:** +- Consumes: nothing from earlier tasks +- Produces: `ScrollReveal` component — `{children}` — wraps children in a `framer-motion` `motion.div` that fades + rises into view on scroll, and renders children plainly (no animation props) when `prefers-reduced-motion: reduce` is set. Used by Home/Shop/Book/Rules sections in later tasks. + +- [ ] **Step 1: Write the failing test** + +`src/components/ScrollReveal.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, afterEach } from 'vitest' +import ScrollReveal from './ScrollReveal' + +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('ScrollReveal', () => { + afterEach(() => vi.restoreAllMocks()) + + it('renders its children', () => { + mockMatchMedia(false) + render(

Hello

) + expect(screen.getByText('Hello')).toBeInTheDocument() + }) + + it('renders children unanimated when reduced motion is preferred', () => { + mockMatchMedia(true) + render(

Hello

) + expect(screen.getByTestId('content')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/components/ScrollReveal.test.tsx` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 3: Implement `ScrollReveal`** + +`src/components/ScrollReveal.tsx`: + +```tsx +import type { ReactNode } from 'react' +import { motion, useReducedMotion } from 'framer-motion' + +interface ScrollRevealProps { + children: ReactNode + delay?: number +} + +function ScrollReveal({ children, delay = 0 }: ScrollRevealProps) { + const prefersReducedMotion = useReducedMotion() + + if (prefersReducedMotion) { + return <>{children} + } + + return ( + + {children} + + ) +} + +export default ScrollReveal +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run src/components/ScrollReveal.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/ScrollReveal.tsx src/components/ScrollReveal.test.tsx +git commit -m "feat: add ScrollReveal motion wrapper" +``` + +--- + +### Task 4: `Nav` and `Footer` components + +**Files:** +- Create: `src/components/Nav.tsx`, `src/components/Nav.module.css` +- Create: `src/components/Footer.tsx`, `src/components/Footer.module.css` +- Test: `src/components/Nav.test.tsx` +- Test: `src/components/Footer.test.tsx` + +**Interfaces:** +- Produces: `Nav` component (no props) — renders the logo/wordmark and links to `/`, `/shop`, `/book`, `/rules`; adds a `condensed` class to its root once `window.scrollY > 40`. +- Produces: `Footer` component (no props) — renders contact info, hours, and quick links to the four routes. +- Both must be rendered inside a `MemoryRouter` (or `BrowserRouter`) in tests and in `Layout` (Task 5) since they use `Link`. + +- [ ] **Step 1: Write the failing Nav test** + +`src/components/Nav.test.tsx`: + +```tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect } from 'vitest' +import Nav from './Nav' + +describe('Nav', () => { + it('renders links to all four routes', () => { + render(