From df1c1888da67d6d85ef8eadcff5ae783e408d77c Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Mon, 5 Oct 2026 00:32:32 -0300 Subject: [PATCH] feat(home): rolagem suave (Lenis) e seis transicoes de secao ligadas a rolagem Cada passagem segura a secao que sai e a que entra na tela enquanto a transicao acontece: dissolucao do hero no notebook, cartao, iris, corte lateral, perspectiva, cortina diagonal e zoom. Medidas pelo layout (offsetTop) para nao confundir os gatilhos com os transforms. Remove letreiros e divisores entre secoes. Co-Authored-By: Claude Opus 5.5 --- .../cinematic/CinematicEffects.test.tsx | 94 +------- app/components/cinematic/CinematicHome.tsx | 35 ++- app/components/cinematic/HeroFieldScene.tsx | 2 +- .../cinematic/ScrollVelocityText.tsx | 76 ------- app/components/cinematic/SectionDivider.tsx | 44 ---- .../cinematic/useCinematicScroll.ts | 209 ++++++++++++++++++ app/components/story/NotebookStory.tsx | 7 +- app/globals.css | 27 +-- package.json | 1 + pnpm-lock.yaml | 21 ++ 10 files changed, 262 insertions(+), 254 deletions(-) delete mode 100644 app/components/cinematic/ScrollVelocityText.tsx delete mode 100644 app/components/cinematic/SectionDivider.tsx create mode 100644 app/components/cinematic/useCinematicScroll.ts diff --git a/app/components/cinematic/CinematicEffects.test.tsx b/app/components/cinematic/CinematicEffects.test.tsx index 3360dad..671231e 100644 --- a/app/components/cinematic/CinematicEffects.test.tsx +++ b/app/components/cinematic/CinematicEffects.test.tsx @@ -1,103 +1,13 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { render } from "@testing-library/react"; import FilmGrain from "./FilmGrain"; -import ScrollVelocityText from "./ScrollVelocityText"; -import SectionDivider from "./SectionDivider"; - -function mockReducedMotion(matches: boolean) { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: jest.fn().mockImplementation((query: string) => ({ - matches: query.includes("prefers-reduced-motion") ? matches : false, - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); -} describe("static cinematic effects", () => { - beforeEach(() => mockReducedMotion(false)); - - it("renders grain and every divider without a canvas", () => { + it("renders grain without a canvas", () => { const grain = render(); expect(grain.container.firstElementChild).toHaveAttribute( "aria-hidden", "true", ); expect(grain.container.querySelector("canvas")).not.toBeInTheDocument(); - grain.unmount(); - - for (const variant of ["gradient", "pulse", "waveform"] as const) { - const divider = render(); - expect(divider.container.firstElementChild).toHaveAttribute( - "aria-hidden", - "true", - ); - expect(divider.container.querySelector("canvas")).not.toBeInTheDocument(); - expect(divider.container.firstElementChild).not.toHaveAttribute( - "tabindex", - ); - divider.unmount(); - } - }); - - it("exposes a persistent pause control for an automatic marquee", () => { - render(); - - const pause = screen.getByRole("button", { name: "Pausar animação" }); - const track = document.getElementById( - pause.getAttribute("aria-controls") ?? "", - ); - expect(pause).toHaveAttribute("aria-pressed", "false"); - expect(track).not.toHaveStyle({ animationPlayState: "paused" }); - expect( - screen.getByText("SOFTWARE ENGINEERING", { - selector: '[aria-hidden="false"]', - }), - ).toBeVisible(); - expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2); - expect( - screen - .getAllByText("SOFTWARE ENGINEERING") - .slice(1) - .every((copy) => copy.getAttribute("aria-hidden") === "true"), - ).toBe(true); - - fireEvent.click(pause); - - const resume = screen.getByRole("button", { name: "Retomar animação" }); - expect(resume).toHaveAttribute("aria-pressed", "true"); - expect(track).toHaveStyle({ animationPlayState: "paused" }); - - fireEvent.click(resume); - expect( - screen.getByRole("button", { name: "Pausar animação" }), - ).toHaveAttribute("aria-pressed", "false"); - expect(track).not.toHaveStyle({ animationPlayState: "paused" }); - }); - - it("starts static under reduced motion without a redundant control", () => { - mockReducedMotion(true); - render(); - - expect( - screen.queryByRole("button", { name: /animação/i }), - ).not.toBeInTheDocument(); - const copies = screen.getAllByText("VAMOS CONVERSAR"); - expect(copies).toHaveLength(2); - expect(copies[0]).toBeVisible(); - expect(copies[0]).toHaveAttribute("aria-hidden", "false"); - expect(copies[0].closest("[style]")).toHaveStyle({ - animationPlayState: "paused", - }); - expect( - copies - .slice(1) - .every((copy) => copy.getAttribute("aria-hidden") === "true"), - ).toBe(true); }); }); diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx index 6492b17..f367b40 100644 --- a/app/components/cinematic/CinematicHome.tsx +++ b/app/components/cinematic/CinematicHome.tsx @@ -16,9 +16,9 @@ import Projects from "../Projects"; import Skills from "../Skills"; import NotebookStory from "../story/NotebookStory"; import { WorkModalities } from "../WorkModalities"; +import { useRef } from "react"; import ExperienceDisclosure from "./ExperienceDisclosure"; -import ScrollVelocityText from "./ScrollVelocityText"; -import SectionDivider from "./SectionDivider"; +import { useCinematicScroll } from "./useCinematicScroll"; import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation"; export default function CinematicHome({ @@ -33,44 +33,39 @@ export default function CinematicHome({ skillCategories: PortfolioSkillCategory[]; }) { const onLayoutReady = useHomeFragmentNavigation(); + const mainRef = useRef(null); + useCinematicScroll(mainRef); return (
-
+ {/* Cada filho direto de
é uma cena; useCinematicScroll liga as passagens entre elas. */} +
-
- -
- - - - +
+ - - - - - - - -
- + + + +
-
+
+
+
); } diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx index d0e0e47..179a5eb 100644 --- a/app/components/cinematic/HeroFieldScene.tsx +++ b/app/components/cinematic/HeroFieldScene.tsx @@ -253,7 +253,7 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) { u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6); u.uScroll.value = p; u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, p); - u.uFade.value = 1 - smooth(0.86, 1, p); + u.uFade.value = 1 - smooth(0.94, 1, p); u.uScale.value = (gl.getPixelRatio() * size.height * 0.5) / Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2)); diff --git a/app/components/cinematic/ScrollVelocityText.tsx b/app/components/cinematic/ScrollVelocityText.tsx deleted file mode 100644 index 43e78a0..0000000 --- a/app/components/cinematic/ScrollVelocityText.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { useEffect, useId, useState } from "react"; - -interface ScrollVelocityTextProps { - text: string; - direction: "left" | "right"; - className?: string; -} - -const GROUPS = [0, 1] as const; - -export default function ScrollVelocityText({ - text, - direction, - className = "", -}: ScrollVelocityTextProps) { - const trackId = useId(); - const [isPaused, setIsPaused] = useState(false); - const [shouldReduceMotion, setShouldReduceMotion] = useState(false); - - useEffect(() => { - const media = window.matchMedia("(prefers-reduced-motion: reduce)"); - setShouldReduceMotion(media.matches); - const updatePreference = (event: MediaQueryListEvent) => { - setShouldReduceMotion(event.matches); - }; - media.addEventListener("change", updatePreference); - return () => media.removeEventListener("change", updatePreference); - }, []); - - const animationPaused = shouldReduceMotion || isPaused; - - return ( -
-
-
- {GROUPS.map((group) => ( - - - {text} - - - - ))} -
-
- - {!shouldReduceMotion ? ( - - ) : null} -
- ); -} diff --git a/app/components/cinematic/SectionDivider.tsx b/app/components/cinematic/SectionDivider.tsx deleted file mode 100644 index 2d77c7d..0000000 --- a/app/components/cinematic/SectionDivider.tsx +++ /dev/null @@ -1,44 +0,0 @@ -interface SectionDividerProps { - variant?: "gradient" | "pulse" | "waveform"; - className?: string; -} - -const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const; - -export default function SectionDivider({ - variant = "gradient", - className = "", -}: SectionDividerProps) { - const rootClassName = - `section-divider section-divider-${variant} ${className}`.trim(); - - if (variant === "gradient") { - return ( - - ); - } - - if (variant === "pulse") { - return ( - - ); - } - - return ( - - ); -} diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts new file mode 100644 index 0000000..e6f1927 --- /dev/null +++ b/app/components/cinematic/useCinematicScroll.ts @@ -0,0 +1,209 @@ +"use client"; + +import { type RefObject, useEffect } from "react"; + +type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => void; + +/* + * Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição + * da rolagem (rolar para cima volta o "vídeo"). + * Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada + * para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada + * "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais. + * Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh). + */ + +const vh = () => window.innerHeight; + +/** Topo da seção na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ +export function pageTop(el: HTMLElement) { + let top = 0; + for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) + top += node.offsetTop; + return top; +} +// origem no meio da parte visível da seção que sai (a última altura de tela dela) +const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`; + +// a seção que sai fica parada na tela enquanto a próxima entra +const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) => + tl.fromTo(prev, { y: 0 }, { y: vh }, 0); +// a seção que entra também fica parada no topo: a transição acontece "no lugar" +const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) => + tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0); + +// uma linguagem diferente por passagem, na ordem das seções da home +const TRANSITIONS: Transition[] = [ + // hero → notebook: o mergulho termina no escuro e o notebook assenta vindo de perto + (tl, prev, next) => { + holdPrev(tl, prev); + holdNext(tl, next); + // o hero some antes de o notebook aparecer: sem texto sobre texto (no celular o hero não esmaece antes) + tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo( + next.querySelector(".notebook-story-sticky") ?? next, + { scale: 1.22, opacity: 0 }, + { scale: 1, opacity: 1, duration: 0.65 }, + 0.35, + ); + }, + // notebook → projetos: a seção sobe como um cartão e o notebook recua para o fundo + (tl, prev, next) => { + holdPrev(tl, prev); + tl.fromTo( + prev, + { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, + { scale: 0.86, opacity: 0.15 }, + 0, + ).fromTo( + next, + { clipPath: "inset(0% 5% 0% 5% round 40px 40px 0px 0px)" }, + { clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" }, + 0, + ); + }, + // projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera + (tl, prev, next) => { + holdPrev(tl, prev); + holdNext(tl, next); + tl.fromTo( + prev, + { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, + { scale: 1.18, opacity: 0.2 }, + 0, + ) + .fromTo( + next, + { clipPath: () => `circle(0px at 50% ${vh() / 2}px)` }, + { + clipPath: () => + `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px at 50% ${vh() / 2}px)`, + duration: 0.96, + }, + 0, + ) + .set(next, { clipPath: "none" }, 1); + }, + // modalidades → IA: corte lateral, uma tela empurra a outra + (tl, prev, next) => { + holdPrev(tl, prev); + holdNext(tl, next); + tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo( + next, + { xPercent: 100 }, + { xPercent: 0 }, + 0, + ); + }, + // IA → qualidade: a próxima tomba para a frente, em perspectiva + (tl, prev, next) => { + holdPrev(tl, prev); + tl.fromTo( + prev, + { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, + { scale: 0.9, opacity: 0.25 }, + 0, + ).fromTo( + next, + { rotationX: 28, scale: 0.88, transformPerspective: 1600, transformOrigin: "50% 0%" }, + { rotationX: 0, scale: 1 }, + 0, + ); + }, + // qualidade → sobre: cortina diagonal + (tl, prev, next) => { + holdPrev(tl, prev); + holdNext(tl, next); + tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0) + .fromTo( + next, + { clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` }, + { + clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)`, + duration: 0.96, + }, + 0, + ) + .set(next, { clipPath: "none" }, 1); + }, + // sobre → contato: o contato cresce do centro, como um zoom de câmera + (tl, prev, next) => { + holdPrev(tl, prev); + holdNext(tl, next); + tl.fromTo( + prev, + { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, + { scale: 0.92, opacity: 0.2 }, + 0, + ).fromTo( + next, + { + scale: 0.55, + clipPath: "inset(0% round 56px)", + transformOrigin: () => `50% ${vh() / 2}px`, + }, + { scale: 1, clipPath: "inset(0% round 0px)" }, + 0, + ).fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0); + }, +]; + +export function useCinematicScroll(mainRef: RefObject) { + useEffect(() => { + const main = mainRef.current; + if (!main) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + let cancelled = false; + let cleanup = () => {}; + + (async () => { + const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([ + import("gsap"), + import("gsap/ScrollTrigger"), + import("lenis"), + ]); + if (cancelled) return; + gsap.registerPlugin(ScrollTrigger); + + const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 }); + lenis.on("scroll", ScrollTrigger.update); + const tick = (time: number) => lenis.raf(time * 1000); + gsap.ticker.add(tick); + gsap.ticker.lagSmoothing(0); + + const scenes = Array.from(main.children) as HTMLElement[]; + const context = gsap.context(() => { + scenes.forEach((scene, index) => { + // quem entra fica por cima; o hero, que sai por dissolução, fica por cima do notebook + gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 }); + }); + TRANSITIONS.forEach((transition, index) => { + const prev = scenes[index]; + const next = scenes[index + 1]; + if (!prev || !next) return; + const tl = gsap.timeline({ + defaults: { ease: "none", duration: 1 }, + scrollTrigger: { + trigger: next, + start: () => pageTop(next) - vh(), + end: () => pageTop(next), + scrub: true, + invalidateOnRefresh: true, + }, + }); + transition(tl, prev, next); + }); + }, main); + + cleanup = () => { + context.revert(); + gsap.ticker.remove(tick); + lenis.destroy(); + }; + })(); + + return () => { + cancelled = true; + cleanup(); + }; + }, [mainRef]); +} diff --git a/app/components/story/NotebookStory.tsx b/app/components/story/NotebookStory.tsx index a597c70..e555062 100644 --- a/app/components/story/NotebookStory.tsx +++ b/app/components/story/NotebookStory.tsx @@ -7,6 +7,7 @@ import type { PortfolioProjectView } from "@/lib/portfolio-data"; import NotebookFallback from "./NotebookFallback"; import { selectNotebookProjects } from "./notebookProjects"; import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline"; +import { pageTop } from "../cinematic/useCinematicScroll"; const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), { ssr: false, @@ -149,8 +150,10 @@ export default function NotebookStory({ onUpdate: applyProgress, scrollTrigger: { trigger: sectionRoot, - start: "top top", - end: "bottom bottom", + // medidas pelo layout: as transições da home deslocam a seção com transform + start: () => pageTop(sectionRoot), + end: () => + pageTop(sectionRoot) + sectionRoot.offsetHeight - window.innerHeight, scrub: true, invalidateOnRefresh: true, onRefresh: () => layoutReadyRef.current?.(), diff --git a/app/globals.css b/app/globals.css index 111c05c..48fa3a4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1130,31 +1130,20 @@ textarea { } } -/* Transição entre seções: cada seção sobe como um cartão e abre até a borda da tela. - CSS puro com linha do tempo de rolagem (Chrome, Edge, Safari 26+); sem suporte, a seção só aparece. */ +/* Transições entre seções: app/components/cinematic/useCinematicScroll.ts (GSAP + Lenis). + O fundo escuro aparece nas frestas enquanto uma seção entra sobre a outra. */ .ds-main { background: var(--ink); } -@keyframes section-rise { - from { - clip-path: inset(8vh 4vw 0 4vw round 28px); - translate: 0 12vh; - } - to { - clip-path: inset(0 0 0 0 round 0); - translate: 0 0; - } +/* Lenis controla a suavidade da rolagem: o scroll-behavior nativo brigaria com ele */ +html.lenis, +html.lenis body { + height: auto; } -@supports (animation-timeline: view()) { - @media (prefers-reduced-motion: no-preference) { - .ds-main > section:not(.hero, .notebook-story) { - animation: section-rise linear both; - animation-timeline: view(); - animation-range: entry 0% entry 85vh; - } - } +html.lenis { + scroll-behavior: auto; } /* Capa dos cards de projeto: captura real ou painel gerado em codigo */ diff --git a/package.json b/package.json index 9775722..502c139 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@react-three/fiber": "9.5.0", "clsx": "2.1.1", "gsap": "3.13.0", + "lenis": "^1.3.26", "lucide-react": "0.542.0", "motion": "12.23.12", "next": "16.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01569fb..6efd97b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: gsap: specifier: 3.13.0 version: 3.13.0 + lenis: + specifier: ^1.3.26 + version: 1.3.26(react@19.1.0) lucide-react: specifier: 0.542.0 version: 0.542.0(react@19.1.0) @@ -1886,6 +1889,20 @@ packages: engines: {node: '>=6'} hasBin: true + lenis@1.3.26: + resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + react: '>=17.0.0' + vue: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + react: + optional: true + vue: + optional: true + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4762,6 +4779,10 @@ snapshots: json5@2.2.3: {} + lenis@1.3.26(react@19.1.0): + optionalDependencies: + react: 19.1.0 + leven@3.1.0: {} lie@3.3.0: