perf(movimento): sem tela vazia, passagens em CSS e vitrine leve

Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:50 -03:00
1 parent 10f170abf4
commit 318a764190
77 files changed
+4293 -4951

No files matched your search

@@ -1,6 +1,68 @@
import { act, render } from "@testing-library/react";
import FilmGrain from "./FilmGrain";
/* IntersectionObserver controlado: o teste decide quando cada tela de projeto entra ou sai da janela. */
type Observer = {
callback: IntersectionObserverCallback;
observed: Set<Element>;
disconnect: jest.Mock;
};
let observers: Observer[] = [];
const original = global.IntersectionObserver;
beforeEach(() => {
observers = [];
global.IntersectionObserver = class {
private readonly record: Observer;
constructor(callback: IntersectionObserverCallback) {
this.record = { callback, observed: new Set(), disconnect: jest.fn() };
observers.push(this.record);
}
observe(target: Element) {
this.record.observed.add(target);
}
unobserve(target: Element) {
this.record.observed.delete(target);
}
disconnect() {
this.record.disconnect();
}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
});
afterEach(() => {
global.IntersectionObserver = original;
document.body.innerHTML = "";
});
function addScreen(className: string) {
const element = document.createElement("div");
element.className = className;
document.body.append(element);
return element;
}
/** O navegador avisa que `target` entrou (ou saiu) da janela; `height` 0 é uma tela escondida por CSS. */
function intersect(target: Element, isIntersecting: boolean, height = 600) {
act(() => {
for (const observer of observers)
if (observer.observed.has(target))
observer.callback(
[
{
target,
isIntersecting,
boundingClientRect: { height },
} as unknown as IntersectionObserverEntry,
],
{} as IntersectionObserver,
);
});
}
describe("static cinematic effects", () => {
it("renders grain without a canvas", () => {
const grain = render(<FilmGrain />);
@@ -13,62 +75,36 @@ describe("static cinematic effects", () => {
});
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 reel = addScreen("ds-reel");
const { container } = render(<FilmGrain />);
const grain = container.firstElementChild as HTMLElement;
expect(grain).not.toHaveAttribute("data-over-projects");
scroll(300); // a vitrine entrou na janela
intersect(reel, true); // a vitrine entrou na janela
expect(grain).toHaveAttribute("data-over-projects");
scroll(-900); // passou: a cena acabou 300 px acima do topo
intersect(reel, false); // passou
expect(grain).not.toHaveAttribute("data-over-projects");
});
it("also covers the browser and phone frames", () => {
addScreen("ds-bframe");
const { container } = render(<FilmGrain />);
scroll(100);
expect(container.firstElementChild).toHaveAttribute("data-over-projects");
it("also covers the browser and phone frames and the card screens", () => {
for (const className of ["ds-bframe", "ds-phone", "ds-frame"]) {
const screen = addScreen(className);
const { container, unmount } = render(<FilmGrain />);
intersect(screen, true);
expect(container.firstElementChild).toHaveAttribute("data-over-projects");
unmount();
document.body.innerHTML = "";
}
});
it("stays on when there is no project screen on the page", () => {
const { container } = render(<FilmGrain />);
scroll(100);
expect(observers[0].observed.size).toBe(0);
expect(container.firstElementChild).not.toHaveAttribute(
"data-over-projects",
);
@@ -0,0 +1,109 @@
import { act, render, screen } from "@testing-library/react";
import CinematicOverlays from "./CinematicOverlays";
/**
* Testes de caminhos-base de CinematicOverlays (complemento de CinematicOverlays.test.tsx): a troca de página
* com a página montada (de fora da home para a home e de volta) e as duas condições do grão juntas.
*/
let mockPathname = "/";
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
const mockLoaded = jest.fn();
jest.mock("./FilmGrain", () => {
mockLoaded();
return {
__esModule: true,
default: function MockGrain() {
return <div data-testid="grain" />;
},
};
});
/** O import dinâmico do grão resolve em microtarefas. */
const loaded = () =>
act(async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
});
const original = window.matchMedia;
function mockMedia(active: string[] = []) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: active.includes(query),
media: query,
addEventListener() {},
removeEventListener() {},
}));
}
beforeEach(() => {
mockPathname = "/";
mockLoaded.mockClear();
mockMedia();
});
afterEach(() => {
window.matchMedia = original;
});
describe("CinematicOverlays: troca de página", () => {
it("path: arriving on the home from another page with a mouse -> the grain and the progress bar appear", async () => {
mockPathname = "/cases";
const { container, rerender } = render(<CinematicOverlays />);
await loaded();
expect(container).toBeEmptyDOMElement();
mockPathname = "/";
rerender(<CinematicOverlays />);
await loaded();
expect(screen.getByTestId("grain")).toBeInTheDocument();
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
});
it("path: leaving the home after the grain loaded -> everything goes away, and comes back on return", async () => {
const { container, rerender } = render(<CinematicOverlays />);
await loaded();
expect(screen.getByTestId("grain")).toBeInTheDocument();
mockPathname = "/servicos";
rerender(<CinematicOverlays />);
expect(screen.queryByTestId("grain")).toBeNull();
expect(container).toBeEmptyDOMElement();
mockPathname = "/";
rerender(<CinematicOverlays />);
await loaded();
expect(screen.getByTestId("grain")).toBeInTheDocument();
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
});
it("path: a sub-route that merely starts like the home path -> not the home", async () => {
mockPathname = "/cases/sev-modaintima";
const { container } = render(<CinematicOverlays />);
await loaded();
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId("grain")).toBeNull();
});
});
describe("CinematicOverlays: condições do grão juntas", () => {
it("path: touch screen and reduced motion together -> no grain, the progress bar stays", async () => {
mockMedia(["(pointer: coarse)", "(prefers-reduced-motion: reduce)"]);
const { container } = render(<CinematicOverlays />);
await loaded();
expect(screen.queryByTestId("grain")).toBeNull();
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
});
it("path: no matchMedia in the browser -> counts as mouse with motion allowed: the grain loads", async () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
render(<CinematicOverlays />);
await loaded();
expect(screen.getByTestId("grain")).toBeInTheDocument();
});
});
@@ -0,0 +1,102 @@
import { act, render, screen } from "@testing-library/react";
import CinematicOverlays from "./CinematicOverlays";
let mockPathname = "/";
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
// o grão vem por import dinâmico (pacote próprio): aqui ele vira um marcador, e o contador diz se o pacote foi pedido
const mockLoaded = jest.fn();
jest.mock("./FilmGrain", () => {
mockLoaded();
return {
__esModule: true,
default: function MockGrain() {
return <div data-testid="grain" />;
},
};
});
/** O import dinâmico do grão resolve em microtarefas. */
const loaded = () =>
act(async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
});
const original = window.matchMedia;
function mockMedia(active: string[] = []) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: active.includes(query),
media: query,
addEventListener() {},
removeEventListener() {},
}));
}
beforeEach(() => {
mockPathname = "/";
mockMedia();
});
afterEach(() => {
window.matchMedia = original;
});
/*
* Os três primeiros testes vêm antes de qualquer um que carregue o grão: o módulo, depois de pedido uma vez, fica
* no registro do Jest, então "o pacote nunca foi pedido" só vale enquanto ninguém o pediu.
*/
describe("CinematicOverlays: where the grain package is never requested", () => {
it("does not request the grain on a touch screen (nothing invisible left measuring the page); the progress bar stays", async () => {
mockMedia(["(pointer: coarse)"]);
const { container } = render(<CinematicOverlays />);
await loaded();
expect(mockLoaded).not.toHaveBeenCalled();
expect(screen.queryByTestId("grain")).toBeNull();
// a barra de progresso é CSS puro: continua
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
});
it("does not request the grain for users who prefer reduced motion", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
render(<CinematicOverlays />);
await loaded();
expect(mockLoaded).not.toHaveBeenCalled();
expect(screen.queryByTestId("grain")).toBeNull();
});
it("shows nothing, and requests nothing, on the other pages", async () => {
mockPathname = "/cases";
const { container } = render(<CinematicOverlays />);
await loaded();
expect(mockLoaded).not.toHaveBeenCalled();
expect(screen.queryByTestId("grain")).toBeNull();
expect(container).toBeEmptyDOMElement();
});
});
describe("CinematicOverlays: with a mouse", () => {
it("shows the scroll progress at once and the film grain as soon as its package arrives", async () => {
const { container } = render(<CinematicOverlays />);
// a barra vem no HTML; o grão só depois do import
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
expect(screen.queryByTestId("grain")).toBeNull();
await loaded();
expect(screen.getByTestId("grain")).toBeInTheDocument();
expect(mockLoaded).toHaveBeenCalled();
});
it("does not mount the grain if the page unmounts while its package loads", async () => {
const { unmount } = render(<CinematicOverlays />);
unmount();
await loaded();
expect(screen.queryByTestId("grain")).toBeNull();
});
});
+24 -14
View File
@@ -1,25 +1,35 @@
"use client";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { type ComponentType, useEffect, useState } from "react";
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
import ScrollProgress from "./ScrollProgress";
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
ssr: false,
});
/**
* Efeitos por cima da home: a barra de progresso (CSS puro) e o grão de filme. O grão só existe com mouse e sem
* movimento reduzido, e é importado só nesse caso: no toque o pacote dele nem é baixado, e nada fica montado,
* invisível, medindo a tela.
*/
export default function CinematicOverlays() {
const pathname = usePathname();
const isHome = pathname === "/";
const isHome = usePathname() === "/";
const [Grain, setGrain] = useState<ComponentType | null>(null);
useEffect(() => {
if (!isHome || isCoarsePointer() || prefersReducedMotion()) return;
let alive = true;
import("./FilmGrain").then((module) => {
if (alive) setGrain(() => module.default);
});
return () => {
alive = false;
};
}, [isHome]);
if (!isHome) return null;
return (
<>
{isHome ? (
<>
<FilmGrain />
<ScrollProgress />
</>
) : null}
{Grain && <Grain />}
<ScrollProgress />
</>
);
}
+164 -82
View File
@@ -2,88 +2,134 @@ 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.
* Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): a medição é do
* IntersectionObserver (nada ouve a rolagem nem mede a página a cada quadro), uma tela escondida por CSS não
* conta, várias telas, telas que a página cria ou remove depois e a limpeza ao desmontar.
*/
const INNER_HEIGHT = 800;
type Observer = {
callback: IntersectionObserverCallback;
observed: Set<Element>;
/** quantas vezes observe() foi chamado (um conjunto esconderia repetições) */
calls: number;
disconnect: jest.Mock;
};
let observers: Observer[] = [];
const original = global.IntersectionObserver;
function addScreen(
className: string,
rect: { top: number; bottom: number; height: number },
) {
beforeEach(() => {
observers = [];
global.IntersectionObserver = class {
private readonly record: Observer;
constructor(callback: IntersectionObserverCallback) {
this.record = {
callback,
observed: new Set(),
calls: 0,
disconnect: jest.fn(),
};
observers.push(this.record);
}
observe(target: Element) {
this.record.observed.add(target);
this.record.calls += 1;
}
unobserve(target: Element) {
this.record.observed.delete(target);
}
disconnect() {
this.record.disconnect();
}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
});
afterEach(() => {
global.IntersectionObserver = original;
jest.restoreAllMocks();
document.body.innerHTML = "";
});
function addScreen(className: string) {
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,
function intersect(target: Element, isIntersecting: boolean, height = 600) {
act(() => {
for (const observer of observers)
if (observer.observed.has(target))
observer.callback(
[
{
target,
isIntersecting,
boundingClientRect: { height },
} as unknown as IntersectionObserverEntry,
],
{} as IntersectionObserver,
);
});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
document.body.innerHTML = "";
});
}
function renderGrain() {
const view = render(<FilmGrain />);
return { ...view, grain: view.container.firstElementChild as HTMLElement };
}
/** Deixa o MutationObserver (microtarefa) avisar. */
const flush = () =>
act(async () => {
await Promise.resolve();
});
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 screen = addScreen("ds-phone");
const { grain } = renderGrain();
intersect(screen, true);
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);
it("path: a screen with no height (hidden by CSS) -> does not count even if the browser says it intersects", () => {
const screen = addScreen("ds-bframe");
const { grain } = renderGrain();
intersect(screen, true, 0);
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 });
it("path: a screen that is off the window -> does not count", () => {
const screen = addScreen("ds-frame");
const { grain } = renderGrain();
intersect(screen, false);
expect(grain).not.toHaveAttribute("data-over-projects");
});
it("path: one screen off the window and another in view -> counts (any is enough), and leaves only when the last one leaves", () => {
const far = addScreen("ds-reel");
const near = addScreen("ds-phone");
const { grain } = renderGrain();
intersect(far, false);
intersect(near, true);
expect(grain).toHaveAttribute("data-over-projects");
intersect(far, true);
intersect(near, false);
expect(grain).toHaveAttribute("data-over-projects");
intersect(far, false);
expect(grain).not.toHaveAttribute("data-over-projects");
});
it("path: no project screen on the page -> the grain stays", () => {
@@ -93,47 +139,83 @@ describe("FilmGrain: is a project screen in view?", () => {
});
});
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();
describe("FilmGrain: screens that appear and disappear", () => {
it("path: a screen the page creates after the grain mounted (the pinned reel swapping the list) is observed too", async () => {
const { grain } = renderGrain();
expect(observers[0].observed.size).toBe(0);
act(() => {
window.dispatchEvent(new Event("scroll"));
window.dispatchEvent(new Event("scroll"));
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(40);
});
const reel = addScreen("ds-reel");
await flush();
expect(observers[0].observed.has(reel)).toBe(true);
expect(measure).toHaveBeenCalledTimes(1);
intersect(reel, true);
expect(grain).toHaveAttribute("data-over-projects");
});
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"));
});
it("path: a screen that is removed while in view stops counting", async () => {
const reel = addScreen("ds-reel");
const { grain } = renderGrain();
intersect(reel, true);
expect(grain).toHaveAttribute("data-over-projects");
reel.remove();
await flush();
expect(observers[0].observed.has(reel)).toBe(false);
expect(grain).not.toHaveAttribute("data-over-projects");
});
it("path: the same screen is never observed twice", async () => {
addScreen("ds-reel");
renderGrain();
expect(observers[0].calls).toBe(1);
addScreen("ds-phone");
await flush();
// só a tela nova foi pedida ao observador; a primeira não se repete
expect(observers[0].calls).toBe(2);
expect(observers[0].observed.size).toBe(2);
});
});
describe("FilmGrain: no work per frame, cleanup", () => {
it("path: nothing listens to the scroll or the window size, and the page is never measured", () => {
const screen = addScreen("ds-reel");
const measure = jest.spyOn(screen, "getBoundingClientRect");
const listen = jest.spyOn(window, "addEventListener");
renderGrain();
unmount();
measure.mockClear();
act(() => {
window.dispatchEvent(new Event("scroll"));
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(100);
});
expect(measure).not.toHaveBeenCalled();
const types = listen.mock.calls.map(([type]) => type);
expect(types).not.toContain("scroll");
expect(types).not.toContain("resize");
});
it("path: unmount -> the observers are disconnected and later screens are ignored", async () => {
const { unmount } = renderGrain();
unmount();
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
addScreen("ds-reel");
await flush();
expect(observers[0].observed.size).toBe(0);
});
it("path: a browser without IntersectionObserver -> the grain renders and simply stays", () => {
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
(global as any).IntersectionObserver = undefined;
addScreen("ds-reel");
const { grain } = renderGrain();
expect(grain).toHaveClass("film-grain");
expect(grain).not.toHaveAttribute("data-over-projects");
});
});
+44 -23
View File
@@ -5,6 +5,48 @@ 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";
/**
* Avisa `onChange(true)` enquanto qualquer tela de projeto está à vista. Usa IntersectionObserver (nada mede a
* página a cada quadro de rolagem) e um MutationObserver só para pegar as telas que a página cria ou remove
* depois (a vitrine fixa troca a lista ao hidratar). Devolve a função que desfaz tudo.
*/
function watchProjectScreens(onChange: (over: boolean) => void) {
if (typeof IntersectionObserver === "undefined") return () => {};
const watched = new Set<Element>();
const inView = new Set<Element>();
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && entry.boundingClientRect.height > 0)
inView.add(entry.target);
else inView.delete(entry.target);
}
onChange(inView.size > 0);
});
const scan = () => {
for (const screen of document.querySelectorAll(PROJECT_SCREENS)) {
if (watched.has(screen)) continue;
watched.add(screen);
observer.observe(screen);
}
for (const screen of watched) {
if (screen.isConnected) continue;
observer.unobserve(screen);
watched.delete(screen);
inView.delete(screen);
}
onChange(inView.size > 0);
};
scan();
const mutations = new MutationObserver(scan);
mutations.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
mutations.disconnect();
};
}
/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */
export default function FilmGrain() {
const ref = useRef<HTMLDivElement>(null);
@@ -12,31 +54,10 @@ export default function FilmGrain() {
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
);
},
);
return watchProjectScreens((over) => {
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 <div ref={ref} aria-hidden="true" className="film-grain" />;
+88 -12
View File
@@ -17,34 +17,110 @@ afterEach(() => {
jest.useRealTimers();
});
const advance = (ms: number) =>
act(() => {
jest.advanceTimersByTime(ms);
});
describe("FramePulse", () => {
it("asks for frames at about 30 per second while active, not 60", () => {
render(<FramePulse active />);
act(() => {
jest.advanceTimersByTime(1000);
});
advance(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(<FramePulse active={false} />);
act(() => {
jest.advanceTimersByTime(1000);
});
advance(1000);
expect(mockInvalidate).not.toHaveBeenCalled();
rerender(<FramePulse active />);
act(() => {
jest.advanceTimersByTime(200);
});
advance(200);
const running = mockInvalidate.mock.calls.length;
expect(running).toBeGreaterThan(0);
rerender(<FramePulse active={false} />);
act(() => {
jest.advanceTimersByTime(1000);
});
advance(1000);
expect(mockInvalidate.mock.calls.length).toBe(running);
});
});
describe("FramePulse: stops when nobody is looking", () => {
it("stops drawing, and stops asking for animation frames, after 5 s without a cursor or scroll", () => {
render(<FramePulse active />);
advance(4000);
const drawn = mockInvalidate.mock.calls.length;
expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo
advance(1500); // passou dos 5 s
const stopped = mockInvalidate.mock.calls.length;
// parou perto dos 5 s: no máximo 5 s de batidas a ~30 por segundo
expect(stopped).toBeLessThanOrEqual(5 * 32);
advance(3000);
expect(mockInvalidate.mock.calls.length).toBe(stopped);
// sem laço nenhum: nenhum quadro de animação pendente
expect(jest.getTimerCount()).toBe(0);
});
it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])(
"wakes up again with %s and keeps drawing",
(type) => {
render(<FramePulse active />);
advance(7000);
const stopped = mockInvalidate.mock.calls.length;
act(() => {
window.dispatchEvent(new Event(type));
});
advance(500);
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(stopped + 8);
},
);
it("each gesture buys another 5 s: it does not stop while there is movement", () => {
render(<FramePulse active />);
for (let i = 0; i < 4; i++) {
advance(3000);
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
}
const before = mockInvalidate.mock.calls.length;
advance(1000);
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(before + 20);
});
it("lets the idle time be configured", () => {
render(<FramePulse active idleMs={1000} />);
advance(1500);
const stopped = mockInvalidate.mock.calls.length;
advance(2000);
expect(mockInvalidate.mock.calls.length).toBe(stopped);
});
it("removes its listeners when it unmounts or goes inactive", () => {
const { rerender, unmount } = render(<FramePulse active />);
rerender(<FramePulse active={false} />);
mockInvalidate.mockClear();
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
advance(500);
expect(mockInvalidate).not.toHaveBeenCalled();
unmount();
act(() => {
window.dispatchEvent(new Event("scroll"));
});
advance(500);
expect(mockInvalidate).not.toHaveBeenCalled();
});
});
+31 -2
View File
@@ -3,17 +3,31 @@
import { useThree } from "@react-three/fiber";
import { useEffect } from "react";
/** Gestos que mostram alguém olhando para a página: qualquer um deles religa a batida. */
const ACTIVITY = [
"pointermove",
"pointerdown",
"scroll",
"wheel",
"touchmove",
"keydown",
] as const;
/**
* 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.
* Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum quadro, nenhum
* requestAnimationFrame) e o último quadro fica na tela; qualquer movimento a religa.
* Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame.
*/
export default function FramePulse({
active,
fps = 30,
idleMs = 5000,
}: {
active: boolean;
fps?: number;
idleMs?: number;
}) {
const invalidate = useThree((state) => state.invalidate);
@@ -22,15 +36,30 @@ export default function FramePulse({
const gap = 1000 / fps - 2;
let id = 0;
let last = 0;
let lastActivity = performance.now();
const beat = (now: number) => {
// parada por inatividade: sem laço até o próximo movimento
if (now - lastActivity > idleMs) {
id = 0;
return;
}
id = requestAnimationFrame(beat);
if (now - last < gap) return;
last = now;
invalidate();
};
const wake = () => {
lastActivity = performance.now();
if (!id) id = requestAnimationFrame(beat);
};
for (const type of ACTIVITY)
window.addEventListener(type, wake, { passive: true });
id = requestAnimationFrame(beat);
return () => cancelAnimationFrame(id);
}, [active, fps, invalidate]);
return () => {
for (const type of ACTIVITY) window.removeEventListener(type, wake);
cancelAnimationFrame(id);
};
}, [active, fps, idleMs, invalidate]);
return null;
}
@@ -1,67 +0,0 @@
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(<HeroBackground />);
expect(container).toBeEmptyDOMElement();
});
it("path: capable device -> the 3D scene plus the three dark layers", () => {
const { container, getByTestId } = render(<HeroBackground />);
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(<HeroBackground />);
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(<HeroBackground />);
expect(html).not.toContain("canvas");
expect(html).toContain("pointer-events-none");
expect(lowPower).not.toHaveBeenCalled();
});
});
@@ -1,47 +0,0 @@
"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 && <HeroScene />}
{/* Cinematic radial vignette */}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.5)_55%,var(--ink)_100%)]" />
{/* Gradient fade to bottom */}
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
{/* Scanlines */}
<div
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.012]"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
}}
/>
</>
);
}
+250 -82
View File
@@ -1,10 +1,11 @@
import { act, render, screen } from "@testing-library/react";
import HeroField from "./HeroField";
import HeroField, { DIVE_QUERY } 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).
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): como a espera pelo WebGL acaba
* (primeira interação, requestIdleCallback com folga ou sem folga, temporizador), os cancelamentos, as falhas
* (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a
* visibilidade (observador, aba oculta, fim do mergulho) e a limpeza ao desmontar.
*/
type SceneProps = {
@@ -14,16 +15,25 @@ type SceneProps = {
onError: () => void;
};
const mockScenes: SceneProps[] = [];
const mockPrepare = jest.fn();
let mockImportFails = false;
jest.mock("next/dynamic", () => ({
function MockScene(props: SceneProps) {
mockScenes.push(props);
return require("react").createElement("canvas", { "data-testid": "scene" });
}
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
default: () =>
function MockScene(props: SceneProps) {
mockScenes.push(props);
return require("react").createElement("canvas", {
"data-testid": "scene",
});
},
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
get default() {
if (mockImportFails) throw new Error("chunk indisponível");
return MockScene;
},
get prepareField() {
if (mockImportFails) throw new Error("chunk indisponível");
return () => mockPrepare();
},
}));
const win = window as unknown as Record<string, unknown>;
@@ -36,7 +46,7 @@ const original = {
cancelIdle: win.cancelIdleCallback,
};
function mockMedia(active: string[]) {
function mockMedia(active: string[] = [DIVE_QUERY]) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: active.includes(query),
media: query,
@@ -69,6 +79,8 @@ function Hero({ children }: { children?: React.ReactNode }) {
);
}
const section = () => document.querySelector(".hero") as HTMLElement;
function setHidden(value: boolean) {
Object.defineProperty(document, "hidden", { configurable: true, value });
}
@@ -83,11 +95,53 @@ function reportIntersecting(value: boolean) {
});
}
/**
* requestIdleCallback de mentira: o pedido da espera pela página ociosa (timeout 4000) fica guardado para o teste
* disparar com o prazo que quiser; os dos passos de carregamento (timeout 500) rodam logo.
*/
function mockIdleCallback() {
const waiting = {
callback: undefined as ((d: IdleDeadline) => void) | undefined,
};
let requests = 0;
const request = jest.fn(
(
callback: (d: IdleDeadline) => void,
options?: { timeout?: number },
): number => {
requests += 1;
if (options?.timeout === 500)
setTimeout(
() => callback({ didTimeout: false, timeRemaining: () => 50 }),
0,
);
else waiting.callback = callback;
return requests;
},
);
win.requestIdleCallback = request;
win.cancelIdleCallback = jest.fn();
return { waiting, request };
}
const advance = (ms: number) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** Depois de a espera acabar, os passos seguintes (pacote, geometria, Canvas). */
async function finishLoading() {
await advance(60);
await advance(60);
}
beforeEach(() => {
jest.useFakeTimers();
mockScenes.length = 0;
mockPrepare.mockClear();
mockImportFails = false;
observers = [];
mockMedia([]);
mockMedia();
mockNavigator({
hardwareConcurrency: 8,
deviceMemory: undefined,
@@ -127,90 +181,165 @@ afterEach(() => {
});
describe("HeroField: quando o WebGL monta", () => {
it("path: no requestIdleCallback -> the scene mounts after 1.2 s of timer, not before", () => {
it("path: no requestIdleCallback -> the scene starts loading after 1.2 s of timer, not before", async () => {
render(<Hero />);
act(() => {
jest.advanceTimersByTime(1199);
});
await advance(1199);
expect(mockPrepare).not.toHaveBeenCalled();
expect(screen.queryByTestId("scene")).toBeNull();
act(() => {
jest.advanceTimersByTime(1);
});
await advance(1);
await finishLoading();
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;
it.each([
"pointerdown",
"pointermove",
"keydown",
"wheel",
"touchstart",
"scroll",
])(
"path: first %s -> the scene starts loading without waiting for the timer",
async (type) => {
render(<Hero />);
act(() => {
window.dispatchEvent(new Event(type));
});
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
},
);
it("path: several gestures in a row -> the scene is loaded once", async () => {
render(<Hero />);
act(() => {
window.dispatchEvent(new Event("pointermove"));
window.dispatchEvent(new Event("pointermove"));
window.dispatchEvent(new Event("keydown"));
});
win.requestIdleCallback = request;
win.cancelIdleCallback = jest.fn();
await finishLoading();
await advance(3000);
expect(mockPrepare).toHaveBeenCalledTimes(1);
expect(mockScenes.length).toBeGreaterThan(0);
});
it("path: requestIdleCallback with plenty of free time -> the scene starts loading right then, not on the timer", async () => {
const { waiting, request } = mockIdleCallback();
render(<Hero />);
act(() => {
jest.advanceTimersByTime(5000);
});
await advance(1300);
expect(screen.queryByTestId("scene")).toBeNull();
expect(request).toHaveBeenCalledWith(expect.any(Function), {
timeout: 2500,
timeout: 4000,
});
act(() => idle?.());
act(() =>
waiting.callback?.({ didTimeout: false, timeRemaining: () => 45 }),
);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
it("path: an idle period with no free time -> waits for the next one; waiting too long -> goes ahead anyway", async () => {
const { waiting, request } = mockIdleCallback();
render(<Hero />);
const first = request.mock.calls.length;
act(() =>
waiting.callback?.({ didTimeout: false, timeRemaining: () => 3 }),
);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(request.mock.calls.length).toBeGreaterThan(first); // pediu o próximo intervalo
act(() => waiting.callback?.({ didTimeout: true, timeRemaining: () => 0 }));
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: unmounted while waiting for the browser to get idle -> the request is cancelled and no scene mounts", async () => {
const cancel = jest.fn();
win.requestIdleCallback = jest.fn(() => 7);
win.cancelIdleCallback = cancel;
const view = render(<Hero />);
view.unmount();
await advance(5000);
expect(cancel).toHaveBeenCalledWith(7);
expect(mockScenes).toHaveLength(0);
});
it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
it("path: unmounted before the fallback timer fires -> nothing loads afterwards, and the gestures no longer matter", async () => {
const view = render(<Hero />);
view.unmount();
act(() => {
jest.advanceTimersByTime(5000);
window.dispatchEvent(new Event("pointermove"));
});
await advance(5000);
expect(mockPrepare).not.toHaveBeenCalled();
expect(mockScenes).toHaveLength(0);
});
it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
it("path: unmounted in the middle of loading (after the package, before the geometry) -> nothing mounts", async () => {
const view = render(<Hero />);
await advance(1200);
view.unmount();
await finishLoading();
expect(mockPrepare).not.toHaveBeenCalled();
expect(mockScenes).toHaveLength(0);
});
});
describe("HeroField: falhas voltam ao hero simples", () => {
it("path: WebGL2 constructor exists but creating the context throws -> static background, no dive", async () => {
setContext(() => {
throw new Error("sem GPU");
});
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await advance(1200);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(section()).not.toHaveAttribute("data-dive");
});
it("path: WebGL2 constructor exists but the context is null -> static background", () => {
it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => {
setContext(() => null);
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await advance(1200);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(section()).not.toHaveAttribute("data-dive");
});
it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
it("path: the three.js package fails to load (network) -> static background, no dive, no scene", async () => {
mockImportFails = true;
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.queryByTestId("scene")).toBeNull();
expect(mockScenes).toHaveLength(0);
expect(section()).not.toHaveAttribute("data-dive");
});
it("path: the probe context is released right away (WEBGL2 lose_context) before the real scene mounts", async () => {
const loseContext = jest.fn();
setContext(() => ({
getExtension: (name: string) =>
@@ -218,46 +347,48 @@ describe("HeroField: quando o WebGL monta", () => {
}));
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await advance(1200);
await finishLoading();
expect(loseContext).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: the lose-context extension is missing -> the probe still succeeds", () => {
it("path: the lose-context extension is missing -> the probe still succeeds", async () => {
setContext(() => ({ getExtension: () => null }));
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await advance(1200);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: low power -> the WebGL probe and the timer are never started", () => {
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
mockMedia(["(pointer: coarse)"]);
const probe = jest.fn(() => ({ getExtension: () => null }));
setContext(probe);
const addListener = jest.spyOn(window, "addEventListener");
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
window.dispatchEvent(new Event("pointermove"));
});
await advance(3000);
await finishLoading();
expect(probe).not.toHaveBeenCalled();
expect(mockScenes).toHaveLength(0);
expect(mockPrepare).not.toHaveBeenCalled();
const types = addListener.mock.calls.map(([type]) => type);
expect(types).not.toContain("pointermove");
expect(types).not.toContain("scroll");
expect(observers).toHaveLength(0);
});
});
describe("HeroField: rolagem e visibilidade", () => {
function section() {
return document.querySelector(".hero") as HTMLElement;
}
function place(top: number, height = 2000) {
function place(top: number, height = 3000) {
section().getBoundingClientRect = () =>
({ top, height, bottom: top + height }) as DOMRect;
}
@@ -270,6 +401,11 @@ describe("HeroField: rolagem e visibilidade", () => {
});
}
async function loaded() {
await advance(1200);
await finishLoading();
}
beforeEach(() => {
Object.defineProperty(window, "innerHeight", {
configurable: true,
@@ -277,15 +413,13 @@ describe("HeroField: rolagem e visibilidade", () => {
});
});
it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
it("path: no .hero ancestor -> no scroll variables and no observer, and nothing else starts either", async () => {
render(<HeroField />);
act(() => {
jest.advanceTimersByTime(3000);
});
await advance(3000);
await finishLoading();
expect(observers).toHaveLength(0);
expect(screen.getByTestId("scene")).toBeInTheDocument();
expect(mockScenes.at(-1)?.visible).toBe(false);
expect(mockScenes).toHaveLength(0);
});
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
@@ -302,13 +436,14 @@ describe("HeroField: rolagem e visibilidade", () => {
it("path: resize recomputes the progress like a scroll", () => {
render(<Hero />);
place(-600);
place(-700);
act(() => {
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(40);
});
// trilho de 1400 px (3000 de hero - 2 telas de 800)
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
0.5,
);
@@ -322,25 +457,43 @@ describe("HeroField: rolagem e visibilidade", () => {
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
});
it("path: end of the dive then back up -> drawing stops, then resumes", () => {
it("path: scroll far past the dive (Projects covering the stage) -> progress clamps at 1", () => {
render(<Hero />);
scroll(-2200);
expect(Number(section().style.getPropertyValue("--hero-p"))).toBe(1);
});
it("path: a hero shorter than the minimum rail -> the rail is 80 % of the window, never zero", () => {
render(<Hero />);
place(-400, 800);
act(() => {
jest.advanceTimersByTime(3000);
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(40);
});
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
400 / 640,
);
});
it("path: end of the dive then back up -> drawing stops, then resumes", async () => {
render(<Hero />);
await loaded();
reportIntersecting(true);
scroll(-1200);
scroll(-1400);
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", () => {
it("path: the hero leaves the screen -> not visible; returns -> visible", async () => {
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await loaded();
reportIntersecting(true);
expect(mockScenes.at(-1)?.visible).toBe(true);
@@ -352,11 +505,9 @@ describe("HeroField: rolagem e visibilidade", () => {
expect(mockScenes.at(-1)?.visible).toBe(true);
});
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", () => {
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", async () => {
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await loaded();
reportIntersecting(true);
setHidden(true);
@@ -372,11 +523,9 @@ describe("HeroField: rolagem e visibilidade", () => {
expect(mockScenes.at(-1)?.visible).toBe(true);
});
it("path: the tab shows but the hero is off screen -> still not visible", () => {
it("path: the tab shows but the hero is off screen -> still not visible", async () => {
render(<Hero />);
act(() => {
jest.advanceTimersByTime(3000);
});
await loaded();
reportIntersecting(false);
act(() => {
@@ -386,11 +535,14 @@ describe("HeroField: rolagem e visibilidade", () => {
expect(mockScenes.at(-1)?.visible).toBe(false);
});
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled", () => {
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled, hero back to plain", () => {
const removeListener = jest.spyOn(window, "removeEventListener");
const removeDocListener = jest.spyOn(document, "removeEventListener");
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
const view = render(<Hero />);
const hero = section();
scroll(-700);
expect(hero).toHaveAttribute("data-dive");
view.unmount();
@@ -401,5 +553,21 @@ describe("HeroField: rolagem e visibilidade", () => {
);
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
expect(cancelFrame).toHaveBeenCalled();
expect(hero).not.toHaveAttribute("data-dive");
expect(hero.style.getPropertyValue("--hero-p")).toBe("");
});
});
describe("HeroField: navegador sem matchMedia", () => {
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
render(<Hero />);
await advance(1200);
await finishLoading();
expect(section()).not.toHaveAttribute("data-dive");
expect(section().style.getPropertyValue("--hero-p")).toBe("");
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
});
+111 -34
View File
@@ -1,5 +1,5 @@
import { act, render, screen } from "@testing-library/react";
import HeroField from "./HeroField";
import HeroField, { DIVE_QUERY } from "./HeroField";
type SceneProps = {
visible: boolean;
@@ -9,16 +9,17 @@ type SceneProps = {
onError: () => void;
};
const mockScenes: SceneProps[] = [];
const mockPrepare = jest.fn();
jest.mock("next/dynamic", () => ({
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
default: () =>
function MockScene(props: SceneProps) {
mockScenes.push(props);
return require("react").createElement("canvas", {
"data-testid": "scene",
});
},
default: function MockScene(props: SceneProps) {
mockScenes.push(props);
return require("react").createElement("canvas", {
"data-testid": "scene",
});
},
prepareField: () => mockPrepare(),
}));
const original = {
@@ -27,7 +28,8 @@ const original = {
getContext: HTMLCanvasElement.prototype.getContext,
};
function mockMedia(active: string[]) {
/** Consultas de mídia que valem; o mergulho (DIVE_QUERY) vale por padrão: tela alta, sem movimento reduzido. */
function mockMedia(active: string[] = [DIVE_QUERY]) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: active.includes(query),
media: query,
@@ -49,17 +51,37 @@ function Hero() {
);
}
/** O navegador ocioso: o Three.js só entra depois do tempo de espera. */
function idle() {
const section = () => document.querySelector(".hero") as HTMLElement;
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
function scrollWritesProgress() {
section().getBoundingClientRect = () =>
({ top: -700, height: 3000, bottom: 2300 }) as DOMRect;
act(() => {
jest.advanceTimersByTime(3000);
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
return section().style.getPropertyValue("--hero-p") !== "";
}
/** Avança o relógio e deixa as promessas (importação do pacote, passos ociosos) resolverem no caminho. */
const advance = (ms: number) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** A página fica ociosa (sem requestIdleCallback: 1,2 s) e o Three.js entra, passo a passo. */
async function idle() {
await advance(1200); // a espera
await advance(60); // pacote carregado, geometria no próximo intervalo ocioso
await advance(60); // Canvas montado
}
beforeEach(() => {
jest.useFakeTimers();
mockScenes.length = 0;
mockMedia([]);
mockPrepare.mockClear();
mockMedia();
mockNavigator({
hardwareConcurrency: 8,
deviceMemory: undefined,
@@ -81,18 +103,50 @@ afterEach(() => {
});
describe("HeroField (ambient background)", () => {
it("mounts the WebGL scene only after the page is idle, on a capable desktop", () => {
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
render(<Hero />);
expect(screen.queryByTestId("scene")).toBeNull();
idle();
await 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", () => {
it("brings the geometry in before the Canvas, in a task of its own", async () => {
render(<Hero />);
await advance(1200);
await advance(10); // o pacote carregou, mas o intervalo ocioso da geometria ainda não veio
expect(mockPrepare).not.toHaveBeenCalled();
expect(screen.queryByTestId("scene")).toBeNull();
await advance(60);
expect(mockPrepare).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("scene")).toBeNull();
await advance(60);
expect(screen.getByTestId("scene")).toBeInTheDocument();
expect(mockPrepare).toHaveBeenCalledTimes(1);
});
it("does not wait for the idle time once the visitor interacts", async () => {
render(<Hero />);
await advance(100);
expect(screen.queryByTestId("scene")).toBeNull();
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
await advance(200);
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("is decorative: hidden from assistive technology and with no labels over the 3D", async () => {
const { container } = render(<Hero />);
idle();
await idle();
const field = container.querySelector(".hero-field");
expect(field).toHaveAttribute("aria-hidden", "true");
expect(container.querySelector(".hero-tag")).toBeNull();
@@ -102,7 +156,7 @@ describe("HeroField (ambient background)", () => {
});
it.each([
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
@@ -115,26 +169,33 @@ describe("HeroField (ambient background)", () => {
},
],
])(
"keeps the light static background (no WebGL) with %s",
(_label, arrange) => {
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
async (_label, arrange) => {
arrange();
const { container } = render(<Hero />);
idle();
await idle();
expect(screen.queryByTestId("scene")).toBeNull();
expect(mockScenes).toHaveLength(0);
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
"data-ready",
);
// o hero é o palco de 100svh: sem marca de mergulho, sem variável de rolagem
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
},
);
it("follows the hero scroll with --hero-p and --hero-open, and stops drawing once the dive is over", () => {
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
const { container } = render(<Hero />);
idle();
const section = container.querySelector(".hero") as HTMLElement;
const hero = container.querySelector(".hero") as HTMLElement;
expect(hero).toHaveAttribute("data-dive");
await idle();
// 3000 px de hero: 800 de palco, 800 da tela de Projetos subindo e 1400 de mergulho
let top = 0;
section.getBoundingClientRect = () =>
({ top, height: 2000, bottom: top + 2000 }) as DOMRect;
hero.getBoundingClientRect = () =>
({ top, height: 3000, bottom: top + 3000 }) as DOMRect;
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 800,
@@ -150,30 +211,46 @@ describe("HeroField (ambient background)", () => {
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);
scroll(700); // meio do mergulho (1400 px de trilho)
expect(Number(hero.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);
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
scroll(1200);
expect(Number(section.style.getPropertyValue("--hero-p"))).toBe(1);
scroll(1400);
expect(Number(hero.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", () => {
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
mockMedia([]);
render(<Hero />);
await idle();
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("reveals the scene (data-ready) once it has drawn its first frames, and goes back to the plain hero if it fails", async () => {
const { container } = render(<Hero />);
idle();
await 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();
// de volta ao palco de 100svh: sem marca de mergulho nem variáveis de rolagem
expect(section()).not.toHaveAttribute("data-dive");
expect(section().style.getPropertyValue("--hero-p")).toBe("");
});
});
+176 -63
View File
@@ -1,13 +1,31 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type ComponentType,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { isLowPowerDevice } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
ssr: false,
});
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
export const DIVE_QUERY =
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
const IDLE_SLACK_MS = 20;
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
const IDLE_PATIENCE_MS = 4000;
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
const FIRST_ACTIVITY = [
"pointerdown",
"pointermove",
"keydown",
"wheel",
"touchstart",
"scroll",
] as const;
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
@@ -25,83 +43,178 @@ function supportsWebGL2() {
}
}
/** Um intervalo ocioso do navegador (sem requestIdleCallback, um instante depois). */
function nextIdle() {
return new Promise<void>((resolve) => {
if (typeof window.requestIdleCallback === "function")
window.requestIdleCallback(() => resolve(), { timeout: 500 });
else setTimeout(resolve, 50);
});
}
/**
* 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.
* Chama `run` uma vez: na primeira interação do visitante ou quando o navegador fica ocioso com folga (o que
* vier primeiro). Título, botões e hidratação vêm antes; devolve a função que cancela a espera.
*/
function whenIdle(run: () => void) {
let idle: number | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = () => {
for (const type of FIRST_ACTIVITY) window.removeEventListener(type, fire);
if (idle !== undefined) window.cancelIdleCallback(idle);
clearTimeout(timer);
};
function fire() {
stop();
run();
}
for (const type of FIRST_ACTIVITY)
window.addEventListener(type, fire, { passive: true });
if (typeof window.requestIdleCallback === "function") {
const wait = (deadline: IdleDeadline) => {
if (deadline.didTimeout || deadline.timeRemaining() >= IDLE_SLACK_MS)
fire();
else
idle = window.requestIdleCallback(wait, { timeout: IDLE_PATIENCE_MS });
};
idle = window.requestIdleCallback(wait, { timeout: IDLE_PATIENCE_MS });
} else timer = setTimeout(fire, 1200);
return stop;
}
/**
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
* num intervalo ocioso) e só então o Canvas. Devolve `null` se o aparelho não faz WebGL2 ou se a tela foi
* desmontada no meio do caminho.
*/
async function loadScene(isAlive: () => boolean) {
if (!supportsWebGL2()) return null;
const scene = await import("./HeroFieldScene");
await nextIdle();
if (!isAlive()) return null;
scene.prepareField();
await nextIdle();
return isAlive() ? scene.default : null;
}
/**
* 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 marca o hero com data-dive, que
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
*/
function trackDive(
section: HTMLElement,
progress: { current: number },
onDone: (done: boolean) => void,
) {
section.dataset.dive = "";
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
// o palco fica preso por (altura - 1 tela); a última tela dessa rolagem é de Projetos subindo sobre ele
const span = Math.max(
rect.height - window.innerHeight * 2,
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 === progress.current) return;
progress.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));
onDone(p >= 0.999);
};
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);
delete section.dataset.dive;
section.style.removeProperty("--hero-p");
section.style.removeProperty("--hero-open");
};
}
/** Avisa se o hero está à vista (e a aba visível): fora disso a cena não desenha nada. */
function trackVisibility(
section: HTMLElement,
onChange: (visible: boolean) => void,
) {
let intersecting = false;
const sync = () => onChange(intersecting && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
sync();
});
observer.observe(section);
document.addEventListener("visibilitychange", sync);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}
/**
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2, sem toque, com mais de
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
* (trackDive). Sem legendas sobre o 3D: o espaço da direita é das molduras.
*/
export default function HeroField() {
const rootRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const [mount, setMount] = useState(false);
const giveUp = useRef(() => {});
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
null,
);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
const span = Math.max(
rect.height - window.innerHeight,
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);
let alive = true;
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopVisibility = trackVisibility(section, setVisible);
// sem WebGL2, com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
giveUp.current = () => {
stopDive?.();
if (alive) setFailed(true);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", queue, { passive: true });
window.addEventListener("resize", queue);
let intersecting = false;
const sync = () => setVisible(intersecting && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
sync();
const stopWaiting = whenIdle(() => {
loadScene(() => alive)
.then((component) => {
if (!alive) return;
if (component) setScene(() => component);
else giveUp.current();
})
.catch(() => giveUp.current());
});
observer.observe(section);
document.addEventListener("visibilitychange", sync);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", queue);
window.removeEventListener("resize", queue);
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
alive = false;
stopWaiting();
stopVisibility();
stopDive?.();
};
}, []);
useEffect(() => {
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") {
const id = window.requestIdleCallback(() => setMount(true), {
timeout: 2500,
});
return () => window.cancelIdleCallback(id);
}
const id = setTimeout(() => setMount(true), 1200);
return () => clearTimeout(id);
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => setFailed(true), []);
const handleError = useCallback(() => giveUp.current(), []);
return (
<div
@@ -110,7 +223,7 @@ export default function HeroField() {
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
>
{mount && !failed && (
{Scene && !failed && (
<Scene
progressRef={progressRef}
visible={visible && !done}
@@ -1,6 +1,6 @@
import { act, render } from "@testing-library/react";
import * as THREE from "three";
import HeroFieldScene from "./HeroFieldScene";
import HeroFieldScene, { prepareField } from "./HeroFieldScene";
/**
* Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
@@ -23,6 +23,7 @@ const mockState: {
throwOnUseThree: boolean;
} = { frame: null, three: null, throwOnUseThree: false };
const mockMaterials: THREE.ShaderMaterial[] = [];
const mockCounts = { vectors: 0 };
const mockGeometries: THREE.BufferGeometry[] = [];
jest.mock("three", () => {
@@ -39,10 +40,17 @@ jest.mock("three", () => {
mockGeometries.push(this as unknown as THREE.BufferGeometry);
}
}
class CountingVector3 extends actual.Vector3 {
constructor(x?: number, y?: number, z?: number) {
super(x, y, z);
mockCounts.vectors += 1;
}
}
return {
...actual,
ShaderMaterial: CapturingMaterial,
BufferGeometry: CapturingGeometry,
Vector3: CountingVector3,
};
});
jest.mock("@react-three/fiber", () => ({
@@ -166,6 +174,7 @@ function pointerMove(type: string, x = 600, y = 400) {
}
beforeEach(() => {
mockCounts.vectors = 0;
mockMaterials.length = 0;
mockGeometries.length = 0;
mockState.frame = null;
@@ -237,15 +246,19 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
expect(scene.uniforms().uIntro.value).toBe(1);
});
it("path: the scene fades out only at the very end of the dive", () => {
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
const scene = setup({ progress: 0 });
scene.settle(10);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 0.9;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 1;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBeCloseTo(0, 6);
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 6);
});
it("path: layers spread apart only while they face the viewer", () => {
@@ -279,13 +292,17 @@ describe("Field: enquadramento e foco", () => {
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", () => {
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.group.position.y).toBeCloseTo(0, 6);
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
expect(scene.camera.position.z).toBeLessThan(0);
expect(scene.camera.position.z).toBeGreaterThan(-spread);
expect(scene.camera.position.z + spread).toBeLessThan(6);
});
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
@@ -297,12 +314,30 @@ describe("Field: enquadramento e foco", () => {
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
});
it("path: camera past every layer -> the focus never goes below 1.5", () => {
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
expect(scene.uniforms().uFocus.value).toBe(1.5);
const spread = scene.uniforms().uSpread.value;
expect(scene.uniforms().uFocus.value).toBeCloseTo(
scene.camera.position.z + spread,
6,
);
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
});
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
const scene = setup({ progress: 0.55 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
const z = scene.camera.position.z;
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
expect(z).toBeGreaterThan(0.2);
expect(z).toBeLessThan(spread);
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
});
});
@@ -472,6 +507,43 @@ describe("Field: quando pedir o próximo quadro", () => {
});
});
describe("Field: geometria pré-calculada e quadros sem alocação", () => {
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
prepareField();
expect(mockGeometries).toHaveLength(1);
const prepared = mockGeometries[0];
prepareField(); // de novo, sem montar: nada é refeito
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(1);
expect(mockGeometries[0]).toBe(prepared);
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
});
it("path: the preparation is used once; a second scene builds its own points", () => {
prepareField();
setup();
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(2);
});
it("path: no vector is created per frame, not even with the mouse driving the cursor over the layer", () => {
const scene = setup();
jest.spyOn(performance, "now").mockReturnValue(5000);
pointerMove("mouse");
scene.frame(5.1, 1 / 60);
const before = mockCounts.vectors;
for (let i = 0; i < 60; i++) scene.frame(5.2 + i / 60, 1 / 60);
expect(mockCounts.vectors).toBe(before);
});
});
describe("Field: densidade, contexto WebGL e limpeza", () => {
const pointCount = () => {
const geometry = mockGeometries[mockGeometries.length - 1];
+248 -94
View File
@@ -25,12 +25,14 @@ 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 Hero explicam cada camada nessa hora).
* frente e a câmera atravessa uma a uma, até parar diante dos dados (as legendas de Hero explicam cada
* camada nessa hora; no fim, a seção seguinte sobe e cobre o palco).
* 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.
* e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
* sem cursor nem rolagem (FramePulse), não desenha nada.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
@@ -371,7 +373,8 @@ const FRAME = 5.2;
/*
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
* atravessa, enquadra os dados e fica ali (o mergulho não acaba no escuro: Projetos sobe sobre a última
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) seguem
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
* camadas se afastam mais para a câmera caber entre elas.
@@ -387,11 +390,209 @@ function cameraFrames(rest: number, spread: number, frame: number) {
[0.74, -0.3],
[0.78, -spread + frame],
[0.89, -spread + frame * 0.87],
[0.95, -spread - 0.3],
[1, -spread - 2],
[1, -spread + frame * 0.8],
] as const;
}
/** Tela larga o bastante (e CPU suficiente) para a densidade cheia de pontos. */
const isDense = () =>
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
let prepared: THREE.BufferGeometry | null = null;
/**
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
* isto num intervalo ocioso, antes de montar o Canvas, para a criação do contexto WebGL não carregar também
* essa conta. Chamar de novo, sem a cena ter montado, não refaz nada.
*/
export function prepareField() {
prepared ??= buildField(isDense());
}
/** A geometria pronta (que a montagem consome), ou uma nova se ninguém a preparou antes. */
function takeField() {
const geometry = prepared ?? buildField(isDense());
prepared = null;
return geometry;
}
type Uniforms = THREE.ShaderMaterial["uniforms"];
/** Enquadramento da câmera para o tamanho atual da tela: só muda quando a tela muda, não a cada quadro. */
function frameLayout(
width: number,
height: number,
fov: number,
pixelRatio: number,
) {
const aspect = width / height;
const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2));
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
const rest = Math.max(9, frameDist * 1.1);
return {
wide: aspect > 1.1,
tanHalf,
spreadMax,
rest,
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
scale:
((pixelRatio * height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME),
camera: cameraFrames(rest, spreadMax, frameDist),
};
}
type Layout = ReturnType<typeof frameLayout>;
/** Uniforms do tempo, da entrada e do mergulho. Devolve o quanto as camadas já estão de frente (0 a 1). */
function updateUniforms(
u: Uniforms,
layout: Layout,
p: number,
time: number,
dt: number,
) {
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
const face = smooth(0.02, 0.28, p);
u.uSpread.value = 1.05 + (layout.spreadMax - 1.05) * face * face;
u.uFade.value = 1 - 0.4 * smooth(0.93, 1, p);
u.uScale.value = layout.scale;
return face;
}
type FieldState = {
progress: number;
pointer: THREE.Vector2;
lastMove: number;
mouse: THREE.Vector2;
tilt: THREE.Vector2;
frames: number;
};
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
type Helpers = {
ray: THREE.Raycaster;
plane: THREE.Plane;
up: THREE.Vector3;
hit: THREE.Vector3;
zero: THREE.Vector2;
target: THREE.Vector2;
};
/** O mouse se mexeu há pouco? Sem ele (toque, ou mouse parado), um cursor fantasma percorre a tela. */
const cursorIsActive = (s: FieldState, time: number) =>
s.lastMove > 0 && time - s.lastMove < 2.5;
/** Posição do grupo, rotação, câmera e foco: a vista de três quartos que balança e depois se endireita no mergulho. */
function updateRig(
g: THREE.Group,
camera: THREE.Camera,
u: Uniforms,
layout: Layout,
s: FieldState,
h: Helpers,
p: number,
face: number,
time: number,
dt: number,
cursorActive: boolean,
) {
s.tilt.lerp(cursorActive ? s.pointer : h.zero, 1 - Math.exp(-dt * 3));
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
s.tilt.x * 0.15 * (1 - face),
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
const settle = 1 - smooth(0, 0.28, p);
g.position.set(
0,
(layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38) * settle,
0,
);
camera.position.z = keyframes(layout.camera, p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
const behind = camera.position.z - 0.2;
const ahead = spread < behind ? spread : 0 < behind ? 0 : -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
}
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em h.target. Falso se o raio não toca a camada. */
function cursorOnLayer(
g: THREE.Group,
camera: THREE.Camera,
s: FieldState,
h: Helpers,
spread: number,
) {
h.ray.setFromCamera(s.pointer, camera);
h.plane.set(h.up, -spread).applyMatrix4(g.matrixWorld);
if (!h.ray.ray.intersectPlane(h.plane, h.hit)) return false;
g.worldToLocal(h.hit);
h.target.set(h.hit.x, h.hit.z);
return true;
}
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
function updatePointer(
g: THREE.Group,
camera: THREE.Camera,
u: Uniforms,
s: FieldState,
h: Helpers,
p: number,
time: number,
dt: number,
cursorActive: boolean,
) {
if (cursorActive) {
if (cursorOnLayer(g, camera, s, h, u.uSpread.value))
s.mouse.lerp(h.target, 1 - Math.exp(-dt * 10));
} else {
s.mouse.lerp(
h.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
return targetAmt;
}
/**
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
* rolagem); este pede o quadro seguinte (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); o cursor só conta com o hero
* à vista.
*/
function needsFrame(
u: Uniforms,
s: FieldState,
target: number,
amount: number,
cursorActive: boolean,
visible: boolean,
) {
const settling = u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
const interacting =
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
return settling || (visible && interacting);
}
function Field({
progressRef,
visible,
@@ -400,11 +601,7 @@ function Field({
}: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef<THREE.Group>(null);
const dense = useMemo(
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
[],
);
const geometry = useMemo(() => buildField(dense), [dense]);
const geometry = useMemo(takeField, []);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
@@ -427,7 +624,17 @@ function Field({
}),
[],
);
const state = useRef({
const layout = useMemo(
() =>
frameLayout(
size.width,
size.height,
(camera as THREE.PerspectiveCamera).fov,
gl.getPixelRatio(),
),
[camera, gl, size.height, size.width],
);
const state = useRef<FieldState>({
progress: 0,
pointer: new THREE.Vector2(),
lastMove: -10,
@@ -435,10 +642,11 @@ function Field({
tilt: new THREE.Vector2(),
frames: 0,
});
const helpers = useMemo(
const helpers = useMemo<Helpers>(
() => ({
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
up: new THREE.Vector3(0, 1, 0),
hit: new THREE.Vector3(),
zero: new THREE.Vector2(),
target: new THREE.Vector2(),
@@ -477,95 +685,41 @@ function Field({
const s = state.current;
const u = material.uniforms;
const time = frame.clock.elapsedTime;
const target = progressRef.current ?? 0;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
s.progress +=
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
const p = s.progress;
const aspect = size.width / size.height;
const wide = aspect > 1.1;
const tanHalf = Math.tan(
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
const cursorActive = cursorIsActive(s, time);
const face = updateUniforms(u, layout, p, time, dt);
updateRig(
g,
camera,
u,
layout,
s,
helpers,
p,
face,
time,
dt,
cursorActive,
);
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
const rest = Math.max(9, frameDist * 1.1);
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
const face = smooth(0.02, 0.28, p);
u.uSpread.value = 1.05 + (spreadMax - 1.05) * face * face;
u.uFade.value = 1 - smooth(0.93, 1, p);
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
u.uScale.value =
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
Math.sqrt(frameDist / FRAME);
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
s.tilt.x * 0.15 * (1 - face),
0,
const amount = updatePointer(
g,
camera,
u,
s,
helpers,
p,
time,
dt,
cursorActive,
);
const settle = 1 - smooth(0, 0.28, p);
// 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;
const ahead =
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
if (mouseActive) {
helpers.ray.setFromCamera(s.pointer, camera);
helpers.plane
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
.applyMatrix4(g.matrixWorld);
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
g.worldToLocal(helpers.hit);
s.mouse.lerp(
helpers.target.set(helpers.hit.x, helpers.hit.z),
1 - Math.exp(-dt * 10),
);
}
} else {
s.mouse.lerp(
helpers.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
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();
if (needsFrame(u, s, target, amount, cursorActive, visible))
frame.invalidate();
});
return (
@@ -1,206 +0,0 @@
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(<HeroScene />);
expect(lastPulse()).toBe(true);
});
it("path: isVisible=false -> the pulse is off even though the scene is in view", () => {
render(<HeroScene isVisible={false} />);
expect(lastPulse()).toBe(false);
});
it("path: scrolled out of view -> off; back in view -> on", () => {
render(<HeroScene />);
report(false);
expect(lastPulse()).toBe(false);
report(true);
expect(lastPulse()).toBe(true);
});
it("path: out of view and isVisible=false -> off", () => {
render(<HeroScene isVisible={false} />);
report(false);
expect(lastPulse()).toBe(false);
});
it("path: unmount -> the observer is disconnected", () => {
const view = render(<HeroScene />);
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(<HeroScene />);
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(<HeroScene />);
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(<HeroScene />);
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(<HeroScene />);
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(<HeroScene />);
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(<HeroScene />);
const frame = gridFrame();
view.unmount();
expect(() => frame({ clock: { elapsedTime: 3 } }, 0.016)).not.toThrow();
});
});
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import FramePulse from "./FramePulse";
function GridFloor() {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (meshRef.current) {
const material = meshRef.current.material as THREE.ShaderMaterial;
if (material.uniforms) {
material.uniforms.uTime.value = state.clock.elapsedTime;
}
}
});
const gridShader = useMemo(
() => ({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color("#ccff00") },
},
vertexShader: `
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vec2 grid = abs(fract(vPosition.xz * 0.5) - 0.5) / fwidth(vPosition.xz * 0.5);
float line = min(grid.x, grid.y);
float alpha = 1.0 - min(line, 1.0);
// Distance fade
float dist = length(vPosition.xz) * 0.08;
alpha *= max(0.0, 1.0 - dist);
alpha *= 0.12;
// Pulse wave
float wave = sin(length(vPosition.xz) * 0.3 - uTime * 0.8) * 0.5 + 0.5;
alpha *= 0.5 + wave * 0.5;
gl_FragColor = vec4(uColor, alpha);
}
`,
}),
[],
);
return (
<mesh ref={meshRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, -4, 0]}>
<planeGeometry args={[40, 40, 1, 1]} />
<shaderMaterial
{...gridShader}
transparent
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
);
}
function CameraRig() {
const { camera } = useThree();
const mouseRef = useRef({ x: 0, y: 0 });
useFrame(() => {
const targetX = mouseRef.current.x * 0.5;
const targetY = mouseRef.current.y * 0.3 + 0.5;
camera.position.x += (targetX - camera.position.x) * 0.02;
camera.position.y += (targetY - camera.position.y) * 0.02;
camera.lookAt(0, 0, 0);
});
useEffect(() => {
const handler = (e: MouseEvent) => {
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
};
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
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<HTMLDivElement>(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 (
<div
ref={rootRef}
className="absolute inset-0 z-0"
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
>
<Canvas
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
dpr={[1, 1.5]}
frameloop="demand"
gl={{
antialias: false,
alpha: true,
powerPreference: "high-performance",
}}
style={{
background: "transparent",
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "block",
}}
>
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
<ambientLight intensity={0.25} />
<directionalLight
position={[5, 5, 5]}
intensity={0.4}
color="#ccff00"
/>
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
<FramePulse active={isVisible && inView} />
<CameraRig />
<GridFloor />
</Canvas>
</div>
);
}
@@ -1,41 +0,0 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import MagneticButton from "./MagneticButton";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
describe("MagneticButton semantics", () => {
it("renders a list item directly when used inside a list", () => {
const { container } = render(
<ul>
<MagneticButton as="li" className="ds-tag">
TypeScript
</MagneticButton>
</ul>,
);
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
});
});
-111
View File
@@ -1,111 +0,0 @@
"use client";
import { motion } from "motion/react";
import { type MouseEvent, type ReactNode, useState } from "react";
interface MagneticButtonProps {
children: ReactNode;
className?: string;
href?: string;
onClick?: () => void;
strength?: number;
as?: "a" | "button" | "div" | "span" | "li";
target?: string;
rel?: string;
download?: boolean | string;
type?: "button" | "submit" | "reset";
wrapperClassName?: string;
"aria-label"?: string;
}
export default function MagneticButton({
children,
className = "",
href,
onClick,
strength = 0.3,
as = "button",
target,
rel,
download,
type,
wrapperClassName = "",
"aria-label": ariaLabel,
}: MagneticButtonProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouse = (e: MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
setPosition({
x: (e.clientX - centerX) * strength,
y: (e.clientY - centerY) * strength,
});
};
const handleMouseLeave = () => {
setPosition({ x: 0, y: 0 });
};
const Tag =
as === "a"
? motion.a
: as === "button"
? motion.button
: as === "div"
? motion.div
: as === "span"
? motion.span
: motion.li;
const linkProps = as === "a" ? { href, target, rel, download } : {};
const buttonProps = as === "button" ? { type } : {};
const motionProps = {
animate: {
x: position.x,
y: position.y,
},
transition: {
type: "spring" as const,
stiffness: 350,
damping: 15,
mass: 0.5,
},
};
if (as === "li") {
return (
<Tag
className={`${className} ${wrapperClassName}`.trim()}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
>
{children}
</Tag>
);
}
return (
<div className={`inline-block ${wrapperClassName}`.trim()}>
<Tag
className={className}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
>
{children}
</Tag>
</div>
);
}
@@ -0,0 +1,52 @@
import { render } from "@testing-library/react";
import ScrollProgress from "./ScrollProgress";
describe("ScrollProgress", () => {
it("is decorative: hidden from assistive technology, with no text", () => {
const { container } = render(<ScrollProgress />);
for (const piece of container.querySelectorAll(".ds-scroll-progress"))
expect(piece).toHaveAttribute("aria-hidden", "true");
expect(container).toHaveTextContent("");
});
it("has the top bar and the side rail with its fill and dot, ready for the CSS scroll timeline", () => {
const { container } = render(<ScrollProgress />);
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
expect(
container.querySelector(".ds-scroll-rail .ds-scroll-fill"),
).toBeInTheDocument();
expect(
container.querySelector(".ds-scroll-rail .ds-scroll-dot"),
).toBeInTheDocument();
});
it("drives nothing from JavaScript: no inline transform or opacity, no scroll listener", () => {
const listen = jest.spyOn(window, "addEventListener");
const { container } = render(<ScrollProgress />);
for (const element of container.querySelectorAll<HTMLElement>("*")) {
expect(element.style.transform).toBe("");
expect(element.style.opacity).toBe("");
}
expect(listen.mock.calls.map(([type]) => type)).not.toContain("scroll");
listen.mockRestore();
});
it("does not need the animation library", () => {
jest.isolateModules(() => {
// o pacote "motion" saiu do projeto: virtual, para o dublê valer mesmo sem ele no node_modules
jest.doMock(
"motion/react",
() => {
throw new Error(
"motion não pode ser carregado pela barra de rolagem",
);
},
{ virtual: true },
);
expect(() => require("./ScrollProgress")).not.toThrow();
});
});
});
+14 -42
View File
@@ -1,53 +1,25 @@
"use client";
import { motion, useScroll, useSpring, useTransform } from "motion/react";
/**
* Barra de progresso no topo e trilho lateral da rolagem da página. Sem JavaScript de rolagem: o CSS liga a
* animação à posição da rolagem (`animation-timeline: scroll()`, em globals.css). Onde o navegador não
* suporta, os dois somem.
*/
export default function ScrollProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const opacity = useTransform(
scrollYProgress,
[0, 0.02, 0.98, 1],
[0, 1, 1, 0],
);
return (
<>
{/* Top progress bar */}
<motion.div
className="fixed left-0 right-0 top-0 z-[9995] h-[2px] origin-left bg-[var(--slash)]"
style={{ scaleX, opacity }}
<div
aria-hidden="true"
className="ds-scroll-progress ds-scroll-bar fixed left-0 right-0 top-0 z-[9995] h-[2px] origin-left bg-[var(--slash)]"
/>
{/* Side scroll indicator */}
<motion.div
className="fixed right-6 top-1/2 z-[9994] hidden -translate-y-1/2 lg:block"
style={{ opacity }}
<div
aria-hidden="true"
className="ds-scroll-progress ds-scroll-rail fixed right-6 top-1/2 z-[9994] hidden -translate-y-1/2 lg:block"
>
<div className="relative h-[120px] w-px bg-[rgba(255,255,255,0.08)]">
<motion.div
className="absolute left-0 top-0 w-full origin-top bg-[var(--slash)]"
style={{
scaleY: scrollYProgress,
height: "100%",
}}
/>
<motion.div
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[var(--slash)]"
style={{
width: 6,
height: 6,
top: useTransform(scrollYProgress, [0, 1], ["0%", "100%"]),
boxShadow: "0 0 10px rgba(204, 255, 0, 0.5)",
}}
/>
<div className="ds-scroll-fill absolute left-0 top-0 h-full w-full origin-top bg-[var(--slash)]" />
<div className="ds-scroll-dot absolute left-1/2 top-0 h-[6px] w-[6px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--slash)] shadow-[0_0_10px_rgba(204,255,0,0.5)]" />
</div>
</motion.div>
</div>
</>
);
}
@@ -1,31 +1,37 @@
import { act, render } from "@testing-library/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useRef } from "react";
import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
import { 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
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx), com o hook montado
* numa página de verdade:
* - cliques em â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.
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos, com Lenis e sem ele (toque);
* - ref vazia, movimento reduzido e a limpeza dos ouvintes ao desmontar.
* O Lenis é trocado por um dublê que registra o que recebe; o GSAP nem existe mais (se alguém o importar, o
* dublê abaixo quebra o teste).
*/
type Call = unknown[];
const mockTimelines: Array<{ calls: Call[] }> = [];
const mockTicker = {
add: jest.fn(),
remove: jest.fn(),
lagSmoothing: jest.fn(),
time: 0,
};
jest.mock(
"gsap",
() => {
throw new Error("gsap não deve ser carregado");
},
{ virtual: true },
);
jest.mock(
"gsap/ScrollTrigger",
() => {
throw new Error("gsap/ScrollTrigger não deve ser carregado");
},
{ virtual: true },
);
const mockLenis: MockLenis[] = [];
class MockLenis {
isScrolling: false | "smooth" = false;
on = jest.fn();
raf = jest.fn();
scrollTo = jest.fn();
destroy = jest.fn();
@@ -34,30 +40,6 @@ class MockLenis {
}
}
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() {
@@ -126,7 +108,6 @@ function Home({ attach = true }: { attach?: boolean }) {
}
const originalMatchMedia = window.matchMedia;
const OriginalResizeObserver = global.ResizeObserver;
const originalOffsetTop = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetTop",
@@ -216,14 +197,7 @@ afterAll(() => {
});
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(() => {});
@@ -236,7 +210,6 @@ beforeEach(() => {
afterEach(() => {
window.matchMedia = originalMatchMedia;
global.ResizeObserver = OriginalResizeObserver;
jest.useRealTimers();
jest.restoreAllMocks();
});
@@ -527,113 +500,13 @@ describe("useCinematicScroll: salto direto x rolagem curta", () => {
});
});
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(<Home />);
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);
const { prevented } = click(link("projetos"));
expect(prevented).toBe(false);
});
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
@@ -644,6 +517,20 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
expect(prevented).toBe(false);
expect(mockLenis).toHaveLength(0);
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => {
mockMedia(["(pointer: coarse)"]);
const view = render(<Home />);
// sem esperar importação nenhuma: o clique já é tratado
const { prevented } = click(link("projetos"));
expect(prevented).toBe(true);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
expect(mockLenis).toHaveLength(0);
view.unmount();
});
it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
@@ -662,59 +549,16 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
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();
it("path: after unmount an anchor click is no longer intercepted", async () => {
const view = await mount();
act(() => {
resizers[0].callback();
});
const target = link("projetos");
view.unmount();
act(() => {
jest.advanceTimersByTime(1000);
});
document.body.append(target);
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
const { prevented } = click(target);
expect(prevented).toBe(false);
target.remove();
});
});
@@ -2,171 +2,41 @@ import { act, render } from "@testing-library/react";
import { useRef } from "react";
import {
anchorDuration,
ENTER_OFFSET,
enterScene,
exitScene,
anchorTop,
IDLE_SECONDS,
isPlainPrimaryClick,
JUMP_SCREENS,
MAX_ANCHOR_SECONDS,
PASSAGE_END,
samePageHashTarget,
useCinematicScroll,
} from "./useCinematicScroll";
/* ------------------------------------------------------------------------------------------------
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
* ------------------------------------------------------------------------------------------------ */
/*
* A rolagem da home sem biblioteca de animação por quadro: as passagens entre cenas são CSS; este hook só
* liga o Lenis (mouse) e leva os links internos até o destino. Aqui: o hook montado (Lenis trocado por um
* dublê que registra o que recebe) e as funções puras que ele usa.
*/
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
// o GSAP e o ScrollTrigger saíram da página: se alguém voltar a importá-los, estes dublês quebram o teste
jest.mock(
"gsap",
() => {
throw new Error("gsap não deve ser carregado");
},
{ virtual: true },
);
jest.mock(
"gsap/ScrollTrigger",
() => {
throw new Error("gsap/ScrollTrigger não deve ser carregado");
},
{ virtual: true },
);
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();
@@ -175,30 +45,6 @@ class MockLenis {
}
}
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() {
@@ -226,7 +72,6 @@ function Home() {
<section id="projects" data-top={TOPS.projects}>
<h2>Projetos</h2>
</section>
{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
<section id="short" data-top={TOPS.short}>
<h2>Curta</h2>
</section>
@@ -257,15 +102,20 @@ function mockMedia(active: string[]) {
async function mount() {
const view = render(<Home />);
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
// a importação dinâmica do Lenis resolve em microtarefas
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
return view;
}
const scenes = () =>
Array.from(document.querySelectorAll<HTMLElement>("main > section"));
/** Quadros de animação pedidos pelo hook desde que o espião foi criado. */
let frames: jest.SpyInstance;
const advance = (ms: number) =>
act(() => {
jest.advanceTimersByTime(ms);
});
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
@@ -286,12 +136,9 @@ afterAll(() => {
});
beforeEach(() => {
mockTimelines.length = 0;
jest.useFakeTimers();
frames = jest.spyOn(window, "requestAnimationFrame");
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(() => {});
@@ -299,162 +146,286 @@ beforeEach(() => {
configurable: true,
value: 800,
});
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
jest.useRealTimers();
jest.restoreAllMocks();
});
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);
}
});
it("takes longer for farther anchors, up to the cap, and the same time up or down", () => {
const near = anchorDuration(200, 800) as number;
const far = anchorDuration(1600, 800) as number;
expect(far).toBeGreaterThan(near);
expect(anchorDuration(-1600, 800)).toBe(far);
expect(anchorDuration(0, 0)).not.toBeNaN();
});
});
describe("isPlainPrimaryClick", () => {
const click = (init: MouseEventInit = {}) =>
new MouseEvent("click", { cancelable: true, button: 0, ...init });
it("accepts a plain click of the main button", () => {
expect(isPlainPrimaryClick(click())).toBe(true);
});
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
"refuses a click with %s held (new tab, selection)",
(modifier) => {
expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false);
},
);
it.each([1, 2])("refuses mouse button %i", (button) => {
expect(isPlainPrimaryClick(click({ button }))).toBe(false);
});
it("refuses a click somebody else already cancelled", () => {
const event = click();
event.preventDefault();
expect(isPlainPrimaryClick(event)).toBe(false);
});
});
describe("samePageHashTarget", () => {
afterEach(() => {
document.body.innerHTML = "";
});
function clickOn(markup: string, selector: string) {
document.body.innerHTML = markup;
const target = document.querySelector(selector) as Element;
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: target });
return event;
}
it("finds the element a same-page #link points to", () => {
const event = clickOn(
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
"a",
);
const hit = samePageHashTarget(event);
expect(hit?.hash).toBe("#alvo");
expect(hit?.target.id).toBe("alvo");
});
it("works from inside the link (the text, an icon)", () => {
const event = clickOn(
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
"#inside",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it.each([
["another site", `<a href="https://example.com/#alvo">x</a>`],
["another page of this site", `<a href="/outra#alvo">x</a>`],
["no anchor", `<a href="/">x</a>`],
["an empty anchor", `<a href="/#">x</a>`],
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
["an id that does not exist", `<a href="/#nada">x</a>`],
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
["no link at all", `<p>x</p>`],
])("returns null for %s", (_label, markup) => {
const event = clickOn(
`${markup}<section id="alvo"></section>`,
markup.startsWith("<a") ? "a" : "p",
);
expect(samePageHashTarget(event)).toBeNull();
});
it("handles target=_self like a plain link", () => {
const event = clickOn(
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
"a",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it("ignores a click whose target is not an element", () => {
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: document });
expect(samePageHashTarget(event)).toBeNull();
});
});
describe("anchorTop", () => {
const scenes = () => {
document.body.innerHTML = `
<section id="a" data-top="1800"><h2 id="a-title" data-top="1900">t</h2></section>
<section id="b" data-top="2400"></section>
<p id="loose" data-top="9000">x</p>`;
return [...document.querySelectorAll<HTMLElement>("section")];
};
const byId = (id: string) => document.getElementById(id) as HTMLElement;
afterEach(() => {
document.body.innerHTML = "";
jest.restoreAllMocks();
});
function headerOffset(px: string) {
jest
.spyOn(window, "getComputedStyle")
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
}
it("is the scene top when the target is the whole scene (the header offset is ignored)", () => {
const all = scenes();
headerOffset("80px");
expect(anchorTop(byId("a"), all)).toBe(1800);
});
it("is the target minus the fixed header when it is inside a scene", () => {
const all = scenes();
headerOffset("80px");
expect(anchorTop(byId("a-title"), all)).toBe(1900 - 80);
});
it("never goes above the top of the scene", () => {
const all = scenes();
headerOffset("300px");
expect(anchorTop(byId("a-title"), all)).toBe(1800);
});
it("measures from the top of the page, minus the header, for a target outside every scene", () => {
const all = scenes();
headerOffset("100px");
expect(anchorTop(byId("loose"), all)).toBe(9000 - 100);
});
it("treats a missing header offset as zero", () => {
const all = scenes();
headerOffset("");
expect(anchorTop(byId("a-title"), all)).toBe(1900);
});
});
describe("useCinematicScroll", () => {
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
it("never hides, pins or restyles a scene: the passages are CSS, nothing inline", 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("");
for (const element of document.querySelectorAll<HTMLElement>(
"main > section",
)) {
expect(element.getAttribute("style")).toBeNull();
}
// 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<string, unknown>,
Record<string, unknown>,
];
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 () => {
it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
expect(mockTimelines).toHaveLength(0);
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
act(() => {
link.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(false);
expect(mockLenis).toHaveLength(0);
expect(mockTicker.add).not.toHaveBeenCalled();
expect(window.scrollTo).not.toHaveBeenCalled();
});
describe("smooth scroll", () => {
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
it("is not created on a touch screen: the package is not even loaded, and the native scroll does the work", 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);
expect(frames).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new Event("wheel"));
window.dispatchEvent(new Event("scroll"));
});
advance(500);
expect(frames).not.toHaveBeenCalled();
});
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
it("is created with a mouse, with the smoothing options", async () => {
await mount();
expect(mockLenis).toHaveLength(1);
expect(mockLenis[0].options).toEqual({
lerp: 0.075,
wheelMultiplier: 0.85,
});
});
it("feeds Lenis in milliseconds while scrolling, and lets the animation clock go once idle", async () => {
await mount();
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
// rolando: segue ligado, a cada quadro
lenis.isScrolling = "smooth";
tick(0.5);
expect(lenis.raf).toHaveBeenLastCalledWith(500);
expect(mockTicker.remove).not.toHaveBeenCalled();
advance(500);
const whileScrolling = lenis.raf.mock.calls.length;
expect(whileScrolling).toBeGreaterThan(20);
expect(lenis.raf.mock.calls.at(-1)?.[0]).toBeGreaterThan(400);
// parado há mais que o tempo de espera: o relógio é solto
// parado: depois de IDLE_SECONDS o relógio é solto de vez (nenhum quadro de animação pedido)
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);
advance(IDLE_SECONDS * 1000 + 200);
const stopped = lenis.raf.mock.calls.length;
const requested = frames.mock.calls.length;
advance(3000);
expect(lenis.raf.mock.calls.length).toBe(stopped);
expect(frames.mock.calls.length).toBe(requested);
});
it("wakes up again with the wheel or any scroll event, and a second event does not start a second clock", async () => {
await mount();
const lenis = mockLenis[0];
advance(IDLE_SECONDS * 1000 + 500);
lenis.raf.mockClear();
// a roda do mouse o acorda de novo
mockTicker.time = 10;
act(() => {
window.dispatchEvent(new Event("wheel"));
window.dispatchEvent(new Event("scroll"));
});
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);
advance(100);
// um laço só: cerca de um quadro a cada 16 ms
expect(lenis.raf.mock.calls.length).toBeGreaterThan(0);
expect(lenis.raf.mock.calls.length).toBeLessThanOrEqual(8);
});
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
@@ -467,7 +438,7 @@ describe("useCinematicScroll", () => {
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
expect(farTop).toBe(TOPS.contact);
expect(farOptions).toMatchObject({ immediate: true });
expect(farOptions).toMatchObject({ immediate: true, duration: 0 });
expect(window.location.hash).toBe("#contact");
expect(document.getElementById("contact")).toHaveFocus();
@@ -481,7 +452,22 @@ describe("useCinematicScroll", () => {
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
});
it("without Lenis (touch) a far anchor is an instant jump", async () => {
it("wakes the clock before an anchor scroll, so the animation actually runs after a long rest", async () => {
await mount();
const lenis = mockLenis[0];
advance(IDLE_SECONDS * 1000 + 500);
lenis.raf.mockClear();
act(() => {
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
});
advance(100);
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
expect(lenis.raf).toHaveBeenCalled();
});
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
mockMedia(["(pointer: coarse)"]);
await mount();
act(() => {
@@ -501,17 +487,33 @@ describe("useCinematicScroll", () => {
});
});
it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
it("releases Lenis, the clock and the listeners 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();
lenis.raf.mockClear();
const requested = frames.mock.calls.length;
act(() => {
window.dispatchEvent(new Event("wheel"));
window.dispatchEvent(new Event("scroll"));
});
expect(mockTicker.add).not.toHaveBeenCalled();
advance(200);
expect(lenis.raf).not.toHaveBeenCalled();
expect(frames.mock.calls.length).toBe(requested);
});
it("creates nothing when the page unmounted before the package finished loading", async () => {
const view = render(<Home />);
view.unmount();
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
expect(mockLenis).toHaveLength(0);
expect(frames).not.toHaveBeenCalled();
});
});
});
+151 -231
View File
@@ -4,38 +4,30 @@ import { type RefObject, useEffect } from "react";
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
/*
* 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.
* Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação:
*
* 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.
* 1. As passagens entre cenas são CSS (globals.css, `animation-timeline: view()`): o conteúdo de cada seção
* (`.ds-section-shell`) chega com fade e uma subida curta enquanto a seção entra na janela, e o fundo da
* seção fica sempre inteiro (seções do mesmo tom não piscam em cinza). Nada de JavaScript por quadro, e onde
* o navegador não tem o recurso o conteúdo simplesmente já está lá. Qualquer filho direto de <main> é uma
* cena; nada aqui depende de uma seção específica existir.
* 2. Este hook cuida só do que o CSS não faz: no mouse, o Lenis suaviza a roda (com o relógio ligado apenas
* enquanto algo se move, nunca com a página parada), e os links internos (#id) "avançam o vídeo" até o
* destino em vez de saltar. No toque a rolagem nativa corre em outra thread: nem o Lenis é baixado.
*
* Cada cena tem uma entrada e uma saída, cada uma com o próprio gatilho, e a saída só começa depois que a
* entrada da mesma cena terminou (cenas curtas não brigam pela mesma opacidade). Qualquer filho direto de
* <main> é uma cena; nada aqui depende de uma seção específica existir.
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
*/
/** Deslocamento vertical, em px, com que a cena chega (entre 24 e 40). */
export const ENTER_OFFSET = 32;
/** A passagem corre enquanto o topo da cena que entra sobe de 1 altura de tela até esta fração dela. */
export const PASSAGE_END = 0.2;
/** Âncoras mais distantes que isto (em alturas de tela) são um salto direto. */
export const JUMP_SCREENS = 3;
/** Nenhuma âncora anima por mais que isto. */
export const MAX_ANCHOR_SECONDS = 0.8;
/** Depois deste tempo parada, a rolagem suave solta o relógio do GSAP. */
/** Depois deste tempo parada, a rolagem suave solta o relógio (requestAnimationFrame). */
export const IDLE_SECONDS = 1;
const vh = () => window.innerHeight;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das passagens, getBoundingClientRect não). */
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms que as passagens aplicam). */
function pageTop(el: HTMLElement) {
let top = 0;
for (
@@ -47,32 +39,6 @@ function pageTop(el: HTMLElement) {
return top;
}
/** A cena chega: fade e subida curta. Ao terminar, o estilo inline some (sem transform sobrando na cena). */
export function enterScene(tl: gsap.core.Timeline, scene: HTMLElement) {
tl.fromTo(
scene,
{ opacity: 0, y: ENTER_OFFSET },
{
opacity: 1,
y: 0,
ease: "power2.out",
clearProps: "opacity,transform",
immediateRender: false,
},
0,
);
}
/** A cena sai: fade até 0. É o último estado dela até a rolagem voltar. */
export function exitScene(tl: gsap.core.Timeline, scene: HTMLElement) {
tl.fromTo(
scene,
{ opacity: 1 },
{ opacity: 0, ease: "power2.in", immediateRender: false },
0,
);
}
/** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */
export function anchorDuration(distance: number, viewport: number) {
const screens = Math.abs(distance) / Math.max(1, viewport);
@@ -83,204 +49,158 @@ export function anchorDuration(distance: number, viewport: number) {
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
/** Clique do botão principal, sem tecla modificadora, que nenhum outro código já tratou. */
export function isPlainPrimaryClick(event: MouseEvent) {
return !(
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
/** O link (<a>) que o clique atingiu, se abre na mesma aba; `null` para qualquer outro clique. */
function sameTabLink(event: MouseEvent) {
const link =
event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!(link instanceof HTMLAnchorElement)) return null;
return !link.target || link.target === "_self" ? link : null;
}
/** O `#id` de um link para esta mesma página; `null` se ele leva a outra página ou não tem âncora. */
function hashOnThisPage(link: HTMLAnchorElement) {
const url = new URL(link.href);
const same =
url.origin === location.origin && url.pathname === location.pathname;
return same && url.hash ? url.hash : null;
}
function elementForHash(hash: string) {
try {
return document.getElementById(decodeURIComponent(hash.slice(1)));
} catch {
return null;
}
}
/** Link para outro ponto da MESMA página (`#id`): o elemento de destino e o hash; `null` para qualquer outro clique. */
export function samePageHashTarget(event: MouseEvent) {
const link = sameTabLink(event);
const hash = link && hashOnThisPage(link);
const target = hash ? elementForHash(hash) : null;
return hash && target ? { target, hash } : null;
}
/**
* Posição da rolagem (px) para mostrar `target`: o alvo que é a cena inteira para no topo dela; um alvo
* interno desconta o cabeçalho fixo, sem passar do topo da cena.
*/
export function anchorTop(target: HTMLElement, scenes: readonly HTMLElement[]) {
const scene = scenes.find((candidate) => candidate.contains(target));
const sceneTop = scene ? pageTop(scene) : 0;
if (scene === target) return sceneTop;
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
return Math.max(sceneTop, pageTop(target) - offset);
}
type SmoothScroll = {
scrollTo: (top: number, duration: number | null) => void;
destroy: () => void;
};
/**
* Lenis com o relógio ligado só enquanto a rolagem se move; parado por IDLE_SECONDS, o requestAnimationFrame
* para. Devolve `null` (sem criar nada) se a página já saiu de cena enquanto o pacote carregava.
*/
async function startSmoothScroll(
isAlive: () => boolean,
): Promise<SmoothScroll | null> {
const { default: Lenis } = await import("lenis");
if (!isAlive()) return null;
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
let frame = 0;
let lastBusy = 0;
const tick = (time: number) => {
lenis.raf(time);
if (lenis.isScrolling) lastBusy = time;
frame =
time - lastBusy > IDLE_SECONDS * 1000 ? 0 : requestAnimationFrame(tick);
};
const wake = () => {
lastBusy = performance.now();
if (!frame) frame = requestAnimationFrame(tick);
};
window.addEventListener("wheel", wake, { passive: true });
window.addEventListener("scroll", wake, { passive: true });
wake();
return {
scrollTo(top, duration) {
wake();
lenis.scrollTo(top, {
immediate: duration === null,
duration: duration ?? 0,
easing: easeInOutCubic,
});
},
destroy() {
window.removeEventListener("wheel", wake);
window.removeEventListener("scroll", wake);
cancelAnimationFrame(frame);
frame = 0;
lenis.destroy();
},
};
}
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (prefersReducedMotion()) return;
let cancelled = false;
let cleanup = () => {};
let smooth: SmoothScroll | null = null;
(async () => {
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
const smooth = !isCoarsePointer();
const [{ gsap }, { ScrollTrigger }, lenisModule] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
smooth ? import("lenis") : Promise.resolve(null),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
// Lenis: o relógio do GSAP só roda enquanto a rolagem anima; parada por IDLE_SECONDS, ele é solto
const lenis = lenisModule
? new lenisModule.default({ lerp: 0.075, wheelMultiplier: 0.85 })
: null;
let awake = false;
let lastBusy = 0;
const tick = (time: number) => {
lenis?.raf(time * 1000);
if (lenis?.isScrolling) lastBusy = time;
else if (time - lastBusy > IDLE_SECONDS) sleep();
};
function sleep() {
if (!awake) return;
awake = false;
gsap.ticker.remove(tick);
}
const wake = () => {
if (!lenis) return;
lastBusy = gsap.ticker.time;
if (awake) return;
awake = true;
gsap.ticker.add(tick);
};
if (lenis) {
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.lagSmoothing(0);
wake();
window.addEventListener("wheel", wake, { passive: true });
window.addEventListener("scroll", wake, { passive: true });
}
const scenes = Array.from(main.children) as HTMLElement[];
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
const following = scenes[index + 1];
const trigger = (
start: () => number,
end: () => number,
extra: ScrollTrigger.Vars = {},
) => ({
trigger: scene,
start,
end,
scrub: true,
invalidateOnRefresh: true,
...extra,
});
const timeline = (vars: ScrollTrigger.Vars) =>
gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: vars,
});
if (index > 0)
enterScene(
timeline(
trigger(
() => pageTop(scene) - vh(),
() => pageTop(scene) - vh() * PASSAGE_END,
),
),
scene,
);
if (following)
exitScene(
timeline(
trigger(
// só depois de a própria entrada terminar, mesmo numa cena mais curta que a tela
() =>
Math.max(
pageTop(scene) - vh() * PASSAGE_END,
pageTop(following) - vh(),
),
() => pageTop(following) - vh() * PASSAGE_END,
),
),
scene,
);
});
}, main);
// a página mudou de altura (fontes, imagens, seções que abrem e fecham): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
if (!isCoarsePointer())
startSmoothScroll(() => !cancelled).then((scroll) => {
smooth = scroll;
});
resizeObserver.observe(main);
const scrollToTop = (top: number) => {
const distance = top - window.scrollY;
const duration = anchorDuration(distance, vh());
if (lenis) {
wake();
lenis.scrollTo(top, {
immediate: duration === null,
duration: duration ?? 0,
easing: easeInOutCubic,
});
} else {
window.scrollTo({
top,
behavior: duration === null ? "instant" : "smooth",
});
}
};
const scrollToTop = (top: number) => {
const duration = anchorDuration(top - window.scrollY, vh());
if (smooth) smooth.scrollTo(top, duration);
else
window.scrollTo({
top,
behavior: duration === null ? "instant" : "smooth",
});
};
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== location.origin ||
url.pathname !== location.pathname ||
!url.hash
)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(
decodeURIComponent(url.hash.slice(1)),
);
} catch {}
if (!target) return;
event.preventDefault();
// o alvo é a cena inteira: para no topo dela; alvo interno: desconta o cabeçalho fixo, sem passar do topo da cena
const destination = target;
const scene = scenes.find((candidate) =>
candidate.contains(destination),
);
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = scene ? pageTop(scene) : 0;
const top =
scene === destination
? sceneTop
: Math.max(sceneTop, pageTop(destination) - offset);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
scrollToTop(top);
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
if (!destination.hasAttribute("tabindex")) destination.tabIndex = -1;
destination.focus({ preventScroll: true });
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
window.removeEventListener("wheel", wake);
window.removeEventListener("scroll", wake);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
sleep();
lenis?.destroy();
};
})();
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino
const onClick = (event: MouseEvent) => {
if (!isPlainPrimaryClick(event)) return;
const hit = samePageHashTarget(event);
if (!hit) return;
event.preventDefault();
if (location.hash !== hit.hash) history.pushState(null, "", hit.hash);
scrollToTop(
anchorTop(hit.target, Array.from(main.children) as HTMLElement[]),
);
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
if (!hit.target.hasAttribute("tabindex")) hit.target.tabIndex = -1;
hit.target.focus({ preventScroll: true });
};
document.addEventListener("click", onClick, true);
return () => {
cancelled = true;
cleanup();
document.removeEventListener("click", onClick, true);
smooth?.destroy();
};
}, [mainRef]);
}