diff --git a/app/components/Reveal.test.tsx b/app/components/Reveal.test.tsx index e3852f4..d970380 100644 --- a/app/components/Reveal.test.tsx +++ b/app/components/Reveal.test.tsx @@ -179,9 +179,12 @@ describe("Section", () => { "ds-reveal", ); expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal"); - expect(screen.getByText("lateral").closest("aside")).toHaveClass( - "ds-reveal", - ); + // um
, não um
diff --git a/app/components/cinematic/HeroField.paths.test.tsx b/app/components/cinematic/HeroField.paths.test.tsx index 3e2ecbc..f93847d 100644 --- a/app/components/cinematic/HeroField.paths.test.tsx +++ b/app/components/cinematic/HeroField.paths.test.tsx @@ -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 ( -
+
{children}
@@ -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(); + 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(); + 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(); + 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(); + 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().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, + ); + }, + ); +}); diff --git a/app/components/cinematic/HeroField.test.tsx b/app/components/cinematic/HeroField.test.tsx index ffb02a7..a68e8b9 100644 --- a/app/components/cinematic/HeroField.test.tsx +++ b/app/components/cinematic/HeroField.test.tsx @@ -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) { 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 ( -
+
); @@ -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) => { diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx index 08d8c0c..70b6ef9 100644 --- a/app/components/cinematic/HeroField.tsx +++ b/app/components/cinematic/HeroField.tsx @@ -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(".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); diff --git a/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx b/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx new file mode 100644 index 0000000..cf339c2 --- /dev/null +++ b/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx @@ -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(); + 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(); + 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); + }); +}); diff --git a/app/components/cinematic/HeroFieldScene.paths.test.tsx b/app/components/cinematic/HeroFieldScene.paths.test.tsx index 04041b6..317b6a2 100644 --- a/app/components/cinematic/HeroFieldScene.paths.test.tsx +++ b/app/components/cinematic/HeroFieldScene.paths.test.tsx @@ -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, + 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 diff --git a/app/components/cinematic/HeroFieldScene.test.tsx b/app/components/cinematic/HeroFieldScene.test.tsx index 016c650..1fcd5c1 100644 --- a/app/components/cinematic/HeroFieldScene.test.tsx +++ b/app/components/cinematic/HeroFieldScene.test.tsx @@ -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 = () => 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(); + it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => { + const { container } = await mount(); 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(); - expect(mockRenderers[0].pixelRatio).toBe(expected); - }); + ])( + "caps the pixel ratio at 1.5 (device %s -> %s)", + async (device, expected) => { + setDevicePixelRatio(device); + await mount(); + 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(); + await mount(); 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(); + await mount(); 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(); + const { rerender } = await mount( + , + ); 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( , ); 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(); + const { rerender } = await mount(); const second = props(); rerender( { 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(); + const { container } = await mount(); 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(); + await mount(); 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(); + await mount(); 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(); + // 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(); + + 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(); + + 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(); + 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( + , + ); + 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(); + + 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( + , + ); + rerender(); + + 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); + }); + }); }); diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx index 59f2138..14feab4 100644 --- a/app/components/cinematic/HeroFieldScene.tsx +++ b/app/components/cinematic/HeroFieldScene.tsx @@ -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; + +/** 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((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 { + 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]); diff --git a/app/components/cinematic/fragment-nav.test.tsx b/app/components/cinematic/fragment-nav.test.tsx index 42fb06b..42570b2 100644 --- a/app/components/cinematic/fragment-nav.test.tsx +++ b/app/components/cinematic/fragment-nav.test.tsx @@ -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(`x`))?.hash).toBe( + "#alvo", + ); expect( - samePageHashTarget(clickOn(`x`)), - ).toBeNull(); - expect( - samePageHashTarget(clickOn(`x`))?.hash, + samePageHashTarget(clickOn(`x`))?.hash, ).toBe("#alvo"); + expect( + samePageHashTarget(clickOn(`x`))?.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(`x`)), + ).toBeNull(); }); }); diff --git a/app/components/cinematic/fragment-nav.ts b/app/components/cinematic/fragment-nav.ts index 869d258..67cb532 100644 --- a/app/components/cinematic/fragment-nav.ts +++ b/app/components/cinematic/fragment-nav.ts @@ -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; } diff --git a/app/components/cinematic/hero-dive.css.test.ts b/app/components/cinematic/hero-dive.css.test.ts deleted file mode 100644 index 18515e7..0000000 --- a/app/components/cinematic/hero-dive.css.test.ts +++ /dev/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); - } - }); -}); diff --git a/app/components/cinematic/hero-dive.test.tsx b/app/components/cinematic/hero-dive.test.tsx new file mode 100644 index 0000000..a918413 --- /dev/null +++ b/app/components/cinematic/hero-dive.test.tsx @@ -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; +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( +
+ +
, + ); + 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((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); + }); +}); diff --git a/app/components/cinematic/useCinematicScroll.paths.test.tsx b/app/components/cinematic/useCinematicScroll.paths.test.tsx index 1e25310..6a1c2d4 100644 --- a/app/components/cinematic/useCinematicScroll.paths.test.tsx +++ b/app/components/cinematic/useCinematicScroll.paths.test.tsx @@ -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 () => { diff --git a/app/components/cinematic/useCinematicScroll.test.tsx b/app/components/cinematic/useCinematicScroll.test.tsx index 07d7748..303234b 100644 --- a/app/components/cinematic/useCinematicScroll.test.tsx +++ b/app/components/cinematic/useCinematicScroll.test.tsx @@ -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", () => { diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts index 5d8d86a..8195b3b 100644 --- a/app/components/cinematic/useCinematicScroll.ts +++ b/app/components/cinematic/useCinematicScroll.ts @@ -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) { 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 diff --git a/app/components/showcase/PinnedReel.tsx b/app/components/showcase/PinnedReel.tsx index f9b7e94..1ac952d 100644 --- a/app/components/showcase/PinnedReel.tsx +++ b/app/components/showcase/PinnedReel.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { type CSSProperties, type RefObject, @@ -47,6 +48,16 @@ const headerHeight = () => { return headerCache.height; }; +/** + * Larguras (CSS) em que os pôsteres aparecem, para o next/image pedir o arquivo do tamanho certo (um WebP/AVIF de + * ~1080 px no notebook e de ~256 px no celular) e não o original de 1440 px: o notebook mede ~58vw (até ~1100 px nas + * telas largas; 90vw abaixo de 1024 px, com a vitrine em coluna) e o celular da moldura, 22% dele. + */ +const LAPTOP_POSTER_SIZES = + "(min-width: 1900px) 1100px, (min-width: 1024px) 58vw, 90vw"; +const PHONE_POSTER_SIZES = + "(min-width: 1900px) 240px, (min-width: 1024px) 13vw, 20vw"; + /** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */ function seekVideo(video: HTMLVideoElement | null, fraction: number) { if (!video || video.readyState < 1 || !video.duration) return; @@ -519,16 +530,22 @@ function ReelLayer({ inert={!isActive} > -
+
+ {/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */} + {poster && ( + + )}
- + + {poster && ( + + )} +
); } @@ -615,7 +641,11 @@ function ReelCaption({ ))} - onExpand(config.id)} /> + onExpand(config.id)} + grouped + /> ); } diff --git a/app/components/showcase/ProjectActions.paths.test.tsx b/app/components/showcase/ProjectActions.paths.test.tsx new file mode 100644 index 0000000..c19565f --- /dev/null +++ b/app/components/showcase/ProjectActions.paths.test.tsx @@ -0,0 +1,168 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { getFeaturedProjects } from "@/lib/portfolio-data"; +import { canOpenSite, ProjectActions } from "./ProjectActions"; +import { buildShowcaseItems, type ShowcaseItem } from "./ShowcaseProvider"; + +/** + * Testes de caminhos-base de ProjectActions (CC 7) e canOpenSite (CC 2). + * Sem `grouped` (a lista): "Ver case", "Ampliar vídeo" (só com onExpand) e "Abrir site" (só com site no ar) são três + * paradas do Tab cada uma. Com `grouped` (a vitrine fixa): uma barra de ferramentas e UMA parada, o roving tabindex: + * a parada é "Ver case" até o foco passar a outra ação que ainda exista; as setas e Home/End movem o foco. + * Combinações: grouped x (com | sem onExpand) x (com | sem site). + */ + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + children, + prefetch: _prefetch, + ...props + }: { children?: React.ReactNode; prefetch?: unknown } & Record< + string, + unknown + >) => {children}, +})); + +const base = buildShowcaseItems(getFeaturedProjects(null)).find( + ({ project }) => project.demoUrl, +) as ShowcaseItem; + +function item(overrides: Partial): ShowcaseItem { + return { ...base, project: { ...base.project, ...overrides } }; +} + +const withSite = item({ + demoUrl: "https://exemplo.com.br", + demoAvailability: "available", +}); +const withoutSite = item({ demoUrl: null, demoAvailability: "unknown" }); +const siteDown = item({ + demoUrl: "https://exemplo.com.br", + demoAvailability: "unavailable", +}); + +const link = (name: RegExp) => screen.getByRole("link", { name }); +const tabindexes = () => + Array.from(document.querySelectorAll("[data-roving]")).map( + (element) => `${element.getAttribute("data-roving")}:${element.tabIndex}`, + ); +const press = (key: string) => + fireEvent.keyDown(document.activeElement as Element, { key }); + +describe("canOpenSite", () => { + it.each([ + ["site no ar", withSite, true], + ["sem endereço", withoutSite, false], + ["endereço dado como fora do ar", siteDown, false], + ])("path: %s -> %s", (_name, project, expected) => { + expect(canOpenSite(project.project)).toBe(expected); + }); +}); + +describe("ProjectActions sem grouped (a lista)", () => { + it("path: expand and a live site -> three actions, no toolbar, no roving attributes (each is its own Tab stop)", () => { + const { container } = render( + , + ); + + expect(link(/Ver case/)).toBeVisible(); + expect(screen.getByRole("button", { name: /Ampliar/ })).toBeVisible(); + expect(link(/Abrir site/)).toHaveAttribute("target", "_blank"); + expect(screen.queryByRole("toolbar")).toBeNull(); + expect(container.querySelector("[data-roving]")).toBeNull(); + expect(container.querySelector("[tabindex]")).toBeNull(); + }); + + it("path: no onExpand (before the page mounts) and no site -> only 'Ver case'", () => { + render(); + + expect(link(/Ver case/)).toBeVisible(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByRole("link", { name: /Abrir site/ })).toBeNull(); + }); + + it("path: a site declared down -> no 'Abrir site' even with an address", () => { + render(); + + expect(screen.queryByRole("link", { name: /Abrir site/ })).toBeNull(); + }); + + it("path: the expand button runs onExpand", () => { + const onExpand = jest.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Ampliar/ })); + + expect(onExpand).toHaveBeenCalledTimes(1); + }); +}); + +describe("ProjectActions com grouped (a vitrine fixa)", () => { + it("path: all three actions -> one toolbar named after the project, one Tab stop ('Ver case'), the others at -1", () => { + render(); + + expect( + screen.getByRole("toolbar", { + name: `Ações de ${withSite.project.title}`, + }), + ).toBeInTheDocument(); + expect(tabindexes()).toEqual(["case:0", "expand:-1", "site:-1"]); + }); + + it("path: the arrows walk the three actions in order and stop at the ends; Home and End jump; Tab is left alone", () => { + render(); + const expand = screen.getByRole("button", { name: /Ampliar/ }); + + act(() => link(/Ver case/).focus()); + press("ArrowRight"); + expect(expand).toHaveFocus(); + press("ArrowRight"); + expect(link(/Abrir site/)).toHaveFocus(); + press("ArrowRight"); + expect(link(/Abrir site/)).toHaveFocus(); + press("Home"); + expect(link(/Ver case/)).toHaveFocus(); + press("End"); + expect(link(/Abrir site/)).toHaveFocus(); + // o Tab sai do grupo em vez de andar dentro dele: não é cancelado + expect(press("Tab")).toBe(true); + }); + + it("path: the Tab stop follows the last focused action, so Shift+Tab and Tab come back to it", () => { + render(); + + act(() => link(/Abrir site/).focus()); + + expect(tabindexes()).toEqual(["case:-1", "expand:-1", "site:0"]); + }); + + it("path: no site -> the toolbar has two actions and the arrows never reach a missing 'Abrir site'", () => { + render(); + + expect(tabindexes()).toEqual(["case:0", "expand:-1"]); + act(() => screen.getByRole("button", { name: /Ampliar/ }).focus()); + press("ArrowRight"); + expect(screen.getByRole("button", { name: /Ampliar/ })).toHaveFocus(); + }); + + it("path: no onExpand and no site -> a toolbar of one action, which is the Tab stop", () => { + render(); + + expect(tabindexes()).toEqual(["case:0"]); + expect(screen.getByRole("toolbar")).toBeInTheDocument(); + }); + + it("path: the stop was on 'Abrir site' and the next render has no site -> back to 'Ver case', never a missing stop", () => { + const { rerender } = render( + , + ); + act(() => link(/Abrir site/).focus()); + expect(tabindexes()).toContain("site:0"); + + rerender( + , + ); + + expect(tabindexes()).toEqual(["case:0", "expand:-1"]); + }); +}); diff --git a/app/components/showcase/ProjectActions.tsx b/app/components/showcase/ProjectActions.tsx index e7af463..49322cf 100644 --- a/app/components/showcase/ProjectActions.tsx +++ b/app/components/showcase/ProjectActions.tsx @@ -1,5 +1,8 @@ +"use client"; + import Link from "next/link"; import type { ShowcaseItem } from "./ShowcaseProvider"; +import { useRovingTabindex } from "./useRovingTabindex"; /** O site publicado abre em nova aba quando existe e não foi dado como fora do ar. */ export function canOpenSite(project: ShowcaseItem["project"]) { @@ -10,19 +13,49 @@ export function canOpenSite(project: ShowcaseItem["project"]) { * Ações de um projeto na vitrine: "Ver case" (contorno) leva ao estudo de caso; "Ampliar vídeo" (contorno, * com ícone) abre a janela ampliada; "Abrir site" (link de texto, nova aba) só existe com `demoUrl`. * Nenhuma é verde-limão: o preenchido de destaque é só da ação de contato. + * + * Com `grouped`, as ações são UMA parada no Tab (barra de ferramentas com roving tabindex: as setas e Home/End movem o + * foco entre elas): na vitrine fixa só o projeto em cena existe, e sem isto eram três paradas a mais por projeto. */ export function ProjectActions({ item, onExpand, + grouped = false, }: { item: ShowcaseItem; /** Sem a janela ampliada (antes de o navegador montar a página) o botão nem aparece. */ onExpand?: () => void; + /** As ações viram uma parada só no Tab (a vitrine fixa); na lista, cada uma é a sua. */ + grouped?: boolean; }) { const { project } = item; + const site = canOpenSite(project); + // só as ações que existem podem ser a parada do grupo (um projeto sem site não tem "Abrir site") + const present = [ + "case", + ...(onExpand ? ["expand"] : []), + ...(site ? ["site"] : []), + ]; + const { groupProps, itemProps } = useRovingTabindex("case", (key) => + present.includes(key), + ); + const roving = (key: string) => (grouped ? itemProps(key) : {}); return ( -
- +
+ Ver case de {project.title} @@ -32,6 +65,7 @@ export function ProjectActions({ className="ds-link ds-link-muted ds-reel-expand" onClick={onExpand} aria-haspopup="dialog" + {...roving("expand")} > de {project.title} )} - {canOpenSite(project) && ( + {site && ( Abrir site diff --git a/app/components/showcase/ProjectLightbox.ready.paths.test.tsx b/app/components/showcase/ProjectLightbox.ready.paths.test.tsx new file mode 100644 index 0000000..6a971ca --- /dev/null +++ b/app/components/showcase/ProjectLightbox.ready.paths.test.tsx @@ -0,0 +1,160 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { getFeaturedProjects } from "@/lib/portfolio-data"; +import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase"; +import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider"; + +/** + * Caminhos-base de readMetadata (CC 3) e do efeito de `readyState` no ProjectLightbox (complemento de + * ProjectLightbox.test.tsx): o vídeo que já chega com os metadados (cache, preload) não dispara `loadedmetadata` + * depois de montado, então o efeito lê a duração e aplica o ponto pendente da troca de aparelho. + * O que o teste principal já cobre: pronto na abertura sem ponto pendente; troca de aparelho com o evento. + * Aqui: troca de aparelho com o vídeo novo JÁ pronto (o ponto pendente é aplicado sem evento algum), troca de + * projeto com o vídeo novo já pronto (sem ponto pendente, o vídeo começa do zero), e vídeo ainda sem metadados. + */ + +jest.mock("./ProjectLightboxLazy", () => ({ + ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox, +})); +jest.mock("@/app/hooks/useReducedMotion", () => ({ + useReducedMotion: () => false, +})); + +const DURATION = 30; +const lavacao = getShowcaseConfig("lavacao-047"); +const projects = getFeaturedProjects(null); +/** O ponto de cada