diff --git a/app/components/About.tsx b/app/components/About.tsx
index 5b59183..701fbca 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -4,7 +4,7 @@ import { motion, type Variants } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
-import { LazyRevealImage } from "./animations/LazyRevealImage";
+import PortraitCyberScene from "./cinematic/PortraitCyberScene";
import { Section } from "./Section";
const STORY_DATA = [
@@ -132,20 +132,19 @@ export default function About() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
-
Technical profile
Base tecnica e postura de entrega
Sou engenheiro da computação e desenvolvedor de software full-stack,
- atuando ha 5 anos com foco em integrar camadas que normalmente
- ficam separadas: hardware, servicos, operacao e interface. Meu
- trabalho e dar forma clara ao que costuma nascer fragmentado.
+ atuando ha 5 anos com foco em integrar camadas que normalmente ficam
+ separadas: hardware, servicos, operacao e interface. Meu trabalho e
+ dar forma clara ao que costuma nascer fragmentado.
{PROFILE_POINTS.map((point) => (
diff --git a/app/components/cinematic/PortraitCyberScene.test.tsx b/app/components/cinematic/PortraitCyberScene.test.tsx
new file mode 100644
index 0000000..ddd015a
--- /dev/null
+++ b/app/components/cinematic/PortraitCyberScene.test.tsx
@@ -0,0 +1,112 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useReducedMotion } from "@/app/hooks/useReducedMotion";
+import {
+ PORTRAIT_CYBER_FEATURES,
+ PortraitCyberScene,
+} from "./PortraitCyberScene";
+
+jest.mock("@/app/hooks/useReducedMotion", () => ({
+ useReducedMotion: jest.fn(),
+}));
+
+jest.mock("../animations/LazyRevealImage", () => ({
+ LazyRevealImage: ({
+ alt,
+ className,
+ }: {
+ alt: string;
+ className?: string;
+ }) => (
+
+ ),
+}));
+
+jest.mock("@react-three/fiber", () => ({
+ Canvas: ({ children: _children, ...props }: Record
) => (
+
+ ),
+ useFrame: jest.fn(),
+}));
+
+const mockedUseReducedMotion = jest.mocked(useReducedMotion);
+
+describe("PortraitCyberScene", () => {
+ beforeEach(() => {
+ mockedUseReducedMotion.mockReturnValue(false);
+ });
+
+ it("keeps the accessible photo and mounts the cyber layers when visible", async () => {
+ render(
+ ,
+ );
+
+ const scene = screen.getByTestId("portrait-cyber");
+
+ await waitFor(() =>
+ expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
+ );
+ expect(
+ screen.getByRole("img", {
+ name: "Retrato profissional de Evandro Fonseca Junior",
+ }),
+ ).toBeInTheDocument();
+ expect(scene).toHaveAttribute(
+ "data-cyber-effects",
+ PORTRAIT_CYBER_FEATURES.join(" "),
+ );
+ expect(screen.getByTestId("portrait-cyber-scanlines")).toBeInTheDocument();
+ });
+
+ it("exposes the RGB split state only while the pointer is over the portrait", async () => {
+ render();
+
+ const scene = screen.getByTestId("portrait-cyber");
+ Object.defineProperty(scene, "getBoundingClientRect", {
+ configurable: true,
+ value: () => ({
+ bottom: 100,
+ height: 100,
+ left: 0,
+ right: 100,
+ top: 0,
+ width: 100,
+ x: 0,
+ y: 0,
+ }),
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
+ );
+ fireEvent.pointerMove(scene, { clientX: 80, clientY: 20 });
+ expect(scene).toHaveAttribute("data-cyber-state", "hovered");
+
+ fireEvent.pointerLeave(scene);
+ expect(scene).toHaveAttribute("data-cyber-state", "idle");
+ });
+
+ it("keeps the photo static while showing the cyber layers when reduced motion is preferred", async () => {
+ mockedUseReducedMotion.mockReturnValue(true);
+
+ render();
+
+ expect(screen.getByTestId("portrait-cyber")).toHaveAttribute(
+ "data-motion",
+ "reduced",
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
+ );
+ expect(
+ screen.queryByRole("img", { name: "Retrato profissional" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/app/components/cinematic/PortraitCyberScene.tsx b/app/components/cinematic/PortraitCyberScene.tsx
new file mode 100644
index 0000000..e92b392
--- /dev/null
+++ b/app/components/cinematic/PortraitCyberScene.tsx
@@ -0,0 +1,386 @@
+"use client";
+
+import { Canvas, useFrame } from "@react-three/fiber";
+import type { StaticImageData } from "next/image";
+import {
+ type PointerEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import * as THREE from "three";
+import { useReducedMotion } from "@/app/hooks/useReducedMotion";
+import { LazyRevealImage } from "../animations/LazyRevealImage";
+
+export const PORTRAIT_CYBER_FEATURES = [
+ "particles",
+ "contour",
+ "scanlines",
+ "rgb-split",
+] as const;
+
+type PointerPosition = {
+ x: number;
+ y: number;
+};
+type PointerReference = {
+ current: PointerPosition;
+};
+
+const CYBER_OUTLINE: readonly [number, number][] = [
+ [-0.78, -0.92],
+ [-0.96, -0.64],
+ [-0.9, -0.28],
+ [-0.68, 0.02],
+ [-0.48, 0.22],
+ [-0.42, 0.58],
+ [-0.25, 0.88],
+ [0, 1.02],
+ [0.24, 0.94],
+ [0.43, 0.62],
+ [0.48, 0.24],
+ [0.72, 0.02],
+ [0.94, -0.3],
+ [0.9, -0.66],
+ [0.75, -0.92],
+ [0.35, -0.78],
+ [0, -0.68],
+ [-0.38, -0.78],
+];
+
+function createContourGeometry(scale = 1.65) {
+ return new THREE.BufferGeometry().setFromPoints(
+ CYBER_OUTLINE.map(([x, y]) => new THREE.Vector3(x * scale, y * scale, 0)),
+ );
+}
+
+function ContourLines({
+ hovered,
+ animate,
+}: {
+ hovered: boolean;
+ animate: boolean;
+}) {
+ const groupRef = useRef(null);
+ const geometry = useMemo(() => createContourGeometry(), []);
+
+ useFrame((state) => {
+ if (!groupRef.current || !animate) return;
+ groupRef.current.rotation.z =
+ Math.sin(state.clock.elapsedTime * 0.35) * 0.008;
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function createParticlePositions(count: number, phase: number) {
+ const positions = new Float32Array(count * 3);
+
+ for (let index = 0; index < count; index += 1) {
+ const [x, y] = CYBER_OUTLINE[(index + phase) % CYBER_OUTLINE.length];
+ const jitterX = (((index * 17 + phase * 7) % 19) / 19 - 0.5) * 0.18;
+ const jitterY = (((index * 11 + phase * 5) % 17) / 17 - 0.5) * 0.18;
+ const radius = 1 + ((index * 13 + phase) % 9) / 26;
+ const index3 = index * 3;
+
+ positions[index3] = x * 1.65 * radius + jitterX;
+ positions[index3 + 1] = y * 1.65 * radius + jitterY;
+ positions[index3 + 2] = 0.24 + ((index + phase) % 7) * 0.025;
+ }
+
+ return positions;
+}
+
+function ParticleField({
+ animate,
+ color,
+ count,
+ phase,
+}: {
+ animate: boolean;
+ color: string;
+ count: number;
+ phase: number;
+}) {
+ const pointsRef = useRef(null);
+ const positions = useMemo(
+ () => createParticlePositions(count, phase),
+ [count, phase],
+ );
+
+ useFrame((state, delta) => {
+ if (!pointsRef.current || !animate) return;
+
+ const positionAttribute = pointsRef.current.geometry.getAttribute(
+ "position",
+ ) as THREE.BufferAttribute;
+ const values = positionAttribute.array as Float32Array;
+
+ for (let index = 0; index < count; index += 1) {
+ const index3 = index * 3;
+ values[index3 + 1] += (0.012 + (index % 4) * 0.002) * delta;
+ values[index3] +=
+ Math.sin(state.clock.elapsedTime * 0.55 + index + phase) * 0.0007;
+
+ if (values[index3 + 1] > 1.9) values[index3 + 1] = -1.9;
+ }
+
+ positionAttribute.needsUpdate = true;
+ });
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function CyberScene({
+ animate,
+ hovered,
+ pointerRef,
+}: {
+ animate: boolean;
+ hovered: boolean;
+ pointerRef: PointerReference;
+}) {
+ const groupRef = useRef(null);
+
+ useFrame((_, delta) => {
+ if (!groupRef.current) return;
+
+ const targetX = animate ? pointerRef.current.x * 0.09 : 0;
+ const targetY = animate ? pointerRef.current.y * 0.06 : 0;
+ groupRef.current.position.x = THREE.MathUtils.damp(
+ groupRef.current.position.x,
+ targetX,
+ 5,
+ delta,
+ );
+ groupRef.current.position.y = THREE.MathUtils.damp(
+ groupRef.current.position.y,
+ targetY,
+ 5,
+ delta,
+ );
+ groupRef.current.rotation.y = THREE.MathUtils.damp(
+ groupRef.current.rotation.y,
+ targetX * 0.4,
+ 5,
+ delta,
+ );
+ });
+
+ return (
+
+
+
+
+
+ );
+}
+
+export interface PortraitCyberSceneProps {
+ src: string | StaticImageData;
+ alt: string;
+ className?: string;
+}
+
+export function PortraitCyberScene({
+ alt,
+ className = "",
+ src,
+}: Readonly) {
+ const shouldReduceMotion = useReducedMotion();
+ const containerRef = useRef(null);
+ const pointerRef = useRef({ x: 0, y: 0 });
+ const [isVisible, setIsVisible] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const [pointerPosition, setPointerPosition] = useState({
+ x: 0,
+ y: 0,
+ });
+ const [isCoarsePointer, setIsCoarsePointer] = useState(false);
+
+ useEffect(() => {
+ const element = containerRef.current;
+ if (!element) return;
+
+ if (typeof IntersectionObserver === "undefined") {
+ setIsVisible(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (!entry?.isIntersecting) return;
+ setIsVisible(true);
+ observer.disconnect();
+ },
+ { rootMargin: "120px" },
+ );
+
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia("(pointer: coarse)");
+ const updatePointerMode = () => setIsCoarsePointer(mediaQuery.matches);
+
+ updatePointerMode();
+ mediaQuery.addEventListener?.("change", updatePointerMode);
+ return () => mediaQuery.removeEventListener?.("change", updatePointerMode);
+ }, []);
+
+ const handlePointerMove = useCallback(
+ (event: PointerEvent) => {
+ if (shouldReduceMotion || isCoarsePointer) return;
+
+ const rect = event.currentTarget.getBoundingClientRect();
+ const width = rect.width || 1;
+ const height = rect.height || 1;
+ const position = {
+ x: ((event.clientX - rect.left) / width) * 2 - 1,
+ y: -(((event.clientY - rect.top) / height) * 2 - 1),
+ };
+
+ pointerRef.current = position;
+ setPointerPosition(position);
+ setIsHovered(true);
+ },
+ [isCoarsePointer, shouldReduceMotion],
+ );
+
+ const handlePointerLeave = useCallback(() => {
+ pointerRef.current = { x: 0, y: 0 };
+ setPointerPosition({ x: 0, y: 0 });
+ setIsHovered(false);
+ }, []);
+
+ const imageTransform = shouldReduceMotion
+ ? undefined
+ : {
+ transform: `translate3d(${pointerPosition.x * 3}px, ${
+ pointerPosition.y * 3
+ }px, 0) scale(1.012)`,
+ transition: isHovered
+ ? "transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
+ : "transform 420ms cubic-bezier(0.16, 1, 0.3, 1)",
+ };
+ const motionMode = shouldReduceMotion
+ ? "reduced"
+ : isCoarsePointer
+ ? "static"
+ : "full";
+ const sceneClassName = [
+ "relative aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]",
+ className,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+
+
+
+
+
+
+ {isVisible && (
+
+ )}
+
+
+
+ );
+}
+
+export default PortraitCyberScene;