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 <[email protected]>
This commit is contained in:
1 parent
0ec0194441
commit
df1c1888da
10 files changed
+262
-254
No files matched your search
@@ -1,103 +1,13 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import FilmGrain from "./FilmGrain";
|
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", () => {
|
describe("static cinematic effects", () => {
|
||||||
beforeEach(() => mockReducedMotion(false));
|
it("renders grain without a canvas", () => {
|
||||||
|
|
||||||
it("renders grain and every divider without a canvas", () => {
|
|
||||||
const grain = render(<FilmGrain />);
|
const grain = render(<FilmGrain />);
|
||||||
expect(grain.container.firstElementChild).toHaveAttribute(
|
expect(grain.container.firstElementChild).toHaveAttribute(
|
||||||
"aria-hidden",
|
"aria-hidden",
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||||
grain.unmount();
|
|
||||||
|
|
||||||
for (const variant of ["gradient", "pulse", "waveform"] as const) {
|
|
||||||
const divider = render(<SectionDivider variant={variant} />);
|
|
||||||
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(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
|
|
||||||
|
|
||||||
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(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -16,9 +16,9 @@ import Projects from "../Projects";
|
|||||||
import Skills from "../Skills";
|
import Skills from "../Skills";
|
||||||
import NotebookStory from "../story/NotebookStory";
|
import NotebookStory from "../story/NotebookStory";
|
||||||
import { WorkModalities } from "../WorkModalities";
|
import { WorkModalities } from "../WorkModalities";
|
||||||
|
import { useRef } from "react";
|
||||||
import ExperienceDisclosure from "./ExperienceDisclosure";
|
import ExperienceDisclosure from "./ExperienceDisclosure";
|
||||||
import ScrollVelocityText from "./ScrollVelocityText";
|
import { useCinematicScroll } from "./useCinematicScroll";
|
||||||
import SectionDivider from "./SectionDivider";
|
|
||||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||||
|
|
||||||
export default function CinematicHome({
|
export default function CinematicHome({
|
||||||
@@ -33,44 +33,39 @@ export default function CinematicHome({
|
|||||||
skillCategories: PortfolioSkillCategory[];
|
skillCategories: PortfolioSkillCategory[];
|
||||||
}) {
|
}) {
|
||||||
const onLayoutReady = useHomeFragmentNavigation();
|
const onLayoutReady = useHomeFragmentNavigation();
|
||||||
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
|
useCinematicScroll(mainRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||||
<main className="ds-main">
|
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
|
||||||
|
<main ref={mainRef} className="ds-main relative z-0">
|
||||||
<Hero stats={heroStats} />
|
<Hero stats={heroStats} />
|
||||||
|
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
|
||||||
<ScrollVelocityText text="SOFTWARE SOB MEDIDA" direction="left" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
||||||
|
|
||||||
<Projects projects={projects} />
|
<Projects projects={projects} />
|
||||||
|
|
||||||
<SectionDivider variant="waveform" />
|
|
||||||
|
|
||||||
<WorkModalities />
|
<WorkModalities />
|
||||||
|
|
||||||
<EngineeringWithAI />
|
<EngineeringWithAI />
|
||||||
|
|
||||||
<LighthouseMetrics />
|
<LighthouseMetrics />
|
||||||
|
|
||||||
<SectionDivider variant="pulse" />
|
<div className="bg-[var(--paper)]">
|
||||||
|
<About />
|
||||||
|
|
||||||
<About />
|
<ExperienceDisclosure>
|
||||||
|
<Skills categories={skillCategories} />
|
||||||
<ExperienceDisclosure>
|
<Experience experiences={experiences} />
|
||||||
<Skills categories={skillCategories} />
|
</ExperienceDisclosure>
|
||||||
<Experience experiences={experiences} />
|
|
||||||
</ExperienceDisclosure>
|
|
||||||
|
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
|
||||||
<ScrollVelocityText text="VAMOS CONSTRUIR" direction="left" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Contact />
|
<Contact />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<div className="relative z-10">
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ function Field({ progressRef, onReady, onError }: HeroFieldSceneProps) {
|
|||||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||||
u.uScroll.value = p;
|
u.uScroll.value = p;
|
||||||
u.uSpread.value = 1.05 + 1.3 * smooth(0, 0.55, 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 =
|
u.uScale.value =
|
||||||
(gl.getPixelRatio() * size.height * 0.5) /
|
(gl.getPixelRatio() * size.height * 0.5) /
|
||||||
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className={`scroll-velocity-text ${className}`.trim()}>
|
|
||||||
<div className="scroll-velocity-viewport">
|
|
||||||
<div
|
|
||||||
id={trackId}
|
|
||||||
className={`scroll-velocity-track ${
|
|
||||||
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
animationPlayState: animationPaused ? "paused" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{GROUPS.map((group) => (
|
|
||||||
<span
|
|
||||||
key={group}
|
|
||||||
className="scroll-velocity-group"
|
|
||||||
aria-hidden={group === 0 ? undefined : "true"}
|
|
||||||
>
|
|
||||||
<span className="scroll-velocity-item">
|
|
||||||
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
|
|
||||||
<span aria-hidden="true" className="scroll-velocity-dot">
|
|
||||||
•
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!shouldReduceMotion ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="scroll-velocity-control"
|
|
||||||
aria-controls={trackId}
|
|
||||||
aria-pressed={isPaused}
|
|
||||||
onClick={() => setIsPaused((paused) => !paused)}
|
|
||||||
>
|
|
||||||
{isPaused ? "Retomar animação" : "Pausar animação"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div aria-hidden="true" className={rootClassName}>
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variant === "pulse") {
|
|
||||||
return (
|
|
||||||
<div aria-hidden="true" className={rootClassName}>
|
|
||||||
{PULSE_DOTS.map((dot) => (
|
|
||||||
<span key={dot} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div aria-hidden="true" className={rootClassName}>
|
|
||||||
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
|
|
||||||
<title>Divisor decorativo</title>
|
|
||||||
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
|
|
||||||
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
|
|
||||||
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<HTMLElement | null>) {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
|||||||
import NotebookFallback from "./NotebookFallback";
|
import NotebookFallback from "./NotebookFallback";
|
||||||
import { selectNotebookProjects } from "./notebookProjects";
|
import { selectNotebookProjects } from "./notebookProjects";
|
||||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||||
|
import { pageTop } from "../cinematic/useCinematicScroll";
|
||||||
|
|
||||||
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -149,8 +150,10 @@ export default function NotebookStory({
|
|||||||
onUpdate: applyProgress,
|
onUpdate: applyProgress,
|
||||||
scrollTrigger: {
|
scrollTrigger: {
|
||||||
trigger: sectionRoot,
|
trigger: sectionRoot,
|
||||||
start: "top top",
|
// medidas pelo layout: as transições da home deslocam a seção com transform
|
||||||
end: "bottom bottom",
|
start: () => pageTop(sectionRoot),
|
||||||
|
end: () =>
|
||||||
|
pageTop(sectionRoot) + sectionRoot.offsetHeight - window.innerHeight,
|
||||||
scrub: true,
|
scrub: true,
|
||||||
invalidateOnRefresh: true,
|
invalidateOnRefresh: true,
|
||||||
onRefresh: () => layoutReadyRef.current?.(),
|
onRefresh: () => layoutReadyRef.current?.(),
|
||||||
|
|||||||
+8
-19
@@ -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.
|
/* Transições entre seções: app/components/cinematic/useCinematicScroll.ts (GSAP + Lenis).
|
||||||
CSS puro com linha do tempo de rolagem (Chrome, Edge, Safari 26+); sem suporte, a seção só aparece. */
|
O fundo escuro aparece nas frestas enquanto uma seção entra sobre a outra. */
|
||||||
.ds-main {
|
.ds-main {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes section-rise {
|
/* Lenis controla a suavidade da rolagem: o scroll-behavior nativo brigaria com ele */
|
||||||
from {
|
html.lenis,
|
||||||
clip-path: inset(8vh 4vw 0 4vw round 28px);
|
html.lenis body {
|
||||||
translate: 0 12vh;
|
height: auto;
|
||||||
}
|
|
||||||
to {
|
|
||||||
clip-path: inset(0 0 0 0 round 0);
|
|
||||||
translate: 0 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (animation-timeline: view()) {
|
html.lenis {
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
scroll-behavior: auto;
|
||||||
.ds-main > section:not(.hero, .notebook-story) {
|
|
||||||
animation: section-rise linear both;
|
|
||||||
animation-timeline: view();
|
|
||||||
animation-range: entry 0% entry 85vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@react-three/fiber": "9.5.0",
|
"@react-three/fiber": "9.5.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"gsap": "3.13.0",
|
"gsap": "3.13.0",
|
||||||
|
"lenis": "^1.3.26",
|
||||||
"lucide-react": "0.542.0",
|
"lucide-react": "0.542.0",
|
||||||
"motion": "12.23.12",
|
"motion": "12.23.12",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
|
|||||||
Generated
+21
@@ -20,6 +20,9 @@ importers:
|
|||||||
gsap:
|
gsap:
|
||||||
specifier: 3.13.0
|
specifier: 3.13.0
|
||||||
version: 3.13.0
|
version: 3.13.0
|
||||||
|
lenis:
|
||||||
|
specifier: ^1.3.26
|
||||||
|
version: 1.3.26([email protected])
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: 0.542.0
|
specifier: 0.542.0
|
||||||
version: 0.542.0([email protected])
|
version: 0.542.0([email protected])
|
||||||
@@ -1886,6 +1889,20 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
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
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -4762,6 +4779,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
Reference in new issue
Block a user