From 12beaa990e98b6d97f9f4bf3779a446da3fca80a Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Mon, 3 Aug 2026 22:49:24 +0000 Subject: [PATCH] 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. --- src/components/Hero3D.test.tsx | 37 -------- src/components/Hero3D.tsx | 25 ----- ...ro3D.module.css => Hero3DScene.module.css} | 16 ++-- src/components/Hero3DScene.test.tsx | 44 +++++++++ src/components/Hero3DScene.tsx | 92 +++++++++++++++++++ src/pages/Home.tsx | 4 +- src/test/setup.ts | 9 ++ 7 files changed, 154 insertions(+), 73 deletions(-) delete mode 100644 src/components/Hero3D.test.tsx delete mode 100644 src/components/Hero3D.tsx rename src/components/{Hero3D.module.css => Hero3DScene.module.css} (57%) create mode 100644 src/components/Hero3DScene.test.tsx create mode 100644 src/components/Hero3DScene.tsx diff --git a/src/components/Hero3D.test.tsx b/src/components/Hero3D.test.tsx deleted file mode 100644 index 3fc6357..0000000 --- a/src/components/Hero3D.test.tsx +++ /dev/null @@ -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() - 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() - expect(screen.getByTestId('hero-3d')).toBeInTheDocument() - expect(screen.getByTestId('hero-3d-motif').className).toMatch(/static/) - }) -}) diff --git a/src/components/Hero3D.tsx b/src/components/Hero3D.tsx deleted file mode 100644 index e3ec881..0000000 --- a/src/components/Hero3D.tsx +++ /dev/null @@ -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 ( -
-
-
- ) - } - - return ( -
- -
- ) -} - -export default Hero3D diff --git a/src/components/Hero3D.module.css b/src/components/Hero3DScene.module.css similarity index 57% rename from src/components/Hero3D.module.css rename to src/components/Hero3DScene.module.css index f060c85..15c6986 100644 --- a/src/components/Hero3D.module.css +++ b/src/components/Hero3DScene.module.css @@ -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; } diff --git a/src/components/Hero3DScene.test.tsx b/src/components/Hero3DScene.test.tsx new file mode 100644 index 0000000..43feb55 --- /dev/null +++ b/src/components/Hero3DScene.test.tsx @@ -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 }) =>
{children}
, + 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() + 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() + expect(screen.getByTestId('hero-3d-scene')).toBeInTheDocument() + expect(screen.getByTestId('r3f-canvas')).toBeInTheDocument() + }) +}) diff --git a/src/components/Hero3DScene.tsx b/src/components/Hero3DScene.tsx new file mode 100644 index 0000000..258f034 --- /dev/null +++ b/src/components/Hero3DScene.tsx @@ -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 ( + + + + ) +} + +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 diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index fea9a47..9082d39 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 (
- +

Your hair, your crown

Protective Styles by Mira

diff --git a/src/test/setup.ts b/src/test/setup.ts index 5fa1320..c6c744b 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -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', {