fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve

Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:48 -03:00
1 parent 9e2d65a926
commit 204ebd5fa5
49 files changed
+2197 -606

No files matched your search

@@ -70,9 +70,12 @@ type Observer = {
};
let observers: Observer[] = [];
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
function Hero({ children }: { children?: React.ReactNode }) {
return (
<section className="hero">
<section className="hero" style={HERO_TAIL}>
<HeroField />
{children}
</section>
@@ -597,3 +600,122 @@ describe("HeroField: desmontagem em pontos que faltavam", () => {
expect(hero).not.toHaveAttribute("data-dive");
});
});
/** Um contexto WebGL de teste que conta ao navegador o nome do renderizador (WEBGL_debug_renderer_info). */
function contextRenderedBy(renderer: unknown, loseContext = jest.fn()) {
const RENDERER = 37446;
return {
getExtension: (name: string) => {
if (name === "WEBGL_debug_renderer_info")
return { UNMASKED_RENDERER_WEBGL: RENDERER };
return name === "WEBGL_lose_context" ? { loseContext } : null;
},
getParameter: (parameter: number) =>
parameter === RENDERER ? renderer : undefined,
};
}
describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", () => {
it.each([
[
"SwiftShader (Chrome sem GPU)",
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
],
["llvmpipe (Mesa)", "llvmpipe (LLVM 15.0.7, 256 bits)"],
[
"Microsoft Basic Render Driver",
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
],
])(
"path: %s -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene, and the probe context is still released",
async (_name, renderer) => {
const loseContext = jest.fn();
setContext(() => contextRenderedBy(renderer, loseContext));
const addListener = jest.spyOn(window, "addEventListener");
render(<Hero />);
act(() => {
window.dispatchEvent(new Event("pointermove"));
});
await advance(3000);
await finishLoading();
expect(section()).not.toHaveAttribute("data-dive");
expect(screen.queryByTestId("scene")).toBeNull();
expect(mockScenes).toHaveLength(0);
expect(mockPrepare).not.toHaveBeenCalled();
expect(observers).toHaveLength(0);
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
"scroll",
);
expect(loseContext).toHaveBeenCalledTimes(1);
},
);
it("path: a hardware renderer -> the scene mounts and the dive starts", async () => {
setContext(() =>
contextRenderedBy("ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11)"),
);
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
expect(section()).toHaveAttribute("data-dive");
});
it("path: no renderer info extension (Firefox, Safari) -> cannot tell, so the scene mounts", async () => {
setContext(() => ({ getExtension: () => null }));
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it("path: the renderer name is not a string -> cannot tell, so the scene mounts", async () => {
setContext(() => contextRenderedBy(null));
render(<Hero />);
await advance(1200);
await finishLoading();
expect(screen.getByTestId("scene")).toBeInTheDocument();
});
it.each([
["missing from the stylesheet", undefined],
["not a number", "abc"],
])(
"path: --hero-tail %s -> no tail, the rail is the whole hero minus one screen (1960 px, never NaN)",
(_name, tail) => {
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 800,
});
const style = tail
? ({ "--hero-tail": tail } as React.CSSProperties)
: undefined;
render(
<section className="hero" style={style}>
<HeroField />
</section>,
);
section().getBoundingClientRect = () =>
({ top: -700, height: 2760, bottom: 2060 }) as DOMRect;
act(() => {
window.dispatchEvent(new Event("resize"));
jest.advanceTimersByTime(40);
});
// 2760 - 800 = 1960 de trilho; com a cauda de 1,7 tela (o CSS real) seria 1400 e o progresso 0,5
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
700 / 1960,
3,
);
},
);
});
+28 -2
View File
@@ -38,14 +38,29 @@ function mockMedia(active: string[] = [DIVE_QUERY]) {
}));
}
/** Um WebGL2 que abre, e que diz na extensão de depuração em que renderizador desenha. */
function mockWebGLRenderer(renderer: string) {
HTMLCanvasElement.prototype.getContext = (() => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info"
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? renderer : null,
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
}
function mockNavigator(values: Record<string, unknown>) {
for (const [key, value] of Object.entries(values))
Object.defineProperty(navigator, key, { configurable: true, value });
}
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
function Hero() {
return (
<section className="hero">
<section className="hero" style={HERO_TAIL}>
<HeroField />
</section>
);
@@ -155,7 +170,7 @@ describe("HeroField (ambient background)", () => {
expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
});
it.each([
it.each<[string, () => void]>([
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
@@ -168,6 +183,17 @@ describe("HeroField (ambient background)", () => {
undefined;
},
],
...[
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"llvmpipe (LLVM 15.0.7, 256 bits)",
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
].map(
(renderer) =>
[
`software WebGL (${renderer.slice(0, 24)}...)`,
() => mockWebGLRenderer(renderer),
] as [string, () => void],
),
])(
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
async (_label, arrange) => {
+27 -13
View File
@@ -7,17 +7,23 @@ import {
useRef,
useState,
} from "react";
import { isLowPowerDevice } from "@/lib/device";
import { isLowPowerDevice, isSoftwareWebGL } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** 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)";
/**
* Telas de hero além do mergulho (globals.css: height = 170svh + --hero-dive): 100 do palco preso e 70 em que Projetos
* sobe sobre ele (margin-top de -70svh). O mergulho (progresso 0 a 1) é o resto da altura do hero.
* Telas de hero além do mergulho: 100 do palco preso e o resto em que Projetos sobe sobre ele. Quem manda é o CSS
* (`--hero-tail` em globals.css, a mesma variável da altura do hero e da margem de Projetos); o mergulho (progresso 0
* a 1) é o resto da altura do hero. Sem a variável (a folha de estilos não definiu), não há cauda: só o palco.
*/
export const HERO_TAIL_SCREENS = 1.7;
function heroTailScreens(section: HTMLElement) {
const tail = Number.parseFloat(
getComputedStyle(section).getPropertyValue("--hero-tail"),
);
return Number.isFinite(tail) ? tail : 1;
}
/** 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. */
@@ -37,12 +43,18 @@ const smooth = (a: number, b: number, x: number) => {
return t * t * (3 - 2 * t);
};
/**
* O aparelho abre um contexto WebGL2 de verdade, na GPU. Um WebGL por software (SwiftShader, llvmpipe, Microsoft
* Basic Render) abre, mas desenha a cena a poucos quadros por segundo na CPU: nesse caso vale o hero estático.
*/
function supportsWebGL2() {
if (typeof window.WebGL2RenderingContext === "undefined") return false;
try {
const context = document.createElement("canvas").getContext("webgl2");
context?.getExtension("WEBGL_lose_context")?.loseContext();
return context !== null;
if (!context) return false;
const software = isSoftwareWebGL(context);
context.getExtension("WEBGL_lose_context")?.loseContext();
return !software;
} catch {
return false;
}
@@ -89,11 +101,9 @@ function whenIdle(run: () => void) {
/**
* 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.
* num intervalo ocioso) e só então o Canvas. Devolve `null` 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;
@@ -113,13 +123,14 @@ function trackDive(
onDone: (done: boolean) => void,
) {
section.dataset.dive = "";
const tail = heroTailScreens(section);
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
// o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele
const span = Math.max(
rect.height - window.innerHeight * HERO_TAIL_SCREENS,
rect.height - window.innerHeight * tail,
window.innerHeight * 0.8,
);
const p = Math.min(1, Math.max(0, -rect.top / span));
@@ -167,8 +178,8 @@ function trackVisibility(
}
/**
* 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
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
* 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
@@ -191,12 +202,15 @@ export default function HeroField() {
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
// WebGL2 só por software (ou nenhum): o hero estático de 100svh, já na hidratação, sem o bloco de rolagem do
// mergulho que depois seria desfeito com a página já rolada
if (!supportsWebGL2()) return;
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
// 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);
@@ -0,0 +1,99 @@
import { act, render } from "@testing-library/react";
import HeroFieldScene from "./HeroFieldScene";
/**
* Caminho-base de mountField que HeroFieldScene.test.tsx não exerce: o erro que vem ANTES de as peças existirem
* (a nuvem de pontos ou a câmera não se criam). O renderizador já abriu o contexto WebGL, então precisa ser solto
* (`disposeRenderer`, o `else` do `catch`), o canvas sai da página e a página é avisada. Os outros caminhos de
* mountField (desmontada antes da 1ª tarefa, entre o renderizador e as peças, durante a compilação, shader que não
* compila, sucesso) estão em HeroFieldScene.test.tsx.
*/
const mockRenderers: MockRenderer[] = [];
let mockBuildThrows = false;
class MockRenderer {
domElement = document.createElement("canvas");
render = jest.fn();
compileAsync = jest.fn(() => Promise.resolve());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = jest.fn();
getPixelRatio = () => 1;
constructor() {
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const actual = jest.requireActual("three");
class FailingCamera extends actual.PerspectiveCamera {
constructor(...args: unknown[]) {
if (mockBuildThrows) throw new Error("sem memória para a cena");
super(...args);
}
}
return {
...actual,
PerspectiveCamera: FailingCamera,
WebGLRenderer: jest.fn(() => new MockRenderer()),
};
});
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockBuildThrows = false;
});
afterEach(() => {
jest.useRealTimers();
});
const props = () => ({
progressRef: { current: 0 },
visible: false,
onReady: jest.fn(),
onError: jest.fn(),
});
const buildScene = () =>
act(async () => {
await jest.advanceTimersByTimeAsync(10);
});
describe("mountField: erro antes de as peças existirem", () => {
it("path: the scene parts fail to build -> the renderer opened a moment ago is released, the canvas leaves and the page is told once", async () => {
mockBuildThrows = true;
const scene = props();
const { container } = render(<HeroFieldScene {...scene} />);
await buildScene();
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("path: nothing fails -> the same setup mounts, draws and is released only on unmount (the control for the case above)", async () => {
const scene = props();
const { container, unmount } = render(<HeroFieldScene {...scene} />);
await buildScene();
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).not.toBeNull();
expect(scene.onError).not.toHaveBeenCalled();
unmount();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
});
});
@@ -502,14 +502,14 @@ describe("createField: quando pedir o próximo quadro", () => {
const scene = setup({ visible: true });
scene.settle(200);
expect(scene.frame()).toBe(0);
expect(scene.frame(50)).toBe(0);
});
it("path: everything settled and the hero is not visible -> no frame", () => {
const scene = setup({ visible: false });
scene.settle(200);
expect(scene.frame()).toBe(0);
expect(scene.frame(50)).toBe(0);
});
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
@@ -518,7 +518,7 @@ describe("createField: quando pedir o próximo quadro", () => {
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame()).toBeGreaterThan(0);
expect(scene.frame(50)).toBeGreaterThan(0);
});
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
@@ -527,7 +527,7 @@ describe("createField: quando pedir o próximo quadro", () => {
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame()).toBe(0);
expect(scene.frame(50)).toBe(0);
});
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
@@ -543,11 +543,11 @@ describe("createField: quando pedir o próximo quadro", () => {
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame()).toBe(0);
expect(scene.frame(50)).toBe(0);
scene.engine.setVisible(true);
expect(scene.frame()).toBeGreaterThan(0);
expect(scene.frame(50)).toBeGreaterThan(0);
});
it("path: several requests before a frame become ONE animation frame", () => {
@@ -843,6 +843,86 @@ describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
});
});
describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", () => {
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
function drawnOn(
scene: ReturnType<typeof setup>,
refreshMs: number,
seconds = 1,
) {
const before = scene.renderer.render.mock.calls.length;
const frames = Math.round((seconds * 1000) / refreshMs);
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
return scene.renderer.render.mock.calls.length - before;
}
function withMovingCursor() {
const scene = setup({ visible: true });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
return scene;
}
it.each([
[144, 1000 / 144],
[120, 1000 / 120],
[60, 1000 / 60],
])(
"path: a %i Hz screen with the cursor moving -> about 30 frames a second, not one per screen frame",
(_hz, refreshMs) => {
const scene = withMovingCursor();
const drawn = drawnOn(scene, refreshMs, 2);
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
expect(drawn).toBeLessThanOrEqual(2 * 31);
},
);
it("path: a skipped frame draws nothing, keeps the clock of the last drawn one and asks for the next", () => {
const scene = withMovingCursor();
scene.frame(50); // o último desenhado
const drawn = scene.renderer.render.mock.calls.length;
expect(scene.frame(10)).toBe(1); // 10 ms depois: pula e pede outro
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
expect(scene.frame(10)).toBe(1);
expect(scene.frame(10)).toBe(1);
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
// 40 ms depois do último desenhado: sai (o relógio do quadro pulado não conta)
scene.frame(10);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
const scene = setup({ visible: true });
scene.frame(); // a entrada ainda corre
const intro = drawnOn(scene, 1000 / 144, 0.5);
expect(intro).toBe(Math.round(500 / (1000 / 144)));
scene.settle(200); // tudo assentado
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
expect(scrolling).toBe(15);
});
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
const scene = setup({ visible: true });
scene.settle(200);
scene.frame(33);
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
const drawn = scene.renderer.render.mock.calls.length;
scene.frame(33);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
});
describe("createField: a batida de 30 quadros por segundo", () => {
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
const interval = jest
+191 -24
View File
@@ -3,7 +3,9 @@ import HeroFieldScene from "./HeroFieldScene";
/*
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
* liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê.
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
* existe: o renderizador é um dublê.
*/
type RendererOptions = {
@@ -13,12 +15,15 @@ type RendererOptions = {
};
const mockRenderers: MockRenderer[] = [];
let mockFailToCreate = false;
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => mockCompile());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
@@ -59,6 +64,7 @@ beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockFailToCreate = false;
mockCompile = () => Promise.resolve();
setDevicePixelRatio(1);
});
@@ -66,9 +72,27 @@ afterEach(() => {
jest.useRealTimers();
});
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
const nextTask = () =>
act(async () => {
await jest.runOnlyPendingTimersAsync();
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
describe("HeroFieldScene (ambient 3D)", () => {
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => {
const { container } = render(<HeroFieldScene {...props()} />);
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toMatchObject({
@@ -88,15 +112,18 @@ describe("HeroFieldScene (ambient 3D)", () => {
[1.25, 1.25],
[2, 1.5],
[3, 1.5],
])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => {
setDevicePixelRatio(device);
render(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
});
])(
"caps the pixel ratio at 1.5 (device %s -> %s)",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => {
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
render(<HeroFieldScene {...props()} />);
await mount(<HeroFieldScene {...props()} />);
expect(frames).toHaveBeenCalledTimes(1);
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
@@ -115,9 +142,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
});
it("tells the page it is ready after the second frame", () => {
it("tells the page it is ready after the second frame", async () => {
const scene = props();
render(<HeroFieldScene {...scene} />);
await mount(<HeroFieldScene {...scene} />);
expect(scene.onReady).not.toHaveBeenCalled();
act(() => {
@@ -127,10 +154,12 @@ describe("HeroFieldScene (ambient 3D)", () => {
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => {
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = render(<HeroFieldScene {...scene} visible={false} />);
const { rerender } = await mount(
<HeroFieldScene {...scene} visible={false} />,
);
act(() => {
jest.advanceTimersByTime(6000);
});
@@ -158,9 +187,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
expect(jest.getTimerCount()).toBe(0);
});
it("releases the context, the canvas and the timers when it unmounts", () => {
it("releases the context, the canvas and the timers when it unmounts", async () => {
const scene = props();
const { container, unmount } = render(
const { container, unmount } = await mount(
<HeroFieldScene {...scene} visible />,
);
expect(container.querySelector("canvas")).not.toBeNull();
@@ -178,9 +207,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => {
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
const first = props();
const { rerender } = render(<HeroFieldScene {...first} />);
const { rerender } = await mount(<HeroFieldScene {...first} />);
const second = props();
rerender(
<HeroFieldScene
@@ -200,11 +229,11 @@ describe("HeroFieldScene (ambient 3D)", () => {
expect(second.onReady).toHaveBeenCalledTimes(1);
});
it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => {
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
mockFailToCreate = true;
const scene = props();
const { container } = render(<HeroFieldScene {...scene} />);
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
@@ -217,18 +246,18 @@ describe("HeroFieldScene (ambient 3D)", () => {
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
])(
"draws at a pixel ratio of %p -> the renderer gets %p",
(device, expected) => {
async (device, expected) => {
setDevicePixelRatio(device);
render(<HeroFieldScene {...props()} />);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => {
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
const scene = props();
render(<HeroFieldScene {...scene} />);
await mount(<HeroFieldScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
@@ -238,4 +267,142 @@ describe("HeroFieldScene (ambient 3D)", () => {
expect(scene.onError).toHaveBeenCalledTimes(1);
});
describe("built in separate tasks (no single long task)", () => {
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
render(<HeroFieldScene {...scene} />);
// nada acontece dentro do commit do React: nem o contexto WebGL
expect(mockRenderers).toHaveLength(0);
await nextTask();
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(frames).not.toHaveBeenCalled();
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
finishCompile();
await act(async () => {
await Promise.resolve();
});
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
expect(mockRenderers[0].setSize).toHaveBeenCalled();
expect(frames).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].render).not.toHaveBeenCalled();
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("compiles the very scene it is going to draw", async () => {
await mount(<HeroFieldScene {...props()} />);
const [scene, camera] = mockRenderers[0].compileAsync.mock
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
expect(scene.isScene).toBe(true);
expect(camera.isCamera).toBe(true);
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
});
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
const scene = props();
const { unmount } = render(<HeroFieldScene {...scene} />);
unmount();
await buildScene();
expect(mockRenderers).toHaveLength(0);
expect(scene.onError).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
const { container, unmount } = render(<HeroFieldScene {...props()} />);
await nextTask();
expect(mockRenderers).toHaveLength(1);
unmount();
await buildScene();
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
});
it("releases the context if it is unmounted while the shader compiles", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const { container, unmount } = await mount(
<HeroFieldScene {...props()} />,
);
expect(mockRenderers).toHaveLength(1);
unmount();
finishCompile();
await buildScene();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(frames).not.toHaveBeenCalled();
});
it("tells the page, and frees the context, when the shader does not compile", async () => {
mockCompile = () => Promise.reject(new Error("shader inválido"));
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it("applies the visibility the page asked for while it was still being built", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = render(
<HeroFieldScene {...scene} visible={false} />,
);
rerender(<HeroFieldScene {...scene} visible />);
await buildScene();
await act(async () => {
await jest.advanceTimersByTimeAsync(980);
});
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
expect(frames.mock.calls.length).toBeGreaterThan(20);
});
});
});
+137 -29
View File
@@ -382,9 +382,9 @@ const FRAME = 5.2;
/*
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
* 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) entram um pouco antes de a
* câmera chegar a cada camada e saem um pouco depois: tela 0,18–0,49; integrações 0,51–0,75; dados 0,79 em diante
* (a última fica até Projetos subir e cobri-la).
* imagem, uma tabela de dados). As legendas do Hero (HERO_CAPTIONS, em hero-captions.ts) entram um pouco antes de
* a câmera chegar a cada camada e saem um pouco depois (a última fica até Projetos subir e cobri-la); se os
* quadros abaixo mudarem, os trechos delas mudam junto.
* 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.
*/
@@ -602,11 +602,16 @@ export function updatePointer(rig: Rig, tick: Tick) {
return targetAmt;
}
/** A entrada ou a rolagem ainda não assentou (o progresso mostrado ainda não alcançou o da página). */
export function isSettling(u: Uniforms, s: FieldState, target: number) {
return u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
}
/**
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
* rolagem); o quadro pede o 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.
* rolagem); o quadro pede o seguinte (no ritmo da tela, até 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.
*/
export function needsFrame(
u: Uniforms,
@@ -616,12 +621,18 @@ export function needsFrame(
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);
return isSettling(u, s, target) || (visible && interacting);
}
/**
* Intervalo mínimo (ms) entre dois quadros quando só o cursor os pede: uns 30 por segundo, mesmo numa tela de 144 Hz
* (que desenhava 142). Fica um pouco abaixo de 33,3 (dois quadros de uma tela de 60 Hz) para a tela de 60 Hz manter
* 30 e a batida (um setInterval de 33 ms, com a folga do temporizador) não perder quadros.
*/
const CURSOR_FRAME_MS = 32;
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
function createMaterial() {
return new ShaderMaterial({
@@ -824,18 +835,46 @@ function listenToField({
}
/**
* A cena sem React: cria o renderizador dentro de `host` e desenha sob demanda. `invalidate` pede um quadro no
* próximo requestAnimationFrame (vários pedidos viram um); cada quadro atualiza os uniforms, a câmera e o cursor e
* pede o seguinte só se algo ainda muda. Lança se o aparelho não cria um contexto WebGL.
* Completa o renderizador com o que o quadro desenha: a nuvem de pontos e o estado. Separado de `createRenderer` (que
* abre o contexto WebGL) para a montagem em tarefas poder pôr um passo em cada uma.
*/
export function createField(
function createParts(renderer: WebGLRenderer) {
return {
renderer,
world: buildWorld(),
state: createState(),
helpers: createHelpers(),
};
}
type Parts = ReturnType<typeof createParts>;
/** Solta o contexto WebGL e tira o canvas da página. */
function disposeRenderer(renderer: WebGLRenderer) {
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
/** Solta o que `createParts` criou: a geometria, o material e o contexto WebGL. */
function disposeParts({ renderer, world }: Parts) {
world.geometry.dispose();
world.material.dispose();
disposeRenderer(renderer);
}
/**
* A cena sem React, a partir das peças já criadas: liga o tamanho, o mouse e a perda do contexto, e desenha sob
* demanda. `invalidate` pede um quadro no próximo requestAnimationFrame (vários pedidos viram um); cada quadro
* atualiza os uniforms, a câmera e o cursor e pede o seguinte só se algo ainda muda. O primeiro quadro sai no
* requestAnimationFrame que `resize` pede, numa tarefa própria.
*/
function startField(
host: HTMLElement,
parts: Parts,
{ progressRef, onReady, onError }: FieldCallbacks,
): FieldEngine {
const renderer = createRenderer(host);
const world = buildWorld();
const state = createState();
const helpers = createHelpers();
const { renderer, world, state, helpers } = parts;
const started = performance.now();
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
let visible = false;
@@ -854,11 +893,22 @@ export function createField(
function render(now: number) {
raf = 0;
if (!alive) return;
const target = progressRef.current ?? 0;
// só o cursor pede quadro (a entrada e a rolagem seguem a cada quadro): pula este se faz menos de ~32 ms do
// último e pede o próximo, em vez de desenhar a cada quadro da tela
if (
last &&
now - last < CURSOR_FRAME_MS &&
!isSettling(world.material.uniforms, state, target)
) {
invalidate();
return;
}
const delta = last ? (now - last) / 1000 : 1 / 60;
last = now;
try {
const step = stepField(world, state, helpers, layout, {
target: progressRef.current ?? 0,
target,
visible,
time: (now - started) / 1000,
delta,
@@ -903,15 +953,59 @@ export function createField(
cancelAnimationFrame(raf);
stopPulse?.();
stopListening();
world.geometry.dispose();
world.material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
disposeParts(parts);
},
};
}
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL. */
export function createField(
host: HTMLElement,
callbacks: FieldCallbacks,
): FieldEngine {
return startField(host, createParts(createRenderer(host)), callbacks);
}
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa, depois de pintar e de atender o visitante. */
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/**
* A mesma cena montada em tarefas separadas, para nenhuma passar de uns poucos milissegundos (criar tudo de uma vez
* era uma só tarefa de 90 a 250 ms): o renderizador (o contexto WebGL); a nuvem de pontos e o estado; a compilação do
* shader (`compileAsync`, que o navegador faz fora da thread principal); e, por fim, o tamanho e o primeiro quadro
* (que sai no requestAnimationFrame seguinte). Devolve `null` se `isAlive` virou falso no caminho; lança se o
* aparelho não cria um contexto WebGL ou se o shader não compila.
*/
export async function mountField(
host: HTMLElement,
callbacks: FieldCallbacks,
isAlive: () => boolean,
): Promise<FieldEngine | null> {
await nextTask();
if (!isAlive()) return null;
const renderer = createRenderer(host);
let parts: Parts | undefined;
try {
await nextTask();
if (!isAlive()) {
disposeRenderer(renderer);
return null;
}
parts = createParts(renderer);
await renderer.compileAsync(parts.world.scene, parts.world.camera);
await nextTask();
} catch (error) {
if (parts) disposeParts(parts);
else disposeRenderer(renderer);
throw error;
}
if (!isAlive()) {
disposeParts(parts);
return null;
}
return startField(host, parts, callbacks);
}
export default function HeroFieldScene({
progressRef,
visible,
@@ -926,27 +1020,41 @@ export default function HeroFieldScene({
callbacks.current = { onReady, onError };
});
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois de o `visible` já ter chegado)
const wantsVisible = useRef(visible);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
try {
engine.current = createField(host, {
let alive = true;
mountField(
host,
{
progressRef,
onReady: () => callbacks.current.onReady(),
onError: () => callbacks.current.onError(),
},
() => alive,
)
.then((created) => {
if (!created) return;
if (!alive) return created.dispose();
engine.current = created;
created.setVisible(wantsVisible.current);
})
// sem WebGL (ou o contexto não abriu, o shader não compilou): o hero fica com o fundo estático
.catch(() => {
if (alive) callbacks.current.onError();
});
} catch {
// sem WebGL (ou o contexto não abriu): o hero fica com o fundo estático
callbacks.current.onError();
return;
}
return () => {
alive = false;
engine.current?.dispose();
engine.current = null;
};
}, [progressRef]);
useEffect(() => {
wantsVisible.current = visible;
engine.current?.setVisible(visible);
}, [visible]);
+17 -6
View File
@@ -129,14 +129,25 @@ describe("samePageHashTarget (rules shared with the arrival code)", () => {
).toBeNull();
});
it("refuses a link to the same path with another query string", () => {
window.history.replaceState(null, "", "/?a=1");
it("ignores the query string: the page is the same, so the header anchors keep scrolling with ?tipo= in the URL", () => {
window.history.replaceState(null, "", "/?tipo=site");
// O link do cabeçalho não repete a busca; um link com outra busca ainda é a mesma página.
expect(samePageHashTarget(clickOn(`<a href="/#alvo">x</a>`))?.hash).toBe(
"#alvo",
);
expect(
samePageHashTarget(clickOn(`<a href="/?a=2#alvo">x</a>`)),
).toBeNull();
expect(
samePageHashTarget(clickOn(`<a href="/?a=1#alvo">x</a>`))?.hash,
samePageHashTarget(clickOn(`<a href="/?tipo=app#alvo">x</a>`))?.hash,
).toBe("#alvo");
expect(
samePageHashTarget(clickOn(`<a href="/?tipo=site#alvo">x</a>`))?.hash,
).toBe("#alvo");
});
it("still refuses a link to another path, with or without a query string", () => {
window.history.replaceState(null, "", "/?tipo=site");
expect(
samePageHashTarget(clickOn(`<a href="/outra?tipo=site#alvo">x</a>`)),
).toBeNull();
});
});
+5 -4
View File
@@ -32,13 +32,14 @@ function sameTabLink(event: MouseEvent) {
return !link.target || link.target === "_self" ? link : null;
}
/** O `#id` de um link para esta mesma página (mesmo caminho e mesma busca); `null` se leva a outro lugar ou não tem âncora. */
/**
* O `#id` de um link para esta mesma página (mesma origem e mesmo caminho); `null` se leva a outro lugar ou não tem
* âncora. A busca (`?tipo=`) não conta: a página é a mesma e o link do cabeçalho (`/#contact`) não a repete.
*/
function hashOnThisPage(link: HTMLAnchorElement) {
const url = new URL(link.href);
const same =
url.origin === location.origin &&
url.pathname === location.pathname &&
url.search === location.search;
url.origin === location.origin && url.pathname === location.pathname;
return same && url.hash ? url.hash : null;
}
@@ -1,54 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { HERO_TAIL_SCREENS } from "./HeroField";
/*
* O mergulho do hero é uma conta que vive em dois lugares: o CSS (altura do hero e margem de Projetos) e o
* HeroField (de onde o progresso --hero-p sai). Se um mudar sem o outro, o progresso acaba antes ou depois de
* Projetos começar a subir e volta o tempo morto (ou a tela vazia) no fim do mergulho.
*/
const css = readFileSync(join(__dirname, "../../globals.css"), "utf8");
function diveBlock() {
const start = css.indexOf(".hero[data-dive] {");
expect(start).toBeGreaterThan(-1);
return css.slice(start, start + 6000);
}
describe("hero dive (globals.css <-> HeroField)", () => {
it("the hero is HERO_TAIL_SCREENS screens taller than the dive", () => {
const height = /height: calc\((\d+)svh \+ var\(--hero-dive\)\)/.exec(
diveBlock(),
);
expect(height).not.toBeNull();
expect(Number(height?.[1]) / 100).toBe(HERO_TAIL_SCREENS);
});
it("Projects rises over the stage by the tail minus the pinned stage (one screen)", () => {
const margin = /margin-top: -(\d+)svh/.exec(diveBlock());
expect(margin).not.toBeNull();
expect(Number(margin?.[1]) / 100).toBeCloseTo(HERO_TAIL_SCREENS - 1);
});
it("the last caption stays on stage until Projects covers it (its end is past the end of the dive)", () => {
const last =
/li:nth-child\(3\) \{\s*--from: ([\d.]+);\s*--to: ([\d.]+);/.exec(
diveBlock(),
);
expect(last).not.toBeNull();
expect(Number(last?.[2])).toBeGreaterThan(1);
});
it("each caption starts before the previous one is gone far enough that the gap between them is short", () => {
const ranges = [
...diveBlock().matchAll(
/li:nth-child\((\d)\) \{\s*--from: ([\d.]+);\s*--to: ([\d.]+);/g,
),
].map((match) => [Number(match[2]), Number(match[3])]);
expect(ranges).toHaveLength(3);
for (let i = 1; i < ranges.length; i++) {
// sem texto entre uma legenda e a seguinte: no máximo 0,1 de progresso (~100 px de rolagem)
expect(ranges[i][0] - ranges[i - 1][1]).toBeLessThanOrEqual(0.1);
}
});
});
+110
View File
@@ -0,0 +1,110 @@
import { act, render } from "@testing-library/react";
import type { CSSProperties } from "react";
import HeroField, { DIVE_QUERY } from "./HeroField";
/*
* A altura do mergulho do hero vive num lugar só: `--hero-tail` (globals.css), que o CSS usa na altura do hero e na
* margem de Projetos, e que o HeroField lê para saber onde o progresso (--hero-p) acaba: quando Projetos começa a
* subir sobre o palco, `--hero-tail` telas antes do fim do hero. Estes testes conferem o comportamento (o progresso
* segue a variável), não o texto do CSS.
*/
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
default: () => null,
prepareField: () => {},
}));
const win = window as unknown as Record<string, unknown>;
const original = {
matchMedia: window.matchMedia,
webgl2: win.WebGL2RenderingContext,
getContext: HTMLCanvasElement.prototype.getContext,
innerHeight: Object.getOwnPropertyDescriptor(window, "innerHeight"),
};
const SCREEN = 800;
beforeEach(() => {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query === DIVE_QUERY,
media: query,
addEventListener() {},
removeEventListener() {},
}));
Object.defineProperty(navigator, "hardwareConcurrency", {
configurable: true,
value: 8,
});
win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
HTMLCanvasElement.prototype.getContext = (() => ({
getExtension: () => null,
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: SCREEN,
});
});
afterEach(() => {
window.matchMedia = original.matchMedia;
win.WebGL2RenderingContext = original.webgl2;
HTMLCanvasElement.prototype.getContext = original.getContext;
if (original.innerHeight)
Object.defineProperty(window, "innerHeight", original.innerHeight);
});
/** O hero com a altura (px) que o CSS lhe dá, rolado `scrolled` px; devolve o progresso do mergulho. */
function progressAt({
tail,
height,
scrolled,
}: {
tail?: number;
height: number;
scrolled: number;
}) {
const style =
tail === undefined ? undefined : ({ "--hero-tail": tail } as CSSProperties);
const { container, unmount } = render(
<section className="hero" style={style}>
<HeroField />
</section>,
);
const hero = container.querySelector(".hero") as HTMLElement;
hero.getBoundingClientRect = () =>
({ top: -scrolled, height, bottom: height - scrolled }) as DOMRect;
act(() => {
window.dispatchEvent(new Event("resize"));
});
// o rAF do jsdom corre em ~16 ms
return new Promise<number>((resolve) => {
setTimeout(() => {
const progress = Number(hero.style.getPropertyValue("--hero-p"));
unmount();
resolve(progress);
}, 60);
});
}
describe("hero dive: --hero-tail is the one source of its height", () => {
it("ends the dive where Projects starts to rise: --hero-tail screens before the end of the hero", async () => {
// hero de 2800 px com 2 telas de cauda: o mergulho tem 2800 - 2 * 800 = 1200 px
expect(
await progressAt({ tail: 2, height: 2800, scrolled: 600 }),
).toBeCloseTo(0.5);
expect(await progressAt({ tail: 2, height: 2800, scrolled: 1200 })).toBe(1);
});
it("follows the variable: the same hero with a shorter tail has a longer dive", async () => {
// 2800 - 1,5 * 800 = 1600 px de mergulho
expect(
await progressAt({ tail: 1.5, height: 2800, scrolled: 800 }),
).toBeCloseTo(0.5);
});
it("treats a hero without --hero-tail as stage only (the dive is the rest of the height)", async () => {
// 2000 - 1 * 800 = 1200 px de mergulho
expect(await progressAt({ height: 2000, scrolled: 600 })).toBeCloseTo(0.5);
});
});
@@ -509,15 +509,19 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
expect(prevented).toBe(false);
});
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
it("path: reduced motion -> no Lenis, and each anchor click is an instant jump, never an animation", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
const { prevented } = click(link("projetos"));
const first = click(link("projetos"));
const second = click(link("projetos"));
expect(prevented).toBe(false);
expect(first.prevented).toBe(true);
expect(second.prevented).toBe(true);
expect(mockLenis).toHaveLength(0);
expect(window.scrollTo).not.toHaveBeenCalled();
expect(window.scrollTo).toHaveBeenCalledTimes(2);
for (const [options] of (window.scrollTo as jest.Mock).mock.calls)
expect(options).toMatchObject({ behavior: "instant" });
});
it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => {
@@ -252,19 +252,32 @@ describe("useCinematicScroll", () => {
expect(contact).toHaveTextContent("Contato");
});
it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => {
it("with reduced motion there is no smooth scroll, and EVERY anchor click jumps instantly (no animation)", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
expect(mockLenis).toHaveLength(0);
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
act(() => {
link.dispatchEvent(event);
});
for (let i = 0; i < 3; i++) {
(window.scrollTo as jest.Mock).mockClear();
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
});
act(() => {
link.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(false);
// o clique é tratado em todas as vezes, não só na primeira: salto instantâneo até a cena
expect(event.defaultPrevented).toBe(true);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
expect(window.scrollTo).toHaveBeenCalledWith({
top: TOPS.projects,
behavior: "instant",
});
}
expect(mockLenis).toHaveLength(0);
expect(window.scrollTo).not.toHaveBeenCalled();
expect(document.getElementById("projects")).toHaveFocus();
});
describe("smooth scroll", () => {
+11 -4
View File
@@ -15,7 +15,8 @@ import { focusFragment, onFragmentClick } from "./fragment-nav";
* 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 (o clique vem de fragment-nav). No toque a rolagem nativa corre em outra thread:
* nem o Lenis é baixado.
* nem o Lenis é baixado. Com movimento reduzido não há Lenis nem animação, mas o clique segue tratado aqui: o
* salto é instantâneo, em todos os cliques (o link do Next só rolava no primeiro, com a mesma âncora).
*
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
*/
@@ -119,17 +120,23 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (prefersReducedMotion()) return;
const reducedMotion = prefersReducedMotion();
let cancelled = false;
let smooth: SmoothScroll | null = null;
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
if (!isCoarsePointer())
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote); com movimento reduzido,
// também não há suavização
if (!reducedMotion && !isCoarsePointer())
startSmoothScroll(() => !cancelled).then((scroll) => {
smooth = scroll;
});
const scrollToTop = (top: number) => {
if (reducedMotion) {
// "instant" vale mesmo com `scroll-behavior: smooth` no CSS
window.scrollTo({ top, behavior: "instant" });
return;
}
const duration = anchorDuration(top - window.scrollY, vh());
if (smooth) smooth.scrollTo(top, duration);
else