diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx
index 852e539..7d871dd 100644
--- a/app/components/ProjectCover.tsx
+++ b/app/components/ProjectCover.tsx
@@ -6,7 +6,6 @@ type CoverProject = {
id: string;
title: string;
demoUrl?: string | null;
- githubUrl?: string | null;
};
/** Capturas de tela publicadas dos projetos que têm interface pública. */
diff --git a/app/components/ProjectVideo.paths.test.tsx b/app/components/ProjectVideo.paths.test.tsx
new file mode 100644
index 0000000..1650cb9
--- /dev/null
+++ b/app/components/ProjectVideo.paths.test.tsx
@@ -0,0 +1,193 @@
+import { render, screen } from "@testing-library/react";
+import { getShowcaseAssets } from "@/lib/project-showcase";
+import { ProjectVideo } from "./ProjectVideo";
+
+/**
+ * Testes de caminhos-base de ProjectVideo (complemento de ProjectVideo.test.tsx): modo só pôster
+ * (movimento reduzido ou "economizar dados") com e sem pôster pedido, inativo x ativo, vídeo sem fontes,
+ * falha do play() e a combinação de ativo com modo só pôster.
+ */
+
+let reducedMotion = false;
+jest.mock("@/app/hooks/useReducedMotion", () => ({
+ useReducedMotion: () => reducedMotion,
+}));
+
+const video = getShowcaseAssets("nutriscan", "desktop");
+
+function setSaveData(value: boolean | undefined) {
+ Object.defineProperty(navigator, "connection", {
+ configurable: true,
+ value: value === undefined ? undefined : { saveData: value },
+ });
+}
+
+beforeEach(() => {
+ reducedMotion = false;
+ setSaveData(undefined);
+ jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
+ jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+ setSaveData(undefined);
+});
+
+describe("ProjectVideo: modo só pôster", () => {
+ it.each([
+ [
+ "reduced motion",
+ () => {
+ reducedMotion = true;
+ },
+ ],
+ ["the data saver", () => setSaveData(true)],
+ ])(
+ "path: %s and the poster is not wanted yet -> renders nothing at all",
+ (_l, arrange) => {
+ arrange();
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ },
+ );
+
+ it.each([
+ [
+ "reduced motion",
+ () => {
+ reducedMotion = true;
+ },
+ ],
+ ["the data saver", () => setSaveData(true)],
+ ])(
+ "path: %s and the poster is wanted -> only the image, with the project name in its alt",
+ (_l, arrange) => {
+ arrange();
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("video")).toBeNull();
+ const image = screen.getByRole("img", {
+ name: "Captura de tela de Demo",
+ });
+ expect(image).toHaveAttribute("width", String(video.width));
+ expect(image).toHaveAttribute("height", String(video.height));
+ },
+ );
+
+ it("path: data saver off and motion allowed -> the video element, never the image", () => {
+ setSaveData(false);
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("video")).not.toBeNull();
+ expect(screen.queryByRole("img")).toBeNull();
+ });
+
+ it("path: active card but poster-only mode -> nothing is attached and nothing plays", () => {
+ reducedMotion = true;
+
+ render();
+
+ expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
+ expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
+ });
+});
+
+describe("ProjectVideo: vídeo", () => {
+ it("path: far card (poster not wanted) -> the video has no poster attribute", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("video")).not.toHaveAttribute("poster");
+ });
+
+ it("path: inactive and never activated -> no source, no load, no play, no pause", () => {
+ render();
+
+ expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
+ expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
+ expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
+ });
+
+ it("path: becomes active -> muted is forced on the element before play()", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const element = container.querySelector("video") as HTMLVideoElement;
+ element.muted = false;
+ let mutedAtPlay: boolean | undefined;
+ jest.mocked(HTMLMediaElement.prototype.play).mockImplementation(function (
+ this: HTMLMediaElement,
+ ) {
+ mutedAtPlay = this.muted;
+ return Promise.resolve();
+ });
+
+ rerender();
+
+ expect(mutedAtPlay).toBe(true);
+ });
+
+ it("path: play() is rejected by the browser -> swallowed, the video stays mounted", async () => {
+ jest
+ .mocked(HTMLMediaElement.prototype.play)
+ .mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
+
+ const { container } = render(
+ ,
+ );
+ await Promise.resolve();
+
+ expect(container.querySelector("video")).not.toBeNull();
+ });
+
+ it("path: no sources in the assets -> becomes active without calling load()", () => {
+ const empty = { ...video, sources: [] };
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelectorAll("source")).toHaveLength(0);
+ expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
+ expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
+ });
+
+ it("path: active -> inactive -> active again -> plays again without reloading the sources", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const loadsAfterFirst = jest.mocked(HTMLMediaElement.prototype.load).mock
+ .calls.length;
+ const playsAfterFirst = jest.mocked(HTMLMediaElement.prototype.play).mock
+ .calls.length;
+
+ rerender();
+ rerender();
+
+ expect(jest.mocked(HTMLMediaElement.prototype.load).mock.calls.length).toBe(
+ loadsAfterFirst,
+ );
+ expect(jest.mocked(HTMLMediaElement.prototype.play).mock.calls.length).toBe(
+ playsAfterFirst + 1,
+ );
+ expect(container.querySelectorAll("source")).toHaveLength(2);
+ });
+});
diff --git a/app/components/ProjectVideo.test.tsx b/app/components/ProjectVideo.test.tsx
index da1f834..5d90b31 100644
--- a/app/components/ProjectVideo.test.tsx
+++ b/app/components/ProjectVideo.test.tsx
@@ -18,6 +18,10 @@ beforeEach(() => {
afterEach(() => {
jest.restoreAllMocks();
+ Object.defineProperty(navigator, "connection", {
+ configurable: true,
+ value: undefined,
+ });
});
describe("ProjectVideo", () => {
@@ -31,7 +35,7 @@ describe("ProjectVideo", () => {
expect(element).toHaveAttribute("autoplay");
expect(element).toHaveAttribute("loop");
expect(element).toHaveAttribute("playsinline");
- expect(element).toHaveAttribute("preload", "none");
+ expect(element).toHaveAttribute("preload", "metadata");
expect(element).toHaveAttribute("poster", video.poster);
expect(element?.muted).toBe(true);
expect(element).toHaveAttribute("data-ready", "false");
@@ -70,6 +74,62 @@ describe("ProjectVideo", () => {
expect(element).toHaveAttribute("data-ready", "true");
});
+ it("does not even request the poster while the card is far from the screen", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container.querySelector("video")).not.toHaveAttribute("poster");
+
+ rerender(
+ ,
+ );
+ expect(container.querySelector("video")).toHaveAttribute(
+ "poster",
+ video.poster,
+ );
+ });
+
+ it("swaps to the sources of the new device when the version changes", () => {
+ const mobile = getShowcaseAssets("nutriscan", "mobile");
+ const { container, rerender } = render(
+ ,
+ );
+ const loads = jest.mocked(HTMLMediaElement.prototype.load).mock.calls
+ .length;
+
+ rerender();
+ expect(
+ [...container.querySelectorAll("source")].map((source) =>
+ source.getAttribute("src"),
+ ),
+ ).toEqual(mobile.sources.map((source) => source.src));
+ expect(
+ jest.mocked(HTMLMediaElement.prototype.load).mock.calls.length,
+ ).toBeGreaterThan(loads);
+ });
+
+ it("shows only the poster, with no video element, when the data saver is on", () => {
+ Object.defineProperty(navigator, "connection", {
+ configurable: true,
+ value: { saveData: true },
+ });
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("video")).toBeNull();
+ expect(container.querySelector("source")).toBeNull();
+ expect(
+ screen.getByRole("img", { name: "Captura de tela de Demo" }),
+ ).toBeInTheDocument();
+ expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
+ });
+
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
reducedMotion = true;
const { container } = render(
diff --git a/app/components/ProjectVideo.tsx b/app/components/ProjectVideo.tsx
index 2e11f24..e6ab5ef 100644
--- a/app/components/ProjectVideo.tsx
+++ b/app/components/ProjectVideo.tsx
@@ -4,36 +4,44 @@ import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { ShowcaseAssets } from "@/lib/project-showcase";
+import { useSaveData } from "./showcase/useShowcaseMedia";
/**
- * Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que
- * está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o
- * card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece.
- * O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
+ * Vídeo de rolagem na capa do card. `video` já é a versão do aparelho (quem chama escolhe: celular abaixo de
+ * 768 px; no servidor, nenhuma). Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que está
+ * sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o card
+ * fica ativo (`preload="metadata"`, sem fontes até lá); fora de cena ele pausa. Com prefers-reduced-motion ou
+ * "economizar dados" só o pôster aparece. `showPoster` falso (seção longe da tela) não pede nem o pôster.
+ * O pôster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
*/
export function ProjectVideo({
video,
title,
active,
+ showPoster = true,
}: {
video: ShowcaseAssets;
title: string;
active: boolean;
+ showPoster?: boolean;
}) {
const reducedMotion = useReducedMotion();
+ const saveData = useSaveData();
const ref = useRef(null);
const [attached, setAttached] = useState(false);
const [ready, setReady] = useState(false);
- const playing = active && !reducedMotion;
+ const posterOnly = reducedMotion || saveData;
+ const playing = active && !posterOnly;
+ const firstSource = video.sources[0]?.src;
useEffect(() => {
if (playing) setAttached(true);
}, [playing]);
useEffect(() => {
- // as fontes entram depois do primeiro render: o elemento precisa ler a lista nova
- if (attached) ref.current?.load();
- }, [attached]);
+ // as fontes entram depois do primeiro render (ou mudam com o aparelho): o elemento precisa ler a lista nova
+ if (attached && firstSource) ref.current?.load();
+ }, [attached, firstSource]);
useEffect(() => {
const element = ref.current;
@@ -46,7 +54,8 @@ export function ProjectVideo({
}
}, [attached, playing]);
- if (reducedMotion) {
+ if (posterOnly) {
+ if (!showPoster) return null;
return (
{
@@ -11,3 +11,66 @@ describe("static cinematic effects", () => {
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
});
});
+
+describe("film grain over the project screens", () => {
+ let screenTop = 5000;
+
+ function addScreen(className: string) {
+ const element = document.createElement("div");
+ element.className = className;
+ element.getBoundingClientRect = () =>
+ ({ top: screenTop, bottom: screenTop + 600, height: 600 }) as DOMRect;
+ document.body.append(element);
+ return element;
+ }
+
+ function scroll(to: number) {
+ screenTop = to;
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ jest.advanceTimersByTime(40);
+ });
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ screenTop = 5000;
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ document.body.innerHTML = "";
+ });
+
+ it("steps aside while a project screen is in view and comes back when it leaves", () => {
+ addScreen("ds-reel");
+ const { container } = render();
+ const grain = container.firstElementChild as HTMLElement;
+ expect(grain).not.toHaveAttribute("data-over-projects");
+
+ scroll(300); // a vitrine entrou na janela
+ expect(grain).toHaveAttribute("data-over-projects");
+
+ scroll(-900); // passou: a cena acabou 300 px acima do topo
+ expect(grain).not.toHaveAttribute("data-over-projects");
+ });
+
+ it("also covers the browser and phone frames", () => {
+ addScreen("ds-bframe");
+ const { container } = render();
+ scroll(100);
+ expect(container.firstElementChild).toHaveAttribute("data-over-projects");
+ });
+
+ it("stays on when there is no project screen on the page", () => {
+ const { container } = render();
+ scroll(100);
+ expect(container.firstElementChild).not.toHaveAttribute(
+ "data-over-projects",
+ );
+ });
+});
diff --git a/app/components/cinematic/FilmGrain.paths.test.tsx b/app/components/cinematic/FilmGrain.paths.test.tsx
new file mode 100644
index 0000000..2d5e1eb
--- /dev/null
+++ b/app/components/cinematic/FilmGrain.paths.test.tsx
@@ -0,0 +1,139 @@
+import { act, render } from "@testing-library/react";
+import FilmGrain from "./FilmGrain";
+
+/**
+ * Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo
+ * de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros
+ * agrupados e a limpeza ao desmontar.
+ */
+
+const INNER_HEIGHT = 800;
+
+function addScreen(
+ className: string,
+ rect: { top: number; bottom: number; height: number },
+) {
+ const element = document.createElement("div");
+ element.className = className;
+ element.getBoundingClientRect = () => rect as DOMRect;
+ document.body.append(element);
+ return element;
+}
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: INNER_HEIGHT,
+ });
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ document.body.innerHTML = "";
+});
+
+function renderGrain() {
+ const view = render();
+ return { ...view, grain: view.container.firstElementChild as HTMLElement };
+}
+
+describe("FilmGrain: is a project screen in view?", () => {
+ it("path: a visible screen -> the grain steps aside", () => {
+ addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
+
+ const { grain } = renderGrain();
+
+ expect(grain).toHaveAttribute("data-over-projects");
+ });
+
+ it.each([
+ ["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }],
+ ["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }],
+ ["ended above the window", { top: -900, bottom: -300, height: 600 }],
+ [
+ "starts exactly at the bottom edge",
+ { top: INNER_HEIGHT, bottom: 1400, height: 600 },
+ ],
+ ["starts below the window", { top: 2000, bottom: 2600, height: 600 }],
+ ])("path: a screen that %s -> does not count", (_label, rect) => {
+ addScreen("ds-bframe", rect);
+
+ const { grain } = renderGrain();
+
+ expect(grain).not.toHaveAttribute("data-over-projects");
+ });
+
+ it("path: just inside the window by one pixel at either edge -> counts", () => {
+ addScreen("ds-frame", { top: -599, bottom: 1, height: 600 });
+ const top = renderGrain();
+ expect(top.grain).toHaveAttribute("data-over-projects");
+ top.unmount();
+ document.body.innerHTML = "";
+
+ addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 });
+ const bottom = renderGrain();
+ expect(bottom.grain).toHaveAttribute("data-over-projects");
+ });
+
+ it("path: one screen off the window and another in view -> counts (any is enough)", () => {
+ addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 });
+ addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
+
+ const { grain } = renderGrain();
+
+ expect(grain).toHaveAttribute("data-over-projects");
+ });
+
+ it("path: no project screen on the page -> the grain stays", () => {
+ const { grain } = renderGrain();
+
+ expect(grain).not.toHaveAttribute("data-over-projects");
+ });
+});
+
+describe("FilmGrain: frames and cleanup", () => {
+ it("path: many scroll events inside one frame -> the page is measured once", () => {
+ const screenEl = addScreen("ds-reel", {
+ top: 5000,
+ bottom: 5600,
+ height: 600,
+ });
+ const measure = jest.spyOn(screenEl, "getBoundingClientRect");
+ renderGrain();
+ measure.mockClear();
+
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ window.dispatchEvent(new Event("scroll"));
+ window.dispatchEvent(new Event("resize"));
+ jest.advanceTimersByTime(40);
+ });
+
+ expect(measure).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: unmount -> listeners removed and no measurement afterwards", () => {
+ const screenEl = addScreen("ds-reel", {
+ top: 5000,
+ bottom: 5600,
+ height: 600,
+ });
+ const measure = jest.spyOn(screenEl, "getBoundingClientRect");
+ const { unmount } = renderGrain();
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ });
+
+ unmount();
+ measure.mockClear();
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ window.dispatchEvent(new Event("resize"));
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(measure).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/cinematic/FilmGrain.tsx b/app/components/cinematic/FilmGrain.tsx
index b2e01b1..008c0f0 100644
--- a/app/components/cinematic/FilmGrain.tsx
+++ b/app/components/cinematic/FilmGrain.tsx
@@ -1,3 +1,43 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+/** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */
+const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone";
+
+/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */
export default function FilmGrain() {
- return ;
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const grain = ref.current;
+ if (!grain) return;
+ let frame = 0;
+ const update = () => {
+ frame = 0;
+ const over = Array.from(document.querySelectorAll(PROJECT_SCREENS)).some(
+ (screen) => {
+ const rect = screen.getBoundingClientRect();
+ return (
+ rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight
+ );
+ },
+ );
+ if (over) grain.dataset.overProjects = "";
+ else delete grain.dataset.overProjects;
+ };
+ const queue = () => {
+ if (!frame) frame = requestAnimationFrame(update);
+ };
+ update();
+ window.addEventListener("scroll", queue, { passive: true });
+ window.addEventListener("resize", queue);
+ return () => {
+ cancelAnimationFrame(frame);
+ window.removeEventListener("scroll", queue);
+ window.removeEventListener("resize", queue);
+ };
+ }, []);
+
+ return ;
}
diff --git a/app/components/cinematic/FramePulse.test.tsx b/app/components/cinematic/FramePulse.test.tsx
new file mode 100644
index 0000000..703c1da
--- /dev/null
+++ b/app/components/cinematic/FramePulse.test.tsx
@@ -0,0 +1,50 @@
+import { act, render } from "@testing-library/react";
+import FramePulse from "./FramePulse";
+
+const mockInvalidate = jest.fn();
+
+jest.mock("@react-three/fiber", () => ({
+ useThree: (select: (state: { invalidate: () => void }) => unknown) =>
+ select({ invalidate: mockInvalidate }),
+}));
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockInvalidate.mockClear();
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+describe("FramePulse", () => {
+ it("asks for frames at about 30 per second while active, not 60", () => {
+ render();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(mockInvalidate.mock.calls.length).toBeGreaterThan(20);
+ expect(mockInvalidate.mock.calls.length).toBeLessThanOrEqual(34);
+ });
+
+ it("draws nothing at all when it is not active (off screen)", () => {
+ const { rerender } = render();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(mockInvalidate).not.toHaveBeenCalled();
+
+ rerender();
+ act(() => {
+ jest.advanceTimersByTime(200);
+ });
+ const running = mockInvalidate.mock.calls.length;
+ expect(running).toBeGreaterThan(0);
+
+ rerender();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(mockInvalidate.mock.calls.length).toBe(running);
+ });
+});
diff --git a/app/components/cinematic/FramePulse.tsx b/app/components/cinematic/FramePulse.tsx
new file mode 100644
index 0000000..236ba12
--- /dev/null
+++ b/app/components/cinematic/FramePulse.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useThree } from "@react-three/fiber";
+import { useEffect } from "react";
+
+/**
+ * Batida das cenas WebGL em `frameloop="demand"`: enquanto `active`, pede um quadro a no máximo `fps` por
+ * segundo (fundo ambiente, sem gastar 60 quadros por segundo); com `active` falso, nenhum quadro é desenhado.
+ * Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame.
+ */
+export default function FramePulse({
+ active,
+ fps = 30,
+}: {
+ active: boolean;
+ fps?: number;
+}) {
+ const invalidate = useThree((state) => state.invalidate);
+
+ useEffect(() => {
+ if (!active) return;
+ const gap = 1000 / fps - 2;
+ let id = 0;
+ let last = 0;
+ const beat = (now: number) => {
+ id = requestAnimationFrame(beat);
+ if (now - last < gap) return;
+ last = now;
+ invalidate();
+ };
+ id = requestAnimationFrame(beat);
+ return () => cancelAnimationFrame(id);
+ }, [active, fps, invalidate]);
+
+ return null;
+}
diff --git a/app/components/cinematic/HeroBackground.paths.test.tsx b/app/components/cinematic/HeroBackground.paths.test.tsx
new file mode 100644
index 0000000..005b708
--- /dev/null
+++ b/app/components/cinematic/HeroBackground.paths.test.tsx
@@ -0,0 +1,67 @@
+import { render } from "@testing-library/react";
+import { renderToString } from "react-dom/server";
+import { isLowPowerDevice } from "@/lib/device";
+import HeroBackground from "./HeroBackground";
+
+/**
+ * Testes de caminhos-base de HeroBackground: movimento reduzido (nada), aparelho fraco (só as camadas escuras,
+ * sem baixar a cena 3D), aparelho capaz (cena montada depois do efeito) e o HTML do servidor (sem cena).
+ */
+
+let reducedMotion = false;
+jest.mock("@/app/hooks/useReducedMotion", () => ({
+ useReducedMotion: () => reducedMotion,
+}));
+jest.mock("@/lib/device", () => ({ isLowPowerDevice: jest.fn() }));
+jest.mock("next/dynamic", () => ({
+ __esModule: true,
+ default: () =>
+ function MockScene() {
+ return require("react").createElement("canvas", {
+ "data-testid": "hero-scene",
+ });
+ },
+}));
+
+const lowPower = jest.mocked(isLowPowerDevice);
+
+beforeEach(() => {
+ reducedMotion = false;
+ lowPower.mockReset();
+ lowPower.mockReturnValue(false);
+});
+
+describe("HeroBackground", () => {
+ it("path: reduced motion -> renders nothing, even on a capable device", () => {
+ reducedMotion = true;
+
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("path: capable device -> the 3D scene plus the three dark layers", () => {
+ const { container, getByTestId } = render();
+
+ expect(getByTestId("hero-scene")).toBeInTheDocument();
+ expect(container.querySelectorAll(".pointer-events-none")).toHaveLength(3);
+ });
+
+ it("path: low-power device -> only the dark layers, the 3D scene is never mounted", () => {
+ lowPower.mockReturnValue(true);
+
+ const { container, queryByTestId } = render();
+
+ expect(queryByTestId("hero-scene")).toBeNull();
+ expect(container.querySelectorAll(".pointer-events-none")).toHaveLength(3);
+ expect(lowPower).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: server HTML -> layers without the scene (capability is only known in the browser)", () => {
+ const html = renderToString();
+
+ expect(html).not.toContain("canvas");
+ expect(html).toContain("pointer-events-none");
+ expect(lowPower).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/cinematic/HeroBackground.tsx b/app/components/cinematic/HeroBackground.tsx
index 2975de0..bb92a5d 100644
--- a/app/components/cinematic/HeroBackground.tsx
+++ b/app/components/cinematic/HeroBackground.tsx
@@ -1,23 +1,32 @@
"use client";
import dynamic from "next/dynamic";
+import { useEffect, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
+import { isLowPowerDevice } from "@/lib/device";
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
/**
* Reusable 3D animated background for internal hero sections.
* Drop this inside any `relative overflow-hidden` dark section.
+ * A cena WebGL só monta em aparelho que aguenta (sem toque, sem pouca CPU/memória, sem "economizar dados"):
+ * nos outros fica o fundo escuro, sem baixar o Three.js.
*/
export default function HeroBackground() {
const shouldReduceMotion = useReducedMotion();
+ const [capable, setCapable] = useState(false);
+
+ useEffect(() => {
+ setCapable(!isLowPowerDevice());
+ }, []);
if (shouldReduceMotion) return null;
return (
<>
{/* 3D particle scene */}
-
+ {capable && }
{/* Cinematic radial vignette */}
diff --git a/app/components/cinematic/HeroField.paths.test.tsx b/app/components/cinematic/HeroField.paths.test.tsx
new file mode 100644
index 0000000..581d724
--- /dev/null
+++ b/app/components/cinematic/HeroField.paths.test.tsx
@@ -0,0 +1,405 @@
+import { act, render, screen } from "@testing-library/react";
+import HeroField from "./HeroField";
+
+/**
+ * Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): a decisão de montar o WebGL
+ * (requestIdleCallback x temporizador, desmontagem antes da hora, falhas ao pedir o contexto WebGL2),
+ * o fundo sem a seção `.hero`, as variáveis de rolagem e a visibilidade (observador, aba oculta, fim do mergulho).
+ */
+
+type SceneProps = {
+ visible: boolean;
+ progressRef: { current: number };
+ onReady: () => void;
+ onError: () => void;
+};
+const mockScenes: SceneProps[] = [];
+
+jest.mock("next/dynamic", () => ({
+ __esModule: true,
+ default: () =>
+ function MockScene(props: SceneProps) {
+ mockScenes.push(props);
+ return require("react").createElement("canvas", {
+ "data-testid": "scene",
+ });
+ },
+}));
+
+const win = window as unknown as Record;
+const original = {
+ matchMedia: window.matchMedia,
+ webgl2: win.WebGL2RenderingContext,
+ getContext: HTMLCanvasElement.prototype.getContext,
+ observer: global.IntersectionObserver,
+ idle: win.requestIdleCallback,
+ cancelIdle: win.cancelIdleCallback,
+};
+
+function mockMedia(active: string[]) {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: active.includes(query),
+ media: query,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+}
+
+function mockNavigator(values: Record) {
+ for (const [key, value] of Object.entries(values))
+ Object.defineProperty(navigator, key, { configurable: true, value });
+}
+
+function setContext(factory: () => unknown) {
+ HTMLCanvasElement.prototype.getContext = factory as never;
+}
+
+type Observer = {
+ callback: IntersectionObserverCallback;
+ disconnect: jest.Mock;
+};
+let observers: Observer[] = [];
+
+function Hero({ children }: { children?: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function setHidden(value: boolean) {
+ Object.defineProperty(document, "hidden", { configurable: true, value });
+}
+
+function reportIntersecting(value: boolean) {
+ act(() => {
+ for (const observer of observers)
+ observer.callback(
+ [{ isIntersecting: value } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ );
+ });
+}
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockScenes.length = 0;
+ observers = [];
+ mockMedia([]);
+ mockNavigator({
+ hardwareConcurrency: 8,
+ deviceMemory: undefined,
+ connection: undefined,
+ });
+ win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
+ setContext(() => ({ getExtension: () => null }));
+ win.requestIdleCallback = undefined;
+ win.cancelIdleCallback = undefined;
+ global.IntersectionObserver = class {
+ private readonly record: Observer;
+ constructor(callback: IntersectionObserverCallback) {
+ this.record = { callback, disconnect: jest.fn() };
+ observers.push(this.record);
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {
+ this.record.disconnect();
+ }
+ takeRecords() {
+ return [];
+ }
+ } as unknown as typeof IntersectionObserver;
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ window.matchMedia = original.matchMedia;
+ win.WebGL2RenderingContext = original.webgl2;
+ win.requestIdleCallback = original.idle;
+ win.cancelIdleCallback = original.cancelIdle;
+ HTMLCanvasElement.prototype.getContext = original.getContext;
+ global.IntersectionObserver = original.observer;
+ Reflect.deleteProperty(document, "hidden");
+});
+
+describe("HeroField: quando o WebGL monta", () => {
+ it("path: no requestIdleCallback -> the scene mounts after 1.2 s of timer, not before", () => {
+ render();
+
+ act(() => {
+ jest.advanceTimersByTime(1199);
+ });
+ expect(screen.queryByTestId("scene")).toBeNull();
+
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(screen.getByTestId("scene")).toBeInTheDocument();
+ });
+
+ it("path: requestIdleCallback available -> mounts when the browser is idle (2.5 s timeout), not on the timer", () => {
+ let idle: (() => void) | undefined;
+ const request = jest.fn((callback: () => void) => {
+ idle = callback;
+ return 42;
+ });
+ win.requestIdleCallback = request;
+ win.cancelIdleCallback = jest.fn();
+
+ render();
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(screen.queryByTestId("scene")).toBeNull();
+ expect(request).toHaveBeenCalledWith(expect.any(Function), {
+ timeout: 2500,
+ });
+
+ act(() => idle?.());
+ expect(screen.getByTestId("scene")).toBeInTheDocument();
+ });
+
+ it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
+ const cancel = jest.fn();
+ win.requestIdleCallback = jest.fn(() => 7);
+ win.cancelIdleCallback = cancel;
+
+ const view = render();
+ view.unmount();
+
+ expect(cancel).toHaveBeenCalledWith(7);
+ expect(mockScenes).toHaveLength(0);
+ });
+
+ it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
+ const view = render();
+ view.unmount();
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+
+ expect(mockScenes).toHaveLength(0);
+ });
+
+ it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
+ setContext(() => {
+ throw new Error("sem GPU");
+ });
+
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(screen.queryByTestId("scene")).toBeNull();
+ });
+
+ it("path: WebGL2 constructor exists but the context is null -> static background", () => {
+ setContext(() => null);
+
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(screen.queryByTestId("scene")).toBeNull();
+ });
+
+ it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
+ const loseContext = jest.fn();
+ setContext(() => ({
+ getExtension: (name: string) =>
+ name === "WEBGL_lose_context" ? { loseContext } : null,
+ }));
+
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(loseContext).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("scene")).toBeInTheDocument();
+ });
+
+ it("path: the lose-context extension is missing -> the probe still succeeds", () => {
+ setContext(() => ({ getExtension: () => null }));
+
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(screen.getByTestId("scene")).toBeInTheDocument();
+ });
+
+ it("path: low power -> the WebGL probe and the timer are never started", () => {
+ mockMedia(["(pointer: coarse)"]);
+ const probe = jest.fn(() => ({ getExtension: () => null }));
+ setContext(probe);
+
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(probe).not.toHaveBeenCalled();
+ expect(mockScenes).toHaveLength(0);
+ });
+});
+
+describe("HeroField: rolagem e visibilidade", () => {
+ function section() {
+ return document.querySelector(".hero") as HTMLElement;
+ }
+
+ function place(top: number, height = 2000) {
+ section().getBoundingClientRect = () =>
+ ({ top, height, bottom: top + height }) as DOMRect;
+ }
+
+ function scroll(top: number) {
+ place(top);
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ jest.advanceTimersByTime(40);
+ });
+ }
+
+ beforeEach(() => {
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+ });
+
+ it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(observers).toHaveLength(0);
+ expect(screen.getByTestId("scene")).toBeInTheDocument();
+ expect(mockScenes.at(-1)?.visible).toBe(false);
+ });
+
+ it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
+ render();
+ const setProperty = jest.spyOn(section().style, "setProperty");
+
+ scroll(-600);
+ const afterFirst = setProperty.mock.calls.length;
+ expect(afterFirst).toBeGreaterThan(0);
+ scroll(-600);
+
+ expect(setProperty.mock.calls).toHaveLength(afterFirst);
+ });
+
+ it("path: resize recomputes the progress like a scroll", () => {
+ render();
+ place(-600);
+
+ act(() => {
+ window.dispatchEvent(new Event("resize"));
+ jest.advanceTimersByTime(40);
+ });
+
+ expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
+ 0.5,
+ );
+ });
+
+ it("path: scroll above the hero (positive top) -> progress clamps at 0", () => {
+ render();
+
+ scroll(300);
+
+ expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
+ });
+
+ it("path: end of the dive then back up -> drawing stops, then resumes", () => {
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+ reportIntersecting(true);
+
+ scroll(-1200);
+ expect(mockScenes.at(-1)?.visible).toBe(false);
+
+ scroll(-300);
+ expect(mockScenes.at(-1)?.visible).toBe(true);
+ });
+
+ it("path: the hero leaves the screen -> not visible; returns -> visible", () => {
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ reportIntersecting(true);
+ expect(mockScenes.at(-1)?.visible).toBe(true);
+
+ reportIntersecting(false);
+ expect(mockScenes.at(-1)?.visible).toBe(false);
+
+ reportIntersecting(true);
+ expect(mockScenes.at(-1)?.visible).toBe(true);
+ });
+
+ it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", () => {
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+ reportIntersecting(true);
+
+ setHidden(true);
+ act(() => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(mockScenes.at(-1)?.visible).toBe(false);
+
+ setHidden(false);
+ act(() => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(mockScenes.at(-1)?.visible).toBe(true);
+ });
+
+ it("path: the tab shows but the hero is off screen -> still not visible", () => {
+ render();
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+ reportIntersecting(false);
+
+ act(() => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+
+ expect(mockScenes.at(-1)?.visible).toBe(false);
+ });
+
+ it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled", () => {
+ const removeListener = jest.spyOn(window, "removeEventListener");
+ const removeDocListener = jest.spyOn(document, "removeEventListener");
+ const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
+ const view = render();
+
+ view.unmount();
+
+ const removed = removeListener.mock.calls.map(([type]) => type);
+ expect(removed).toEqual(expect.arrayContaining(["scroll", "resize"]));
+ expect(removeDocListener.mock.calls.map(([type]) => type)).toContain(
+ "visibilitychange",
+ );
+ expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
+ expect(cancelFrame).toHaveBeenCalled();
+ });
+});
diff --git a/app/components/cinematic/HeroField.test.tsx b/app/components/cinematic/HeroField.test.tsx
new file mode 100644
index 0000000..217f625
--- /dev/null
+++ b/app/components/cinematic/HeroField.test.tsx
@@ -0,0 +1,179 @@
+import { act, render, screen } from "@testing-library/react";
+import HeroField from "./HeroField";
+
+type SceneProps = {
+ visible: boolean;
+ progressRef: { current: number };
+ labelsRef?: unknown;
+ onReady: () => void;
+ onError: () => void;
+};
+const mockScenes: SceneProps[] = [];
+
+jest.mock("next/dynamic", () => ({
+ __esModule: true,
+ default: () =>
+ function MockScene(props: SceneProps) {
+ mockScenes.push(props);
+ return require("react").createElement("canvas", {
+ "data-testid": "scene",
+ });
+ },
+}));
+
+const original = {
+ matchMedia: window.matchMedia,
+ webgl2: (window as unknown as Record).WebGL2RenderingContext,
+ getContext: HTMLCanvasElement.prototype.getContext,
+};
+
+function mockMedia(active: string[]) {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: active.includes(query),
+ media: query,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+}
+
+function mockNavigator(values: Record) {
+ for (const [key, value] of Object.entries(values))
+ Object.defineProperty(navigator, key, { configurable: true, value });
+}
+
+function Hero() {
+ return (
+
+
+
+ );
+}
+
+/** O navegador ocioso: o Three.js só entra depois do tempo de espera. */
+function idle() {
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+}
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockScenes.length = 0;
+ mockMedia([]);
+ mockNavigator({
+ hardwareConcurrency: 8,
+ deviceMemory: undefined,
+ connection: undefined,
+ });
+ (window as unknown as Record).WebGL2RenderingContext =
+ function WebGL2RenderingContext() {};
+ HTMLCanvasElement.prototype.getContext = (() => ({
+ getExtension: () => null,
+ })) as unknown as typeof HTMLCanvasElement.prototype.getContext;
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+ window.matchMedia = original.matchMedia;
+ (window as unknown as Record).WebGL2RenderingContext =
+ original.webgl2;
+ HTMLCanvasElement.prototype.getContext = original.getContext;
+});
+
+describe("HeroField (ambient background)", () => {
+ it("mounts the WebGL scene only after the page is idle, on a capable desktop", () => {
+ render();
+ expect(screen.queryByTestId("scene")).toBeNull();
+ idle();
+ expect(screen.getByTestId("scene")).toBeInTheDocument();
+ // o IntersectionObserver do jest.setup avisa que o hero está à vista: o desenho está liberado
+ expect(mockScenes.at(-1)?.visible).toBe(true);
+ });
+
+ it("is decorative: hidden from assistive technology and with no labels over the 3D", () => {
+ const { container } = render();
+ idle();
+ const field = container.querySelector(".hero-field");
+ expect(field).toHaveAttribute("aria-hidden", "true");
+ expect(container.querySelector(".hero-tag")).toBeNull();
+ for (const label of ["A tela", "As integrações", "Os dados"])
+ expect(screen.queryByText(label, { exact: false })).toBeNull();
+ expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
+ });
+
+ it.each([
+ ["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
+ ["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
+ ["the data saver", () => mockNavigator({ connection: { saveData: true } })],
+ ["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
+ ["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
+ [
+ "no WebGL2",
+ () => {
+ (window as unknown as Record).WebGL2RenderingContext =
+ undefined;
+ },
+ ],
+ ])(
+ "keeps the light static background (no WebGL) with %s",
+ (_label, arrange) => {
+ arrange();
+ const { container } = render();
+ idle();
+ expect(screen.queryByTestId("scene")).toBeNull();
+ expect(mockScenes).toHaveLength(0);
+ expect(container.querySelector(".hero-field")).not.toHaveAttribute(
+ "data-ready",
+ );
+ },
+ );
+
+ it("follows the hero scroll with --hero-p and --hero-open, and stops drawing once the dive is over", () => {
+ const { container } = render();
+ idle();
+ const section = container.querySelector(".hero") as HTMLElement;
+ let top = 0;
+ section.getBoundingClientRect = () =>
+ ({ top, height: 2000, bottom: top + 2000 }) as DOMRect;
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+ const scroll = (to: number) => {
+ top = -to;
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ jest.advanceTimersByTime(40);
+ });
+ };
+
+ scroll(0);
+ expect(mockScenes.at(-1)?.progressRef.current).toBe(0);
+
+ scroll(600); // meio do mergulho (1200 px de trilho)
+ expect(Number(section.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
+ // a máscara já se abriu por completo: o fundo aparece inteiro
+ expect(Number(section.style.getPropertyValue("--hero-open"))).toBe(1);
+
+ scroll(1200);
+ expect(Number(section.style.getPropertyValue("--hero-p"))).toBe(1);
+ expect(mockScenes.at(-1)?.visible).toBe(false);
+ });
+
+ it("reveals the scene (data-ready) once it has drawn its first frames", () => {
+ const { container } = render();
+ idle();
+ expect(container.querySelector(".hero-field")).not.toHaveAttribute(
+ "data-ready",
+ );
+ act(() => mockScenes.at(-1)?.onReady());
+ expect(container.querySelector(".hero-field")).toHaveAttribute(
+ "data-ready",
+ );
+ act(() => mockScenes.at(-1)?.onError());
+ expect(container.querySelector(".hero-field")).not.toHaveAttribute(
+ "data-ready",
+ );
+ expect(screen.queryByTestId("scene")).toBeNull();
+ });
+});
diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx
index 303c4c0..c00bfa1 100644
--- a/app/components/cinematic/HeroField.tsx
+++ b/app/components/cinematic/HeroField.tsx
@@ -2,19 +2,18 @@
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
+import { isLowPowerDevice } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
-/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
-const TAGS = [
- ["A tela", "o que sua equipe e seus clientes usam"],
- ["As integrações", "seus sistemas conversando entre si"],
- ["Os dados", "suas informações organizadas"],
-] as const;
-
const Scene = dynamic(() => import("./HeroFieldScene"), {
ssr: false,
});
+const smooth = (a: number, b: number, x: number) => {
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
+ return t * t * (3 - 2 * t);
+};
+
function supportsWebGL2() {
if (typeof window.WebGL2RenderingContext === "undefined") return false;
try {
@@ -27,13 +26,15 @@ function supportsWebGL2() {
}
/**
- * Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
- * e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
+ * Fundo ambiente do hero. Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho)
+ * e --hero-open (0 em repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e monta a cena
+ * WebGL só quando a página fica ociosa e o aparelho aguenta: WebGL2, sem toque (pointer: coarse), com mais de
+ * 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido. Nos outros casos fica o fundo
+ * estático leve do CSS (.hero-glow). Sem legendas sobre o 3D: o espaço da direita é das molduras.
*/
export default function HeroField() {
const rootRef = useRef(null);
const progressRef = useRef(0);
- const labelsRef = useRef<(HTMLElement | null)[]>([]);
const [mount, setMount] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
@@ -53,8 +54,12 @@ export default function HeroField() {
window.innerHeight * 0.8,
);
const p = Math.min(1, Math.max(0, -rect.top / span));
+ // depois do mergulho (ou antes dele) a rolagem não muda mais nada: sem reescrever as variáveis CSS
+ if (p === progressRef.current) return;
progressRef.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
+ // a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
+ section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
setDone(p >= 0.999);
};
const queue = () => {
@@ -82,7 +87,7 @@ export default function HeroField() {
}, []);
useEffect(() => {
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ if (isLowPowerDevice()) return;
if (!supportsWebGL2()) return;
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
if (typeof window.requestIdleCallback === "function") {
@@ -108,26 +113,11 @@ export default function HeroField() {
{mount && !failed && (
)}
- {mount &&
- !failed &&
- TAGS.map(([title, text], index) => (
- {
- labelsRef.current[index] = element;
- }}
- className="hero-tag"
- >
- {title}
- {text}
-
- ))}
);
}
diff --git a/app/components/cinematic/HeroFieldScene.paths.test.tsx b/app/components/cinematic/HeroFieldScene.paths.test.tsx
new file mode 100644
index 0000000..4751ed8
--- /dev/null
+++ b/app/components/cinematic/HeroFieldScene.paths.test.tsx
@@ -0,0 +1,580 @@
+import { act, render } from "@testing-library/react";
+import * as THREE from "three";
+import HeroFieldScene from "./HeroFieldScene";
+
+/**
+ * Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
+ * - fim antecipado sem grupo, onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente);
+ * - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
+ * - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
+ * - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
+ * - perda do contexto WebGL, limpeza e a falha de um filho (SceneBoundary).
+ * O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega o callback do useFrame; o `three`
+ * é o de verdade, com ShaderMaterial e BufferGeometry registrando as instâncias criadas.
+ */
+
+type FrameState = {
+ clock: { elapsedTime: number };
+ invalidate: () => void;
+};
+const mockState: {
+ frame: ((state: FrameState, delta: number) => void) | null;
+ three: unknown;
+ throwOnUseThree: boolean;
+} = { frame: null, three: null, throwOnUseThree: false };
+const mockMaterials: THREE.ShaderMaterial[] = [];
+const mockGeometries: THREE.BufferGeometry[] = [];
+
+jest.mock("three", () => {
+ const actual = jest.requireActual("three");
+ class CapturingMaterial extends actual.ShaderMaterial {
+ constructor(parameters: unknown) {
+ super(parameters);
+ mockMaterials.push(this as unknown as THREE.ShaderMaterial);
+ }
+ }
+ class CapturingGeometry extends actual.BufferGeometry {
+ constructor() {
+ super();
+ mockGeometries.push(this as unknown as THREE.BufferGeometry);
+ }
+ }
+ return {
+ ...actual,
+ ShaderMaterial: CapturingMaterial,
+ BufferGeometry: CapturingGeometry,
+ };
+});
+jest.mock("@react-three/fiber", () => ({
+ Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+ useFrame: (callback: (state: FrameState, delta: number) => void) => {
+ mockState.frame = callback;
+ },
+ useThree: () => {
+ if (mockState.throwOnUseThree) throw new Error("sem WebGL");
+ return mockState.three;
+ },
+}));
+jest.mock("./FramePulse", () => ({ __esModule: true, default: () => null }));
+
+type Setup = {
+ width?: number;
+ height?: number;
+ /** `null` simula uma ref sem valor (o código trata como 0). */
+ progress?: number | null;
+ visible?: boolean;
+};
+
+function setup({
+ width = 1200,
+ height = 800,
+ progress = 0,
+ visible = true,
+}: Setup = {}) {
+ const domElement = document.createElement("canvas");
+ const camera = new THREE.PerspectiveCamera(35, width / height, 0.05, 120);
+ camera.position.set(0, 0, 9);
+ camera.updateMatrixWorld();
+ mockState.three = {
+ gl: { domElement, getPixelRatio: () => 2 },
+ camera,
+ size: { width, height },
+ };
+ const progressRef = {
+ current: progress === null ? undefined : progress,
+ } as { current: number };
+ const onReady = jest.fn();
+ const onError = jest.fn();
+ const element = (isVisible: boolean) => (
+
+ );
+ const view = render(element(visible));
+
+ // é um elemento desconhecido no jsdom: ganha o que o três espera de um THREE.Group
+ const group = view.container.querySelector("group") as HTMLElement | null;
+ const real = new THREE.Group();
+ if (group)
+ Object.assign(group, {
+ rotation: real.rotation,
+ position: real.position,
+ matrixWorld: real.matrixWorld,
+ updateMatrixWorld: () => real.updateMatrixWorld(true),
+ worldToLocal: (point: THREE.Vector3) => point,
+ });
+ const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
+
+ function frame(time: number, delta = 1 / 60) {
+ const invalidate = jest.fn();
+ act(() => {
+ mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
+ });
+ return invalidate;
+ }
+
+ /** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
+ function settle(count: number, delta = 1 / 20, startTime = 100) {
+ let last = jest.fn();
+ for (let i = 0; i < count; i++) last = frame(startTime + i * delta, delta);
+ return last;
+ }
+
+ return {
+ ...view,
+ camera,
+ domElement,
+ progressRef,
+ onReady,
+ onError,
+ group: real,
+ uniforms,
+ frame,
+ settle,
+ rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
+ };
+}
+
+const originalHardware = Object.getOwnPropertyDescriptor(
+ navigator,
+ "hardwareConcurrency",
+);
+
+function setCores(value: number | undefined) {
+ Object.defineProperty(navigator, "hardwareConcurrency", {
+ configurable: true,
+ value,
+ });
+}
+
+function setWindowWidth(value: number) {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value,
+ });
+}
+
+function pointerMove(type: string, x = 600, y = 400) {
+ const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
+ Object.defineProperty(event, "pointerType", { value: type });
+ act(() => {
+ window.dispatchEvent(event);
+ });
+}
+
+beforeEach(() => {
+ mockMaterials.length = 0;
+ mockGeometries.length = 0;
+ mockState.frame = null;
+ mockState.throwOnUseThree = false;
+ setCores(8);
+ setWindowWidth(1200);
+ // as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
+ jest.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+ if (originalHardware)
+ Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
+});
+
+describe("Field: quadro (useFrame) — começo e rolagem", () => {
+ it("path: the group is gone (unmounted) -> the callback returns at once, nothing is requested", () => {
+ const scene = setup();
+ const callback = mockState.frame;
+ scene.unmount();
+ const invalidate = jest.fn();
+
+ callback?.({ clock: { elapsedTime: 1 }, invalidate }, 1 / 60);
+
+ expect(invalidate).not.toHaveBeenCalled();
+ expect(scene.onReady).not.toHaveBeenCalled();
+ });
+
+ it("path: onReady fires on the second frame, once", () => {
+ const scene = setup();
+
+ scene.frame(0);
+ expect(scene.onReady).not.toHaveBeenCalled();
+ scene.frame(0.02);
+ expect(scene.onReady).toHaveBeenCalledTimes(1);
+ scene.frame(0.04);
+ scene.frame(0.06);
+
+ expect(scene.onReady).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
+ const scene = setup({ progress: 1 });
+
+ scene.frame(0, 5); // delta enorme: o passo é limitado a 1/20 de segundo
+
+ const expected = 1 - Math.exp(-(1 / 20) * 5);
+ expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
+ expect(scene.uniforms().uScroll.value).toBeLessThan(1);
+ });
+
+ it("path: no progress available (undefined) -> treated as the top of the page", () => {
+ const scene = setup({ progress: null });
+
+ scene.settle(5);
+
+ expect(scene.uniforms().uScroll.value).toBe(0);
+ });
+
+ it("path: the intro advances every frame and stops at 1", () => {
+ const scene = setup();
+
+ scene.frame(0, 1 / 20);
+ expect(scene.uniforms().uIntro.value).toBeCloseTo(0.05 / 2.6, 9);
+
+ scene.settle(80);
+
+ expect(scene.uniforms().uIntro.value).toBe(1);
+ });
+
+ it("path: the scene fades out only at the very end of the dive", () => {
+ const scene = setup({ progress: 0 });
+ scene.settle(10);
+ expect(scene.uniforms().uFade.value).toBe(1);
+
+ scene.progressRef.current = 1;
+ scene.settle(120);
+
+ expect(scene.uniforms().uFade.value).toBeCloseTo(0, 6);
+ });
+
+ it("path: layers spread apart only while they face the viewer", () => {
+ const scene = setup({ progress: 0 });
+ scene.settle(5);
+ expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
+
+ scene.progressRef.current = 0.5;
+ scene.settle(150);
+
+ expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
+ });
+});
+
+describe("Field: enquadramento e foco", () => {
+ it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
+ const scene = setup({ width: 1200, height: 800 });
+
+ scene.frame(0);
+
+ expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
+ expect(scene.camera.position.z).toBeCloseTo(9, 9);
+ });
+
+ it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
+ const scene = setup({ width: 400, height: 800 });
+
+ scene.frame(0);
+
+ expect(scene.group.position.y).toBeLessThan(-0.5);
+ expect(scene.camera.position.z).toBeGreaterThan(9);
+ });
+
+ it("path: end of the dive -> the group has settled back to the center and the camera went through the layers", () => {
+ const scene = setup({ progress: 1 });
+
+ scene.settle(200);
+
+ expect(scene.group.position.y).toBeCloseTo(0, 6);
+ expect(scene.camera.position.z).toBeLessThan(0);
+ });
+
+ it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
+ const scene = setup({ progress: 0 });
+
+ scene.frame(0);
+
+ // câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
+ expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
+ });
+
+ it("path: camera past every layer -> the focus never goes below 1.5", () => {
+ const scene = setup({ progress: 1 });
+
+ scene.settle(200);
+
+ expect(scene.uniforms().uFocus.value).toBe(1.5);
+ });
+});
+
+describe("Field: mouse", () => {
+ it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
+ const scene = setup();
+ jest.spyOn(performance, "now").mockReturnValue(5000);
+ pointerMove("touch");
+ pointerMove("pen");
+
+ scene.frame(5.5);
+
+ // sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
+ scene.settle(120, 1 / 20, 5.5);
+ expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
+ });
+
+ it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
+ const scene = setup();
+ jest.spyOn(performance, "now").mockReturnValue(5000);
+ pointerMove("mouse");
+
+ for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
+
+ expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
+ });
+
+ it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
+ const scene = setup();
+ jest.spyOn(performance, "now").mockReturnValue(5000);
+ pointerMove("mouse");
+
+ for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
+
+ expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
+ });
+
+ it("path: a mouse event at timestamp 0 is not counted as activity", () => {
+ const scene = setup();
+ jest.spyOn(performance, "now").mockReturnValue(0);
+ pointerMove("mouse");
+
+ for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
+
+ expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
+ });
+
+ it("path: cursor strength fades to 0 once the dive is under way", () => {
+ const scene = setup({ progress: 1 });
+ jest.spyOn(performance, "now").mockReturnValue(5000);
+ pointerMove("mouse");
+
+ for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
+
+ expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
+ });
+
+ it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
+ const scene = setup();
+ jest
+ .spyOn(THREE.Ray.prototype, "intersectPlane")
+ .mockImplementation(((_plane: THREE.Plane, target: THREE.Vector3) =>
+ target.set(1, 0, 2)) as never);
+ jest.spyOn(performance, "now").mockReturnValue(5000);
+ pointerMove("mouse");
+
+ scene.frame(5.1, 1 / 20);
+
+ const factor = 1 - Math.exp(-(1 / 20) * 10);
+ expect(scene.uniforms().uMouse.value.x).toBeCloseTo(factor * 1, 6);
+ expect(scene.uniforms().uMouse.value.y).toBeCloseTo(factor * 2, 6);
+ });
+
+ it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
+ const scene = setup();
+ jest
+ .spyOn(THREE.Ray.prototype, "intersectPlane")
+ .mockReturnValue(null as never);
+ jest.spyOn(performance, "now").mockReturnValue(5000);
+ pointerMove("mouse");
+
+ scene.frame(5.1, 1 / 20);
+
+ expect(scene.uniforms().uMouse.value.x).toBe(0);
+ expect(scene.uniforms().uMouse.value.y).toBe(0);
+ });
+
+ it("path: idle mouse -> the ghost cursor wanders on its own", () => {
+ const scene = setup();
+
+ scene.frame(10, 1 / 20);
+
+ const { x, y } = scene.uniforms().uMouse.value;
+ expect(Math.hypot(x, y)).toBeGreaterThan(0);
+ });
+});
+
+describe("Field: quando pedir o próximo quadro", () => {
+ it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
+ const scene = setup({ visible: false });
+
+ const invalidate = scene.frame(0, 1 / 60);
+
+ expect(invalidate).toHaveBeenCalled();
+ });
+
+ it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
+ const scene = setup({ visible: true });
+ scene.settle(200);
+
+ const invalidate = scene.frame(500, 1 / 60);
+
+ expect(invalidate).not.toHaveBeenCalled();
+ });
+
+ it("path: everything settled and the hero is not visible -> no frame", () => {
+ const scene = setup({ visible: false });
+ scene.settle(200);
+
+ const invalidate = scene.frame(500, 1 / 60);
+
+ expect(invalidate).not.toHaveBeenCalled();
+ });
+
+ it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
+ const scene = setup({ visible: true });
+ scene.settle(200);
+ jest.spyOn(performance, "now").mockReturnValue(600_000);
+ pointerMove("mouse");
+
+ const invalidate = scene.frame(600.1, 1 / 60);
+
+ expect(invalidate).toHaveBeenCalled();
+ });
+
+ it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
+ const scene = setup({ visible: false });
+ scene.settle(200);
+ jest.spyOn(performance, "now").mockReturnValue(600_000);
+ pointerMove("mouse");
+
+ const invalidate = scene.frame(600.1, 1 / 60);
+
+ expect(invalidate).not.toHaveBeenCalled();
+ });
+
+ it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
+ const scene = setup({ visible: false });
+ scene.settle(200);
+ scene.progressRef.current = 0.5;
+
+ const invalidate = scene.frame(500, 1 / 60);
+
+ expect(invalidate).toHaveBeenCalled();
+ });
+
+ it("path: becoming visible later -> the new callback sees the new flag", () => {
+ const scene = setup({ visible: false });
+ scene.settle(200);
+ jest.spyOn(performance, "now").mockReturnValue(600_000);
+ pointerMove("mouse");
+ expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
+
+ scene.rerender(true);
+
+ expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
+ });
+});
+
+describe("Field: densidade, contexto WebGL e limpeza", () => {
+ const pointCount = () => {
+ const geometry = mockGeometries[mockGeometries.length - 1];
+ return geometry.getAttribute("position").count;
+ };
+
+ it("path: wide window and 4+ cores -> the dense field", () => {
+ setWindowWidth(1200);
+ setCores(8);
+ setup();
+ const dense = pointCount();
+
+ mockGeometries.length = 0;
+ setWindowWidth(600);
+ setup();
+
+ expect(dense).toBeGreaterThan(pointCount());
+ });
+
+ it("path: wide window but 2 cores -> the light field", () => {
+ setWindowWidth(1200);
+ setCores(2);
+ setup();
+ const light = pointCount();
+
+ mockGeometries.length = 0;
+ setCores(8);
+ setup();
+
+ expect(pointCount()).toBeGreaterThan(light);
+ });
+
+ it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
+ setWindowWidth(1200);
+ const counts: Record = {};
+ for (const [label, cores] of [
+ ["dense", 8],
+ ["light", 2],
+ ["unknown", undefined],
+ ] as const) {
+ mockGeometries.length = 0;
+ setCores(cores);
+ setup();
+ counts[label] = pointCount();
+ }
+
+ expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
+ Math.abs(counts.unknown - counts.light),
+ );
+ });
+
+ it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
+ const scene = setup();
+ const event = new Event("webglcontextlost", { cancelable: true });
+
+ act(() => {
+ scene.domElement.dispatchEvent(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(scene.onError).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: unmount -> geometry and material are disposed and the listeners go away", () => {
+ const scene = setup();
+ const geometry = mockGeometries[mockGeometries.length - 1];
+ const material = mockMaterials[mockMaterials.length - 1];
+ const disposeGeometry = jest.spyOn(geometry, "dispose");
+ const disposeMaterial = jest.spyOn(material, "dispose");
+ const event = new Event("webglcontextlost", { cancelable: true });
+
+ scene.unmount();
+ scene.domElement.dispatchEvent(event);
+
+ expect(disposeGeometry).toHaveBeenCalledTimes(1);
+ expect(disposeMaterial).toHaveBeenCalledTimes(1);
+ expect(event.defaultPrevented).toBe(false);
+ expect(scene.onError).not.toHaveBeenCalled();
+ });
+});
+
+describe("SceneBoundary", () => {
+ it("path: a child fails while drawing -> nothing is rendered and the page is told once", () => {
+ mockState.throwOnUseThree = true;
+ const onError = jest.fn();
+
+ const { container } = render(
+ {}}
+ onError={onError}
+ />,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: no failure -> the scene is rendered and onError is not called", () => {
+ const scene = setup();
+
+ expect(scene.container.querySelector("group")).not.toBeNull();
+ expect(scene.onError).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/cinematic/HeroFieldScene.test.tsx b/app/components/cinematic/HeroFieldScene.test.tsx
new file mode 100644
index 0000000..c013380
--- /dev/null
+++ b/app/components/cinematic/HeroFieldScene.test.tsx
@@ -0,0 +1,51 @@
+import { render } from "@testing-library/react";
+import HeroFieldScene from "./HeroFieldScene";
+
+type CanvasProps = {
+ dpr?: [number, number];
+ frameloop?: string;
+ gl?: Record;
+};
+const mockCanvas: CanvasProps[] = [];
+
+jest.mock("@react-three/fiber", () => ({
+ Canvas: (props: CanvasProps) => {
+ mockCanvas.push(props);
+ return null;
+ },
+ useFrame: jest.fn(),
+ useThree: jest.fn(),
+}));
+
+describe("HeroFieldScene (ambient 3D)", () => {
+ beforeEach(() => {
+ mockCanvas.length = 0;
+ });
+
+ it("draws on demand, never on a fixed 60 fps loop, and caps the pixel ratio at 1.5", () => {
+ render(
+ {}}
+ onError={() => {}}
+ />,
+ );
+ const props = mockCanvas.at(-1);
+ expect(props?.frameloop).toBe("demand");
+ expect(props?.dpr).toEqual([1, 1.5]);
+ expect(props?.gl).toMatchObject({ alpha: true, antialias: false });
+ });
+
+ it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => {
+ render(
+ {}}
+ onError={() => {}}
+ />,
+ );
+ expect(mockCanvas.at(-1)?.frameloop).toBe("demand");
+ });
+});
diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx
index 484f8a7..b9249a0 100644
--- a/app/components/cinematic/HeroFieldScene.tsx
+++ b/app/components/cinematic/HeroFieldScene.tsx
@@ -10,11 +10,10 @@ import {
useRef,
} from "react";
import * as THREE from "three";
+import FramePulse from "./FramePulse";
export type HeroFieldSceneProps = {
progressRef: RefObject;
- /** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
- labelsRef: RefObject<(HTMLElement | null)[]>;
visible: boolean;
onReady: () => void;
onError: () => void;
@@ -26,8 +25,12 @@ export type HeroFieldSceneProps = {
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
- * frente e a câmera atravessa uma a uma (as legendas de HeroField explicam cada camada nessa hora).
+ * frente e a câmera atravessa uma a uma (as legendas de Hero explicam cada camada nessa hora).
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
+ *
+ * É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
+ * molduras da direita. O desenho é sob demanda (frameloop "demand"): a cena pede um quadro a 30 por segundo
+ * e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, não desenha nada.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
@@ -389,16 +392,9 @@ function cameraFrames(rest: number, spread: number, frame: number) {
] as const;
}
-// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
-const LABEL_ANCHORS: [number, Layer, number][] = [
- [2.1, 1, 1.4],
- [1.95, 0, 0.9],
- [2, -1, 1.3],
-];
-
function Field({
progressRef,
- labelsRef,
+ visible,
onReady,
onError,
}: HeroFieldSceneProps) {
@@ -444,7 +440,6 @@ function Field({
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
hit: new THREE.Vector3(),
- anchor: new THREE.Vector3(),
zero: new THREE.Vector2(),
target: new THREE.Vector2(),
}),
@@ -521,12 +516,9 @@ function Field({
0,
);
const settle = 1 - smooth(0, 0.28, p);
- // em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
- g.position.set(
- wide ? 1.7 * settle : 0,
- (wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
- 0,
- );
+ // em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras
+ // da direita); no celular, mais abaixo, atrás dos botões
+ g.position.set(0, (wide ? -0.5 : -rest * tanHalf * 0.38) * settle, 0);
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
@@ -561,23 +553,19 @@ function Field({
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
- // etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
- const labels = labelsRef.current ?? [];
- const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
- LABEL_ANCHORS.forEach(([x, layer, z], index) => {
- const label = labels[index];
- if (!label) return;
- helpers.anchor
- .set(x, layer * u.uSpread.value, z)
- .applyMatrix4(g.matrixWorld)
- .project(camera);
- const px = ((helpers.anchor.x + 1) / 2) * size.width;
- const py = ((1 - helpers.anchor.y) / 2) * size.height;
- label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
- label.style.opacity = shown.toFixed(3);
- });
-
if (++s.frames === 2) onReady();
+
+ // sob demanda: o próximo quadro vem do FramePulse (30 por segundo); a 60 só enquanto algo ainda muda.
+ // A entrada e a rolagem terminam de assentar mesmo depois de o hero sair de cena (o último quadro tem de
+ // ser o final, com tudo já apagado); o cursor só conta com o hero à vista.
+ const settling =
+ u.uIntro.value < 1 ||
+ Math.abs((progressRef.current ?? 0) - s.progress) > 4e-4;
+ const interacting =
+ mouseActive === 1 ||
+ Math.abs(targetAmt - u.uMouseAmt.value) > 0.01 ||
+ s.frames < 3;
+ if (settling || (visible && interacting)) frame.invalidate();
});
return (
@@ -610,14 +598,15 @@ export default function HeroFieldScene(props: HeroFieldSceneProps) {
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
- dpr={[1, 1.75]}
- frameloop={props.visible ? "always" : "never"}
+ dpr={[1, 1.5]}
+ frameloop="demand"
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
>
+
diff --git a/app/components/cinematic/HeroScene.paths.test.tsx b/app/components/cinematic/HeroScene.paths.test.tsx
new file mode 100644
index 0000000..1f3600d
--- /dev/null
+++ b/app/components/cinematic/HeroScene.paths.test.tsx
@@ -0,0 +1,206 @@
+import { act, render } from "@testing-library/react";
+import HeroScene from "./HeroScene";
+
+/**
+ * Testes de caminhos-base de HeroScene (fundo 3D das páginas internas): quando o desenho está ligado
+ * (visível E à vista), a câmera que segue o mouse (CameraRig) e o relógio do chão em grade (GridFloor).
+ * O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega os callbacks do useFrame.
+ */
+
+const mockFrames: Array<(state: unknown, delta: number) => void> = [];
+const mockCamera = {
+ position: { x: 0, y: 0.5, z: 8 },
+ lookAt: jest.fn(),
+};
+const mockPulse = jest.fn();
+
+jest.mock("@react-three/fiber", () => ({
+ Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+ useFrame: (callback: (state: unknown, delta: number) => void) => {
+ mockFrames.push(callback);
+ },
+ useThree: () => ({ camera: mockCamera }),
+}));
+jest.mock("./FramePulse", () => ({
+ __esModule: true,
+ default: (props: { active: boolean }) => {
+ mockPulse(props.active);
+ return null;
+ },
+}));
+
+type Observer = {
+ callback: IntersectionObserverCallback;
+ disconnect: jest.Mock;
+};
+let observers: Observer[] = [];
+const OriginalObserver = global.IntersectionObserver;
+
+beforeEach(() => {
+ mockFrames.length = 0;
+ mockPulse.mockClear();
+ mockCamera.position = { x: 0, y: 0.5, z: 8 };
+ mockCamera.lookAt.mockClear();
+ observers = [];
+ global.IntersectionObserver = class {
+ private readonly record: Observer;
+ constructor(callback: IntersectionObserverCallback) {
+ this.record = { callback, disconnect: jest.fn() };
+ observers.push(this.record);
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {
+ this.record.disconnect();
+ }
+ takeRecords() {
+ return [];
+ }
+ } as unknown as typeof IntersectionObserver;
+ // as tags do R3F (fog, mesh, ...) são elementos desconhecidos no DOM do jsdom
+ jest.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ global.IntersectionObserver = OriginalObserver;
+ jest.restoreAllMocks();
+});
+
+const lastPulse = () =>
+ mockPulse.mock.calls[mockPulse.mock.calls.length - 1][0];
+
+function report(isIntersecting: boolean) {
+ act(() => {
+ observers[0].callback(
+ [{ isIntersecting } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ );
+ });
+}
+
+describe("HeroScene: quando desenha", () => {
+ it("path: visible by prop and in view (default) -> the pulse is on", () => {
+ render();
+
+ expect(lastPulse()).toBe(true);
+ });
+
+ it("path: isVisible=false -> the pulse is off even though the scene is in view", () => {
+ render();
+
+ expect(lastPulse()).toBe(false);
+ });
+
+ it("path: scrolled out of view -> off; back in view -> on", () => {
+ render();
+
+ report(false);
+ expect(lastPulse()).toBe(false);
+
+ report(true);
+ expect(lastPulse()).toBe(true);
+ });
+
+ it("path: out of view and isVisible=false -> off", () => {
+ render();
+
+ report(false);
+
+ expect(lastPulse()).toBe(false);
+ });
+
+ it("path: unmount -> the observer is disconnected", () => {
+ const view = render();
+
+ view.unmount();
+
+ expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("HeroScene: câmera que segue o mouse", () => {
+ // a cada renderização CameraRig e GridFloor chamam useFrame, nessa ordem: valem os dois últimos
+ const rigFrame = () => mockFrames[mockFrames.length - 2];
+
+ it("path: mouse at the center -> the camera eases toward the resting height and looks at the origin", () => {
+ render();
+
+ act(() => {
+ rigFrame()({}, 0.016);
+ });
+
+ expect(mockCamera.position.x).toBeCloseTo(0, 9);
+ expect(mockCamera.position.y).toBeCloseTo(0.5, 9);
+ expect(mockCamera.lookAt).toHaveBeenCalledWith(0, 0, 0);
+ });
+
+ it("path: mouse moved to the top-right corner -> the camera drifts right and up, a little per frame", () => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: 1000,
+ });
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+ render();
+
+ act(() => {
+ window.dispatchEvent(
+ new MouseEvent("mousemove", { clientX: 1000, clientY: 0 }),
+ );
+ rigFrame()({}, 0.016);
+ });
+
+ // alvo x = 1 * 0.5; alvo y = 1 * 0.3 + 0.5; passo de 2% por quadro
+ expect(mockCamera.position.x).toBeCloseTo(0.5 * 0.02, 9);
+ expect(mockCamera.position.y).toBeCloseTo(0.5 + (0.8 - 0.5) * 0.02, 9);
+ });
+
+ it("path: unmount -> the mouse listener is removed", () => {
+ const remove = jest.spyOn(window, "removeEventListener");
+ const view = render();
+
+ view.unmount();
+
+ expect(remove.mock.calls.map(([type]) => type)).toContain("mousemove");
+ });
+});
+
+describe("HeroScene: relógio do chão em grade", () => {
+ const gridFrame = () => mockFrames[mockFrames.length - 1];
+
+ function attachMaterial(material: unknown) {
+ const mesh = document.querySelector("mesh") as HTMLElement & {
+ material?: unknown;
+ };
+ mesh.material = material;
+ }
+
+ it("path: the mesh has shader uniforms -> uTime follows the clock", () => {
+ render();
+ const uniforms = { uTime: { value: 0 } };
+ attachMaterial({ uniforms });
+
+ gridFrame()({ clock: { elapsedTime: 12.5 } }, 0.016);
+
+ expect(uniforms.uTime.value).toBe(12.5);
+ });
+
+ it("path: the material has no uniforms yet -> nothing happens", () => {
+ render();
+ attachMaterial({});
+
+ expect(() =>
+ gridFrame()({ clock: { elapsedTime: 3 } }, 0.016),
+ ).not.toThrow();
+ });
+
+ it("path: the mesh is gone (unmounted) -> the frame callback does nothing", () => {
+ const view = render();
+ const frame = gridFrame();
+ view.unmount();
+
+ expect(() => frame({ clock: { elapsedTime: 3 } }, 0.016)).not.toThrow();
+ });
+});
diff --git a/app/components/cinematic/HeroScene.tsx b/app/components/cinematic/HeroScene.tsx
index 84050a1..0e40c17 100644
--- a/app/components/cinematic/HeroScene.tsx
+++ b/app/components/cinematic/HeroScene.tsx
@@ -1,8 +1,9 @@
"use client";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
-import { useEffect, useMemo, useRef } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
+import FramePulse from "./FramePulse";
function GridFloor() {
const meshRef = useRef(null);
@@ -95,20 +96,36 @@ function CameraRig() {
return null;
}
+/** Fundo ambiente das páginas internas: desenha sob demanda, a 30 quadros por segundo e só à vista. */
export default function HeroScene({
isVisible = true,
}: {
isVisible?: boolean;
}) {
+ const rootRef = useRef(null);
+ const [inView, setInView] = useState(true);
+
+ // fora da tela não desenha nada
+ useEffect(() => {
+ const element = rootRef.current;
+ if (!element) return;
+ const observer = new IntersectionObserver(([entry]) =>
+ setInView(entry.isIntersecting),
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
return (
+
diff --git a/app/components/cinematic/useCinematicScroll.paths.test.tsx b/app/components/cinematic/useCinematicScroll.paths.test.tsx
new file mode 100644
index 0000000..fff55c0
--- /dev/null
+++ b/app/components/cinematic/useCinematicScroll.paths.test.tsx
@@ -0,0 +1,720 @@
+import { act, render } from "@testing-library/react";
+import { ScrollTrigger } from "gsap/ScrollTrigger";
+import { useRef } from "react";
+import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
+
+/**
+ * Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx):
+ * - onClick (âncoras): cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena
+ * x fora de qualquer cena, desconto do cabeçalho, pushState só quando a âncora muda, foco no alvo;
+ * - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos;
+ * - criação do Lenis (opções, ligação com o ScrollTrigger), relógio que dorme e acorda, desmontagem antes das
+ * importações terminarem, ref vazia, e o ResizeObserver que remede as passagens com atraso.
+ * GSAP, ScrollTrigger e Lenis são trocados por fakes que registram o que recebem.
+ */
+
+type Call = unknown[];
+const mockTimelines: Array<{ calls: Call[] }> = [];
+const mockTicker = {
+ add: jest.fn(),
+ remove: jest.fn(),
+ lagSmoothing: jest.fn(),
+ time: 0,
+};
+const mockLenis: MockLenis[] = [];
+
+class MockLenis {
+ isScrolling: false | "smooth" = false;
+ on = jest.fn();
+ raf = jest.fn();
+ scrollTo = jest.fn();
+ destroy = jest.fn();
+ constructor(public options: unknown) {
+ mockLenis.push(this);
+ }
+}
+
+jest.mock("gsap", () => ({
+ gsap: {
+ registerPlugin: jest.fn(),
+ ticker: mockTicker,
+ context: (fn: () => void) => {
+ fn();
+ return { revert: jest.fn() };
+ },
+ timeline: () => {
+ const record = { calls: [] as Call[] };
+ mockTimelines.push(record);
+ const chain = {
+ fromTo: (...args: Call) => {
+ record.calls.push(args);
+ return chain;
+ },
+ };
+ return chain;
+ },
+ },
+}));
+jest.mock("gsap/ScrollTrigger", () => ({
+ ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
+}));
+jest.mock("lenis", () => ({
+ __esModule: true,
+ get default() {
+ return MockLenis;
+ },
+}));
+
+const TOPS: Record = {
+ home: 0,
+ projects: 1800,
+ "projects-title": 1900,
+ edge: 2400,
+ contact: 25000,
+ "footer-note": 26000,
+};
+
+function Home({ attach = true }: { attach?: boolean }) {
+ const ref = useRef(null);
+ useCinematicScroll(ref);
+ return (
+ <>
+
+
+ contato
+ projetos
+ titulo dos projetos
+ limite
+ nota fora da cena
+ fantasma
+ malformado
+ outra pagina
+ outro site
+ sem ancora
+ ancora vazia
+
+ nova aba
+
+
+ mesma aba
+
+
+
texto
+
+
+
+ Projetos
+
+
+
+
Limite
+
+ {/* biome-ignore lint/a11y/noNoninteractiveTabindex: alvo de âncora que já traz tabindex, para testar que ele é mantido */}
+
+
Contato
+
+
+
+ Nota
+
+ >
+ );
+}
+
+const originalMatchMedia = window.matchMedia;
+const OriginalResizeObserver = global.ResizeObserver;
+const originalOffsetTop = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetTop",
+);
+
+function mockMedia(active: string[]) {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: active.includes(query),
+ media: query,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+}
+
+async function flushImports() {
+ await act(async () => {
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ });
+}
+
+async function mount(props: { attach?: boolean } = {}) {
+ const view = render();
+ await flushImports();
+ return view;
+}
+
+function link(text: string) {
+ const found = [...document.querySelectorAll("a, svg a")].find(
+ (element) => element.textContent === text,
+ );
+ if (!found) throw new Error(`Missing link ${text}`);
+ return found as Element;
+}
+
+/**
+ * Dispara um clique e diz se o hook cancelou a ação padrão. A ação padrão do navegador (navegar) é cancelada
+ * por um ouvinte na fase de bolha, depois de o hook ter decidido, para o jsdom não navegar de verdade.
+ */
+function click(
+ target: Element | Document,
+ init: MouseEventInit = {},
+): { prevented: boolean } {
+ const event = new MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ ...init,
+ });
+ let prevented = false;
+ const observe = (clickEvent: Event) => {
+ prevented = clickEvent.defaultPrevented;
+ clickEvent.preventDefault();
+ };
+ window.addEventListener("click", observe);
+ try {
+ act(() => {
+ target.dispatchEvent(event);
+ });
+ } finally {
+ window.removeEventListener("click", observe);
+ }
+ return { prevented };
+}
+
+function mockHeaderOffset(px: string) {
+ jest
+ .spyOn(window, "getComputedStyle")
+ .mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
+}
+
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, "offsetTop", {
+ configurable: true,
+ get(this: HTMLElement) {
+ return Number(this.dataset.top ?? 0);
+ },
+ });
+});
+
+afterAll(() => {
+ if (originalOffsetTop)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "offsetTop",
+ originalOffsetTop,
+ );
+});
+
+beforeEach(() => {
+ mockTimelines.length = 0;
+ mockLenis.length = 0;
+ mockTicker.add.mockClear();
+ mockTicker.remove.mockClear();
+ mockTicker.lagSmoothing.mockClear();
+ mockTicker.time = 0;
+ jest.mocked(ScrollTrigger.update).mockClear();
+ jest.mocked(ScrollTrigger.refresh).mockClear();
+ mockMedia([]);
+ window.history.replaceState(null, "", "/");
+ jest.spyOn(window, "scrollTo").mockImplementation(() => {});
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+ Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
+});
+
+afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ global.ResizeObserver = OriginalResizeObserver;
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+});
+
+describe("useCinematicScroll: cliques que voltam ao navegador", () => {
+ it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
+ "path: click with %s held -> left alone (new tab / selection behavior)",
+ async (modifier) => {
+ await mount();
+
+ const { prevented } = click(link("contato"), { [modifier]: true });
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([1, 2])(
+ "path: click with mouse button %i -> left alone",
+ async (button) => {
+ await mount();
+
+ const { prevented } = click(link("contato"), { button });
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ },
+ );
+
+ it("path: another handler already cancelled the click -> not handled again", async () => {
+ await mount();
+ const cancel = (event: Event) => event.preventDefault();
+ window.addEventListener("click", cancel, true);
+ try {
+ click(link("contato"));
+ } finally {
+ window.removeEventListener("click", cancel, true);
+ }
+
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: click on text that is not inside a link -> ignored", async () => {
+ await mount();
+
+ const { prevented } = click(document.getElementById("plain") as Element);
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: the click target is the document itself (not an element) -> ignored", async () => {
+ await mount();
+
+ const { prevented } = click(document);
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: the closest link is an SVG anchor -> ignored", async () => {
+ await mount();
+
+ const { prevented } = click(
+ document.querySelector("svg a text") as Element,
+ );
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: link opens in a new tab -> ignored", async () => {
+ await mount();
+
+ const { prevented } = click(link("nova aba"));
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: link with target=_self -> handled like a plain link", async () => {
+ await mount();
+
+ const { prevented } = click(link("mesma aba"));
+
+ expect(prevented).toBe(true);
+ expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ["to another site", "outro site"],
+ ["to another page of this site", "outra pagina"],
+ ["without an anchor", "sem ancora"],
+ ["with an empty anchor (#)", "ancora vazia"],
+ ])("path: link %s -> ignored", async (_label, text) => {
+ await mount();
+
+ const { prevented } = click(link(text));
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: anchor with broken percent-encoding -> no error, left to the browser", async () => {
+ await mount();
+
+ const { prevented } = click(link("malformado"));
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("path: anchor to an id that does not exist -> left to the browser", async () => {
+ await mount();
+
+ const { prevented } = click(link("fantasma"));
+
+ expect(prevented).toBe(false);
+ expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
+ });
+});
+
+describe("useCinematicScroll: para onde a página vai", () => {
+ it("path: target inside a scene -> goes to the target minus the fixed header, never above the scene top", async () => {
+ await mount();
+ mockHeaderOffset("80px");
+
+ click(link("titulo dos projetos"));
+
+ expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900 - 80);
+ });
+
+ it("path: header taller than the gap to the scene top -> clamped to the scene top", async () => {
+ await mount();
+ mockHeaderOffset("300px");
+
+ click(link("titulo dos projetos"));
+
+ expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
+ });
+
+ it("path: target is the whole scene -> the scene top, ignoring the header offset", async () => {
+ await mount();
+ mockHeaderOffset("80px");
+
+ click(link("projetos"));
+
+ expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
+ });
+
+ it("path: target outside every scene -> measured from the page top, minus the header", async () => {
+ await mount();
+ mockHeaderOffset("100px");
+
+ const { prevented } = click(link("nota fora da cena"));
+
+ expect(prevented).toBe(true);
+ expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(26000 - 100);
+ });
+
+ it("path: no readable header offset -> treated as zero", async () => {
+ await mount();
+ mockHeaderOffset("");
+
+ click(link("titulo dos projetos"));
+
+ expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900);
+ });
+});
+
+describe("useCinematicScroll: histórico e foco", () => {
+ it("path: a new anchor -> pushes it to the history and cancels the default jump", async () => {
+ await mount();
+ const push = jest.spyOn(window.history, "pushState");
+
+ const { prevented } = click(link("projetos"));
+
+ expect(prevented).toBe(true);
+ expect(push).toHaveBeenCalledWith(null, "", "#projects");
+ expect(window.location.hash).toBe("#projects");
+ });
+
+ it("path: the anchor is already the current one -> the history is not touched, the page still scrolls", async () => {
+ window.history.replaceState(null, "", "/#projects");
+ await mount();
+ const push = jest.spyOn(window.history, "pushState");
+
+ click(link("projetos"));
+
+ expect(push).not.toHaveBeenCalled();
+ expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: destination without tabindex -> gets tabindex -1 and the focus (no scroll jump)", async () => {
+ await mount();
+ const focus = jest.spyOn(HTMLElement.prototype, "focus");
+ const target = document.getElementById("projects-title") as HTMLElement;
+ expect(target.hasAttribute("tabindex")).toBe(false);
+
+ click(link("titulo dos projetos"));
+
+ expect(target.getAttribute("tabindex")).toBe("-1");
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ expect(document.activeElement).toBe(target);
+ });
+
+ it("path: destination that already has a tabindex -> kept as it was", async () => {
+ await mount();
+ const target = document.getElementById("contact") as HTMLElement;
+ expect(target.getAttribute("tabindex")).toBe("0");
+
+ click(link("contato"));
+
+ expect(target.getAttribute("tabindex")).toBe("0");
+ expect(document.activeElement).toBe(target);
+ });
+});
+
+describe("useCinematicScroll: salto direto x rolagem curta", () => {
+ it("path: anchor exactly 3 screens away -> still animated, capped at 0.8 s", async () => {
+ await mount();
+
+ click(link("limite")); // 2400 px = 3 telas de 800 px
+
+ const [, options] = mockLenis[0].scrollTo.mock.calls[0];
+ expect(options.immediate).toBe(false);
+ expect(options.duration).toBe(0.8);
+ });
+
+ it("path: anchor just beyond 3 screens -> instant jump with zero duration", async () => {
+ await mount();
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 799,
+ });
+
+ click(link("limite")); // 2400 px passam de 3 x 799
+
+ const [, options] = mockLenis[0].scrollTo.mock.calls[0];
+ expect(options.immediate).toBe(true);
+ expect(options.duration).toBe(0);
+ });
+
+ it("path: anchor already at the current position -> a minimal animation (0.3 s), not a jump", async () => {
+ Object.defineProperty(window, "scrollY", {
+ configurable: true,
+ value: 1800,
+ });
+ await mount();
+
+ click(link("projetos"));
+
+ const [top, options] = mockLenis[0].scrollTo.mock.calls[0];
+ expect(top).toBe(1800);
+ expect(options.immediate).toBe(false);
+ expect(options.duration).toBeCloseTo(0.3, 9);
+ });
+
+ it("path: scrolled far down, a near anchor above -> distance counts in absolute value", async () => {
+ Object.defineProperty(window, "scrollY", {
+ configurable: true,
+ value: 2400,
+ });
+ await mount();
+
+ click(link("projetos")); // 600 px acima
+
+ const [, options] = mockLenis[0].scrollTo.mock.calls[0];
+ expect(options.immediate).toBe(false);
+ expect(options.duration).toBeCloseTo(0.3 + (600 / 800) * 0.2, 9);
+ });
+
+ it("path: touch screen (no Lenis) -> the native scroll is used: smooth when near, instant when far", async () => {
+ mockMedia(["(pointer: coarse)"]);
+ await mount();
+
+ click(link("projetos"));
+ click(link("contato"));
+
+ expect(window.scrollTo).toHaveBeenNthCalledWith(1, {
+ top: 1800,
+ behavior: "smooth",
+ });
+ expect(window.scrollTo).toHaveBeenNthCalledWith(2, {
+ top: 25000,
+ behavior: "instant",
+ });
+ });
+});
+
+describe("useCinematicScroll: Lenis e o relógio do GSAP", () => {
+ it("path: mouse -> Lenis gets the smoothing options and drives ScrollTrigger; the GSAP lag smoothing is turned off", async () => {
+ await mount();
+
+ expect(mockLenis).toHaveLength(1);
+ expect(mockLenis[0].options).toEqual({
+ lerp: 0.075,
+ wheelMultiplier: 0.85,
+ });
+ expect(mockLenis[0].on).toHaveBeenCalledWith(
+ "scroll",
+ ScrollTrigger.update,
+ );
+ expect(mockTicker.lagSmoothing).toHaveBeenCalledWith(0);
+ expect(mockTicker.add).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: touch -> no Lenis, no ticker, no lag-smoothing change and the passages are still created", async () => {
+ mockMedia(["(pointer: coarse)"]);
+ await mount();
+
+ expect(mockLenis).toHaveLength(0);
+ expect(mockTicker.add).not.toHaveBeenCalled();
+ expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
+ expect(mockTimelines.length).toBeGreaterThan(0);
+ });
+
+ it("path: touch -> window scroll events do not touch the clock", async () => {
+ mockMedia(["(pointer: coarse)"]);
+ await mount();
+
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ window.dispatchEvent(new Event("wheel"));
+ });
+
+ expect(mockTicker.add).not.toHaveBeenCalled();
+ });
+
+ it("path: the clock went to sleep, then a native scroll event -> wakes it", async () => {
+ await mount();
+ const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
+ tick(IDLE_SECONDS + 1); // parado há mais que o tempo de espera
+ expect(mockTicker.remove).toHaveBeenCalledTimes(1);
+ mockTicker.time = 5;
+
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ });
+
+ expect(mockTicker.add).toHaveBeenCalledTimes(2);
+ });
+
+ it("path: the clock went to sleep, then an anchor click -> wakes it before scrolling", async () => {
+ await mount();
+ const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
+ tick(IDLE_SECONDS + 1);
+
+ click(link("projetos"));
+
+ expect(mockTicker.add).toHaveBeenCalledTimes(2);
+ expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: the clock is already asleep and the page unmounts -> it is not removed twice", async () => {
+ const view = await mount();
+ const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
+ tick(IDLE_SECONDS + 1);
+ expect(mockTicker.remove).toHaveBeenCalledTimes(1);
+
+ view.unmount();
+
+ expect(mockTicker.remove).toHaveBeenCalledTimes(1);
+ expect(mockLenis[0].destroy).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: Lenis still scrolling -> the tick keeps the clock awake and feeds it in milliseconds", async () => {
+ await mount();
+ const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
+ mockLenis[0].isScrolling = "smooth";
+
+ tick(IDLE_SECONDS * 10);
+ tick(IDLE_SECONDS * 10 + IDLE_SECONDS / 2);
+
+ expect(mockLenis[0].raf).toHaveBeenLastCalledWith(
+ (IDLE_SECONDS * 10 + IDLE_SECONDS / 2) * 1000,
+ );
+ expect(mockTicker.remove).not.toHaveBeenCalled();
+ });
+});
+
+describe("useCinematicScroll: montagem e desmontagem", () => {
+ it("path: unmounted before the dynamic imports finish -> nothing is created", async () => {
+ const view = render();
+ view.unmount();
+ await flushImports();
+
+ expect(mockTimelines).toHaveLength(0);
+ expect(mockLenis).toHaveLength(0);
+ expect(mockTicker.add).not.toHaveBeenCalled();
+ });
+
+ it("path: ref not attached to an element -> the hook does nothing", async () => {
+ await mount({ attach: false });
+
+ expect(mockTimelines).toHaveLength(0);
+ expect(mockLenis).toHaveLength(0);
+ });
+
+ it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
+ mockMedia(["(prefers-reduced-motion: reduce)"]);
+ await mount();
+
+ const { prevented } = click(link("projetos"));
+
+ expect(prevented).toBe(false);
+ expect(mockLenis).toHaveLength(0);
+ });
+
+ it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
+ const view = await mount();
+ const removeDocument = jest.spyOn(document, "removeEventListener");
+ const removeWindow = jest.spyOn(window, "removeEventListener");
+
+ view.unmount();
+
+ expect(removeDocument).toHaveBeenCalledWith(
+ "click",
+ expect.any(Function),
+ true,
+ );
+ expect(removeWindow.mock.calls.map(([type]) => type)).toEqual(
+ expect.arrayContaining(["wheel", "scroll"]),
+ );
+ });
+});
+
+describe("useCinematicScroll: remedida depois de a página mudar de altura", () => {
+ type Resize = { callback: () => void; disconnect: jest.Mock };
+ let resizers: Resize[] = [];
+
+ beforeEach(() => {
+ resizers = [];
+ global.ResizeObserver = class {
+ private readonly record: Resize;
+ constructor(callback: () => void) {
+ this.record = { callback, disconnect: jest.fn() };
+ resizers.push(this.record);
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {
+ this.record.disconnect();
+ }
+ } as unknown as typeof ResizeObserver;
+ });
+
+ it("path: several size changes in a row -> one refresh, 150 ms after the last", async () => {
+ jest.useFakeTimers();
+ await mount();
+
+ act(() => {
+ resizers[0].callback();
+ jest.advanceTimersByTime(100);
+ resizers[0].callback();
+ jest.advanceTimersByTime(149);
+ });
+ expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
+
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(ScrollTrigger.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("path: unmount with a refresh pending -> the refresh never happens and the observer is disconnected", async () => {
+ jest.useFakeTimers();
+ const view = await mount();
+ act(() => {
+ resizers[0].callback();
+ });
+
+ view.unmount();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
+ expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/cinematic/useCinematicScroll.test.tsx b/app/components/cinematic/useCinematicScroll.test.tsx
new file mode 100644
index 0000000..3d52f51
--- /dev/null
+++ b/app/components/cinematic/useCinematicScroll.test.tsx
@@ -0,0 +1,517 @@
+import { act, render } from "@testing-library/react";
+import { useRef } from "react";
+import {
+ anchorDuration,
+ ENTER_OFFSET,
+ enterScene,
+ exitScene,
+ IDLE_SECONDS,
+ JUMP_SCREENS,
+ MAX_ANCHOR_SECONDS,
+ PASSAGE_END,
+ useCinematicScroll,
+} from "./useCinematicScroll";
+
+/* ------------------------------------------------------------------------------------------------
+ * Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
+ * ------------------------------------------------------------------------------------------------ */
+
+// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
+const { gsap: realGsap } = jest.requireActual("gsap");
+
+function scene() {
+ const element = document.createElement("section");
+ document.body.append(element);
+ return element;
+}
+
+function timeline() {
+ return realGsap.timeline({
+ paused: true,
+ defaults: { ease: "none", duration: 1 },
+ });
+}
+
+const translateY = (element: HTMLElement) =>
+ Number(
+ /translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec(
+ element.style.transform,
+ )?.[1] ?? Number.NaN,
+ );
+
+describe("the single passage between scenes", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("brings the next scene in with a fade and a short vertical offset, nothing else", () => {
+ expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24);
+ expect(ENTER_OFFSET).toBeLessThanOrEqual(40);
+
+ const next = scene();
+ const tl = timeline();
+ enterScene(tl, next);
+
+ tl.progress(0.0001);
+ expect(Number(next.style.opacity)).toBeLessThan(0.01);
+ expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0);
+
+ tl.progress(0.5);
+ expect(Number(next.style.opacity)).toBeGreaterThan(0.2);
+ expect(Number(next.style.opacity)).toBeLessThan(1);
+ expect(translateY(next)).toBeGreaterThan(0);
+ expect(translateY(next)).toBeLessThan(ENTER_OFFSET);
+
+ // sem recorte, escala, giro nem visibility em nenhum ponto
+ for (const progress of [0.0001, 0.3, 0.7]) {
+ tl.progress(progress);
+ expect(next.style.clipPath).toBe("");
+ expect(next.style.visibility).toBe("");
+ expect(next.style.transform).not.toMatch(/scale|rotate/);
+ }
+ });
+
+ it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => {
+ const next = scene();
+ const tl = timeline();
+ enterScene(tl, next);
+
+ tl.progress(1);
+ expect(next.style.opacity).toBe("");
+ expect(next.style.transform).toBe("");
+
+ tl.progress(0.4);
+ expect(Number(next.style.opacity)).toBeLessThan(1);
+ expect(next.style.transform).not.toBe("");
+ });
+
+ it("fades the previous scene all the way to opacity 0 at the end of every passage", () => {
+ const previous = scene();
+ const tl = timeline();
+ exitScene(tl, previous);
+
+ tl.progress(0.5);
+ expect(Number(previous.style.opacity)).toBeGreaterThan(0);
+ expect(Number(previous.style.opacity)).toBeLessThan(1);
+ expect(previous.style.visibility).toBe("");
+ expect(previous.style.clipPath).toBe("");
+
+ tl.progress(1);
+ expect(previous.style.opacity).toBe("0");
+
+ // rolando de volta, a cena reaparece inteira
+ tl.progress(0.0001);
+ expect(Number(previous.style.opacity)).toBeGreaterThan(0.99);
+ });
+
+ it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => {
+ const middle = scene();
+ const arrive = timeline();
+ const leave = timeline();
+ enterScene(arrive, middle);
+ exitScene(leave, middle);
+
+ arrive.progress(1);
+ expect(middle.style.opacity).toBe("");
+ leave.progress(1);
+ expect(middle.style.opacity).toBe("0");
+ leave.progress(0);
+ arrive.progress(0.2);
+ expect(Number(middle.style.opacity)).toBeLessThan(0.5);
+ });
+});
+
+describe("anchor travel", () => {
+ it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
+ expect(MAX_ANCHOR_SECONDS).toBe(0.8);
+ const viewport = 800;
+ expect(anchorDuration(25000, viewport)).toBeNull();
+ expect(anchorDuration(-25000, viewport)).toBeNull();
+ expect(
+ anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
+ ).toBeNull();
+
+ for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
+ const seconds = anchorDuration(distance, viewport);
+ expect(seconds).not.toBeNull();
+ expect(seconds as number).toBeGreaterThan(0);
+ expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
+ }
+ });
+});
+
+/* ------------------------------------------------------------------------------------------------
+ * O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem.
+ * ------------------------------------------------------------------------------------------------ */
+
+type Call = unknown[];
+const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = [];
+const mockTicker = {
+ add: jest.fn(),
+ remove: jest.fn(),
+ lagSmoothing: jest.fn(),
+ time: 0,
+};
+const mockLenis: MockLenis[] = [];
+
+interface ScrollTriggerLike {
+ scrollTrigger: {
+ trigger: HTMLElement;
+ start: () => number;
+ end: () => number;
+ scrub: boolean;
+ onEnter?: () => void;
+ };
+}
+
+class MockLenis {
+ isScrolling: false | "smooth" = false;
+ on = jest.fn();
+ raf = jest.fn();
+ scrollTo = jest.fn();
+ destroy = jest.fn();
+ constructor(public options: unknown) {
+ mockLenis.push(this);
+ }
+}
+
+jest.mock("gsap", () => ({
+ gsap: {
+ registerPlugin: jest.fn(),
+ ticker: mockTicker,
+ context: (fn: () => void) => {
+ fn();
+ return { revert: jest.fn() };
+ },
+ timeline: (vars: ScrollTriggerLike) => {
+ const record = { vars, calls: [] as Call[] };
+ mockTimelines.push(record);
+ const chain = {
+ fromTo: (...args: Call) => {
+ record.calls.push(args);
+ return chain;
+ },
+ };
+ return chain;
+ },
+ },
+}));
+jest.mock("gsap/ScrollTrigger", () => ({
+ ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
+}));
+jest.mock("lenis", () => ({
+ __esModule: true,
+ get default() {
+ return MockLenis;
+ },
+}));
+
+const TOPS: Record = {
+ home: 0,
+ projects: 1800,
+ short: 3000,
+ about: 3300,
+ contact: 25000,
+};
+
+function Home() {
+ const ref = useRef(null);
+ useCinematicScroll(ref);
+ return (
+
+
+ Conversar
+ Projetos
+
+
+
Projetos
+
+ {/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
+
+
Curta
+
+
+
Sobre
+
+
+
Contato
+
+
+ );
+}
+
+const originalMatchMedia = window.matchMedia;
+const originalOffsetTop = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "offsetTop",
+);
+
+function mockMedia(active: string[]) {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: active.includes(query),
+ media: query,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+}
+
+async function mount() {
+ const view = render();
+ // as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
+ await act(async () => {
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ });
+ return view;
+}
+
+const scenes = () =>
+ Array.from(document.querySelectorAll("main > section"));
+
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, "offsetTop", {
+ configurable: true,
+ get(this: HTMLElement) {
+ return Number(this.dataset.top ?? 0);
+ },
+ });
+});
+
+afterAll(() => {
+ if (originalOffsetTop)
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "offsetTop",
+ originalOffsetTop,
+ );
+});
+
+beforeEach(() => {
+ mockTimelines.length = 0;
+ mockLenis.length = 0;
+ mockTicker.add.mockClear();
+ mockTicker.remove.mockClear();
+ mockTicker.lagSmoothing.mockClear();
+ mockTicker.time = 0;
+ mockMedia([]);
+ window.history.replaceState(null, "", "/");
+ jest.spyOn(window, "scrollTo").mockImplementation(() => {});
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: 800,
+ });
+});
+
+afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ jest.restoreAllMocks();
+});
+
+describe("useCinematicScroll", () => {
+ it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
+ await mount();
+ for (const element of scenes()) {
+ expect(element.style.visibility).toBe("");
+ expect(element.style.position).toBe("");
+ expect(element.style.top).toBe("");
+ expect(element.style.opacity).toBe("");
+ expect(element.style.clipPath).toBe("");
+ }
+ // o texto do contato continua achável (Ctrl+F) e visível para leitores de tela
+ const contact = document.getElementById("contact") as HTMLElement;
+ expect(contact).toBeVisible();
+ expect(contact).toHaveTextContent("Contato");
+ expect(contact.closest("[aria-hidden]")).toBeNull();
+ });
+
+ it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => {
+ await mount();
+ const allowed = new Set([
+ "opacity",
+ "y",
+ "ease",
+ "clearProps",
+ "immediateRender",
+ ]);
+ const entering: HTMLElement[] = [];
+ const leaving: HTMLElement[] = [];
+
+ for (const { vars, calls } of mockTimelines) {
+ expect(vars.scrollTrigger.scrub).toBe(true);
+ expect(calls).toHaveLength(1);
+ const [target, from, to] = calls[0] as [
+ HTMLElement,
+ Record,
+ Record,
+ ];
+ for (const key of [...Object.keys(from), ...Object.keys(to)])
+ expect(allowed.has(key)).toBe(true);
+ expect(JSON.stringify([from, to])).not.toMatch(
+ /autoAlpha|visibility|clipPath|scale|rotation|xPercent/,
+ );
+ if (to.opacity === 1) {
+ entering.push(target);
+ expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET });
+ expect(vars.scrollTrigger.trigger).toBe(target);
+ } else {
+ leaving.push(target);
+ expect(from).toMatchObject({ opacity: 1 });
+ expect(to.opacity).toBe(0);
+ }
+ }
+
+ const all = scenes();
+ // todas entram, menos a primeira; todas saem, menos a última
+ expect(entering).toEqual(all.slice(1));
+ expect(leaving).toEqual(all.slice(0, -1));
+ });
+
+ it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => {
+ await mount();
+ const all = scenes();
+ const ranges = new Map<
+ HTMLElement,
+ { enter?: [number, number]; exit?: [number, number] }
+ >();
+ for (const { vars, calls } of mockTimelines) {
+ const target = calls[0][0] as HTMLElement;
+ const to = calls[0][2] as { opacity: number };
+ const { start, end } = vars.scrollTrigger;
+ const entry = ranges.get(target) ?? {};
+ if (to.opacity === 1) entry.enter = [start(), end()];
+ else entry.exit = [start(), end()];
+ ranges.set(target, entry);
+ }
+
+ const vh = window.innerHeight;
+ for (const element of all.slice(1)) {
+ const top = TOPS[element.id];
+ expect(ranges.get(element)?.enter).toEqual([
+ top - vh,
+ top - vh * PASSAGE_END,
+ ]);
+ }
+ for (const element of all.slice(0, -1)) {
+ const { enter, exit } = ranges.get(element) ?? {};
+ expect(exit).toBeDefined();
+ const [start, end] = exit as [number, number];
+ expect(start).toBeLessThan(end);
+ // termina quando o topo da cena seguinte chegou ao fim da passagem dela
+ const next = all[all.indexOf(element) + 1];
+ expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END);
+ if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]);
+ }
+ // a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes
+ const short = ranges.get(document.getElementById("short") as HTMLElement);
+ expect(short?.exit?.[0]).toBe(short?.enter?.[1]);
+ });
+
+ it("does no scroll work for users who prefer reduced motion", async () => {
+ mockMedia(["(prefers-reduced-motion: reduce)"]);
+ await mount();
+ expect(mockTimelines).toHaveLength(0);
+ expect(mockLenis).toHaveLength(0);
+ expect(mockTicker.add).not.toHaveBeenCalled();
+ });
+
+ describe("smooth scroll", () => {
+ it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
+ mockMedia(["(pointer: coarse)"]);
+ await mount();
+ expect(mockLenis).toHaveLength(0);
+ expect(mockTicker.add).not.toHaveBeenCalled();
+ expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
+ expect(mockTimelines.length).toBeGreaterThan(0);
+ });
+
+ it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
+ await mount();
+ expect(mockLenis).toHaveLength(1);
+ const lenis = mockLenis[0];
+ expect(mockTicker.add).toHaveBeenCalledTimes(1);
+ const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
+
+ // rolando: segue ligado e alimenta o Lenis em milissegundos
+ lenis.isScrolling = "smooth";
+ tick(0.5);
+ expect(lenis.raf).toHaveBeenLastCalledWith(500);
+ expect(mockTicker.remove).not.toHaveBeenCalled();
+
+ // parado há mais que o tempo de espera: o relógio é solto
+ lenis.isScrolling = false;
+ tick(0.5 + IDLE_SECONDS / 2);
+ expect(mockTicker.remove).not.toHaveBeenCalled();
+ tick(0.5 + IDLE_SECONDS + 0.1);
+ expect(mockTicker.remove).toHaveBeenCalledTimes(1);
+ expect(mockTicker.remove).toHaveBeenCalledWith(tick);
+
+ // a roda do mouse o acorda de novo
+ mockTicker.time = 10;
+ act(() => {
+ window.dispatchEvent(new Event("wheel"));
+ });
+ expect(mockTicker.add).toHaveBeenCalledTimes(2);
+ // e uma roda seguinte, com o relógio já ligado, não o liga duas vezes
+ act(() => {
+ window.dispatchEvent(new Event("wheel"));
+ });
+ expect(mockTicker.add).toHaveBeenCalledTimes(2);
+ });
+
+ it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
+ await mount();
+ const lenis = mockLenis[0];
+
+ act(() => {
+ (document.querySelector('a[href="/#contact"]') as HTMLElement).click();
+ });
+ expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
+ const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
+ expect(farTop).toBe(TOPS.contact);
+ expect(farOptions).toMatchObject({ immediate: true });
+ expect(window.location.hash).toBe("#contact");
+ expect(document.getElementById("contact")).toHaveFocus();
+
+ act(() => {
+ (document.querySelector('a[href="/#projects"]') as HTMLElement).click();
+ });
+ const [nearTop, nearOptions] = lenis.scrollTo.mock.calls[1];
+ expect(nearTop).toBe(TOPS.projects);
+ expect(nearOptions.immediate).toBe(false);
+ expect(nearOptions.duration).toBeGreaterThan(0);
+ expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
+ });
+
+ it("without Lenis (touch) a far anchor is an instant jump", async () => {
+ mockMedia(["(pointer: coarse)"]);
+ await mount();
+ act(() => {
+ (document.querySelector('a[href="/#contact"]') as HTMLElement).click();
+ });
+ expect(window.scrollTo).toHaveBeenCalledWith({
+ top: TOPS.contact,
+ behavior: "instant",
+ });
+
+ act(() => {
+ (document.querySelector('a[href="/#projects"]') as HTMLElement).click();
+ });
+ expect(window.scrollTo).toHaveBeenLastCalledWith({
+ top: TOPS.projects,
+ behavior: "smooth",
+ });
+ });
+
+ it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
+ const view = await mount();
+ const lenis = mockLenis[0];
+ view.unmount();
+ expect(lenis.destroy).toHaveBeenCalledTimes(1);
+ expect(mockTicker.remove).toHaveBeenCalled();
+ mockTicker.add.mockClear();
+ act(() => {
+ window.dispatchEvent(new Event("wheel"));
+ });
+ expect(mockTicker.add).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts
index cecc319..fd9edc2 100644
--- a/app/components/cinematic/useCinematicScroll.ts
+++ b/app/components/cinematic/useCinematicScroll.ts
@@ -1,40 +1,41 @@
"use client";
import { type RefObject, useEffect } from "react";
-
-type Transition = (
- tl: gsap.core.Timeline,
- prev: HTMLElement,
- next: HTMLElement,
- touch: boolean,
-) => void;
+import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
/*
- * 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").
+ * Rolagem como um vídeo: o GSAP liga cada passagem de uma cena para a seguinte à posição da rolagem (rolar
+ * para cima volta o "vídeo") e, no mouse, o Lenis suaviza a roda.
*
- * Quem segura as seções paradas durante uma passagem:
- * - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
- * dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
- * - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
- * rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
- * chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
- * dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
+ * Uma única passagem para a home inteira, só com opacidade e um deslocamento vertical curto:
+ * - a cena que entra chega com fade e sobe ENTER_OFFSET px até o lugar dela;
+ * - a cena que sai some com fade e termina SEMPRE em opacidade 0 (nenhuma cena anterior fica "fantasma" atrás
+ * das seguintes);
+ * - nada de recorte (clip-path), escala ou rotação, e nenhuma cena é escondida com visibility: o conteúdo
+ * nasce visível no HTML do servidor e a opacidade só muda por rolagem (Ctrl+F e leitores de tela enxergam
+ * tudo, e sem JavaScript tudo aparece).
+ * As cenas ficam no fluxo normal da página, uma depois da outra. A única rolagem "presa" é a do CSS: o hero
+ * (sticky) e a vitrine de projetos (sticky); este arquivo não prende nada.
*
- * Regras que mantêm tudo estável:
- * - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
- * - nenhum ancestral de um