feat: replace CSS hero motif with a real WebGL 3D braided-crown scene
Adds Hero3DScene, a procedurally-built rotating braid mesh rendered via @react-three/fiber, replacing the flat CSS-circle Hero3D component. Falls back to a static gradient circle under prefers-reduced-motion, matching the old component's fallback behavior. Also adds a ResizeObserver mock to the test setup, required by @react-three/fiber's Canvas (via react-use-measure) in jsdom, which doesn't implement it natively.
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
|
||||
describe('Hero3D', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('renders the motif container', async () => {
|
||||
vi.doMock('framer-motion', async () => {
|
||||
const actual = await vi.importActual('framer-motion')
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => false,
|
||||
}
|
||||
})
|
||||
const { default: Hero3D } = await import('./Hero3D')
|
||||
render(<Hero3D />)
|
||||
expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders statically when reduced motion is preferred', async () => {
|
||||
vi.doMock('framer-motion', async () => {
|
||||
const actual = await vi.importActual('framer-motion')
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => true,
|
||||
}
|
||||
})
|
||||
const { default: Hero3D } = await import('./Hero3D')
|
||||
render(<Hero3D />)
|
||||
expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('hero-3d-motif').className).toMatch(/static/)
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { motion, useReducedMotion, useScroll, useTransform } from 'framer-motion'
|
||||
import styles from './Hero3D.module.css'
|
||||
|
||||
function Hero3D() {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const { scrollY } = useScroll()
|
||||
const rotateY = useTransform(scrollY, [0, 600], [0, 180])
|
||||
const rotateX = useTransform(scrollY, [0, 600], [0, 20])
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<div className={styles.wrapper} data-testid="hero-3d">
|
||||
<div className={styles.static} data-testid="hero-3d-motif" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-testid="hero-3d">
|
||||
<motion.div className={styles.motif} style={{ rotateY, rotateX }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hero3D
|
||||
@@ -1,18 +1,16 @@
|
||||
.wrapper {
|
||||
perspective: 900px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.motif {
|
||||
.wrapper canvas {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fallbackMotif {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #e6c98f, var(--color-gold));
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.static {
|
||||
composes: motif;
|
||||
}
|
||||
44
src/components/Hero3DScene.test.tsx
Normal file
44
src/components/Hero3DScene.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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: () => {},
|
||||
}))
|
||||
|
||||
describe('Hero3DScene', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the static fallback and no canvas when reduced motion is preferred', async () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('framer-motion', async () => {
|
||||
const actual = await vi.importActual('framer-motion')
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => 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()
|
||||
vi.doMock('framer-motion', async () => {
|
||||
const actual = await vi.importActual('framer-motion')
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => false,
|
||||
}
|
||||
})
|
||||
const { default: Hero3DScene } = await import('./Hero3DScene')
|
||||
render(<Hero3DScene />)
|
||||
expect(screen.getByTestId('hero-3d-scene')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('r3f-canvas')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
92
src/components/Hero3DScene.tsx
Normal file
92
src/components/Hero3DScene.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import Hero3D from '../components/Hero3D'
|
||||
import Hero3DScene from '../components/Hero3DScene'
|
||||
import ScrollReveal from '../components/ScrollReveal'
|
||||
import ServiceCard from '../components/ServiceCard'
|
||||
import { services } from '../data/services'
|
||||
@@ -18,7 +18,7 @@ function Home() {
|
||||
return (
|
||||
<div>
|
||||
<section className={styles.hero}>
|
||||
<Hero3D />
|
||||
<Hero3DScene />
|
||||
<h1 className={styles.headline}>Your hair, your crown</h1>
|
||||
<p className={styles.tagline}>Protective Styles by Mira</p>
|
||||
<div className={styles.ctas}>
|
||||
|
||||
@@ -10,6 +10,15 @@ class MockIntersectionObserver {
|
||||
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
|
||||
|
||||
// Mock ResizeObserver (required by @react-three/fiber's Canvas, via react-use-measure)
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
|
||||
|
||||
// Setup matchMedia mock for framer-motion's useReducedMotion
|
||||
// Make it writable so tests can override it
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
|
||||
Reference in New Issue
Block a user