From c9a0b80656ea82e941e2319bdf3c1ba6fa2ac7e6 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 03:32:23 -0300 Subject: [PATCH] =?UTF-8?q?perf(movimento):=20uma=20transi=C3=A7=C3=A3o,?= =?UTF-8?q?=203D=20leve=20e=20v=C3=ADdeo=20certo=20por=20aparelho?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato. --- app/components/ProjectCover.tsx | 1 - app/components/ProjectVideo.paths.test.tsx | 193 +++++ app/components/ProjectVideo.test.tsx | 62 +- app/components/ProjectVideo.tsx | 31 +- .../cinematic/CinematicEffects.test.tsx | 65 +- .../cinematic/FilmGrain.paths.test.tsx | 139 ++++ app/components/cinematic/FilmGrain.tsx | 42 +- app/components/cinematic/FramePulse.test.tsx | 50 ++ app/components/cinematic/FramePulse.tsx | 36 + .../cinematic/HeroBackground.paths.test.tsx | 67 ++ app/components/cinematic/HeroBackground.tsx | 11 +- .../cinematic/HeroField.paths.test.tsx | 405 ++++++++++ app/components/cinematic/HeroField.test.tsx | 179 +++++ app/components/cinematic/HeroField.tsx | 42 +- .../cinematic/HeroFieldScene.paths.test.tsx | 580 ++++++++++++++ .../cinematic/HeroFieldScene.test.tsx | 51 ++ app/components/cinematic/HeroFieldScene.tsx | 61 +- .../cinematic/HeroScene.paths.test.tsx | 206 +++++ app/components/cinematic/HeroScene.tsx | 22 +- .../useCinematicScroll.paths.test.tsx | 720 ++++++++++++++++++ .../cinematic/useCinematicScroll.test.tsx | 517 +++++++++++++ .../cinematic/useCinematicScroll.ts | 444 +++++------ .../showcase/BrowserFrame.paths.test.tsx | 141 ++++ app/components/showcase/BrowserFrame.test.tsx | 39 +- app/components/showcase/BrowserFrame.tsx | 27 +- app/components/showcase/ProjectActions.tsx | 59 ++ .../showcase/ProjectLightbox.test.tsx | 68 +- app/components/showcase/ProjectLightbox.tsx | 39 +- .../showcase/ProjectList.paths.test.tsx | 173 +++++ app/components/showcase/ProjectList.tsx | 109 +++ app/components/showcase/ProjectMedia.test.tsx | 80 ++ app/components/showcase/ProjectMedia.tsx | 27 +- .../showcase/ProjectReel.paths.test.tsx | 675 ++++++++++++++++ app/components/showcase/ProjectReel.test.tsx | 265 +++++-- app/components/showcase/ProjectReel.tsx | 192 ++--- .../showcase/ShowcaseProvider.paths.test.tsx | 557 ++++++++++++++ .../showcase/ShowcaseProvider.test.tsx | 54 ++ app/components/showcase/ShowcaseProvider.tsx | 32 +- app/components/showcase/showcase.css | 371 ++++++--- .../showcase/useShowcaseMedia.paths.test.tsx | 297 ++++++++ app/components/showcase/useShowcaseMedia.ts | 82 ++ app/globals.css | 642 +++------------- lib/device.paths.test.ts | 185 +++++ lib/device.test.ts | 74 ++ lib/device.ts | 53 ++ lib/project-showcase.paths.test.ts | 199 +++++ lib/project-showcase.test.ts | 50 +- lib/project-showcase.ts | 37 +- 48 files changed, 7210 insertions(+), 1241 deletions(-) create mode 100644 app/components/ProjectVideo.paths.test.tsx create mode 100644 app/components/cinematic/FilmGrain.paths.test.tsx create mode 100644 app/components/cinematic/FramePulse.test.tsx create mode 100644 app/components/cinematic/FramePulse.tsx create mode 100644 app/components/cinematic/HeroBackground.paths.test.tsx create mode 100644 app/components/cinematic/HeroField.paths.test.tsx create mode 100644 app/components/cinematic/HeroField.test.tsx create mode 100644 app/components/cinematic/HeroFieldScene.paths.test.tsx create mode 100644 app/components/cinematic/HeroFieldScene.test.tsx create mode 100644 app/components/cinematic/HeroScene.paths.test.tsx create mode 100644 app/components/cinematic/useCinematicScroll.paths.test.tsx create mode 100644 app/components/cinematic/useCinematicScroll.test.tsx create mode 100644 app/components/showcase/BrowserFrame.paths.test.tsx create mode 100644 app/components/showcase/ProjectActions.tsx create mode 100644 app/components/showcase/ProjectList.paths.test.tsx create mode 100644 app/components/showcase/ProjectList.tsx create mode 100644 app/components/showcase/ProjectReel.paths.test.tsx create mode 100644 app/components/showcase/ShowcaseProvider.paths.test.tsx create mode 100644 app/components/showcase/useShowcaseMedia.paths.test.tsx create mode 100644 app/components/showcase/useShowcaseMedia.ts create mode 100644 lib/device.paths.test.ts create mode 100644 lib/device.test.ts create mode 100644 lib/device.ts create mode 100644 lib/project-showcase.paths.test.ts diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx index 852e539..7d871dd 100644 --- a/app/components/ProjectCover.tsx +++ b/app/components/ProjectCover.tsx @@ -6,7 +6,6 @@ type CoverProject = { id: string; title: string; demoUrl?: string | null; - githubUrl?: string | null; }; /** Capturas de tela publicadas dos projetos que têm interface pública. */ diff --git a/app/components/ProjectVideo.paths.test.tsx b/app/components/ProjectVideo.paths.test.tsx new file mode 100644 index 0000000..1650cb9 --- /dev/null +++ b/app/components/ProjectVideo.paths.test.tsx @@ -0,0 +1,193 @@ +import { render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { ProjectVideo } from "./ProjectVideo"; + +/** + * Testes de caminhos-base de ProjectVideo (complemento de ProjectVideo.test.tsx): modo só pôster + * (movimento reduzido ou "economizar dados") com e sem pôster pedido, inativo x ativo, vídeo sem fontes, + * falha do play() e a combinação de ativo com modo só pôster. + */ + +let reducedMotion = false; +jest.mock("@/app/hooks/useReducedMotion", () => ({ + useReducedMotion: () => reducedMotion, +})); + +const video = getShowcaseAssets("nutriscan", "desktop"); + +function setSaveData(value: boolean | undefined) { + Object.defineProperty(navigator, "connection", { + configurable: true, + value: value === undefined ? undefined : { saveData: value }, + }); +} + +beforeEach(() => { + reducedMotion = false; + setSaveData(undefined); + jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + setSaveData(undefined); +}); + +describe("ProjectVideo: modo só pôster", () => { + it.each([ + [ + "reduced motion", + () => { + reducedMotion = true; + }, + ], + ["the data saver", () => setSaveData(true)], + ])( + "path: %s and the poster is not wanted yet -> renders nothing at all", + (_l, arrange) => { + arrange(); + + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }, + ); + + it.each([ + [ + "reduced motion", + () => { + reducedMotion = true; + }, + ], + ["the data saver", () => setSaveData(true)], + ])( + "path: %s and the poster is wanted -> only the image, with the project name in its alt", + (_l, arrange) => { + arrange(); + + const { container } = render( + , + ); + + expect(container.querySelector("video")).toBeNull(); + const image = screen.getByRole("img", { + name: "Captura de tela de Demo", + }); + expect(image).toHaveAttribute("width", String(video.width)); + expect(image).toHaveAttribute("height", String(video.height)); + }, + ); + + it("path: data saver off and motion allowed -> the video element, never the image", () => { + setSaveData(false); + + const { container } = render( + , + ); + + expect(container.querySelector("video")).not.toBeNull(); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("path: active card but poster-only mode -> nothing is attached and nothing plays", () => { + reducedMotion = true; + + render(); + + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); + }); +}); + +describe("ProjectVideo: vídeo", () => { + it("path: far card (poster not wanted) -> the video has no poster attribute", () => { + const { container } = render( + , + ); + + expect(container.querySelector("video")).not.toHaveAttribute("poster"); + }); + + it("path: inactive and never activated -> no source, no load, no play, no pause", () => { + render(); + + expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled(); + }); + + it("path: becomes active -> muted is forced on the element before play()", () => { + const { container, rerender } = render( + , + ); + const element = container.querySelector("video") as HTMLVideoElement; + element.muted = false; + let mutedAtPlay: boolean | undefined; + jest.mocked(HTMLMediaElement.prototype.play).mockImplementation(function ( + this: HTMLMediaElement, + ) { + mutedAtPlay = this.muted; + return Promise.resolve(); + }); + + rerender(); + + expect(mutedAtPlay).toBe(true); + }); + + it("path: play() is rejected by the browser -> swallowed, the video stays mounted", async () => { + jest + .mocked(HTMLMediaElement.prototype.play) + .mockRejectedValue(new DOMException("blocked", "NotAllowedError")); + + const { container } = render( + , + ); + await Promise.resolve(); + + expect(container.querySelector("video")).not.toBeNull(); + }); + + it("path: no sources in the assets -> becomes active without calling load()", () => { + const empty = { ...video, sources: [] }; + + const { container } = render( + , + ); + + expect(container.querySelectorAll("source")).toHaveLength(0); + expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.play).toHaveBeenCalled(); + }); + + it("path: active -> inactive -> active again -> plays again without reloading the sources", () => { + const { container, rerender } = render( + , + ); + const loadsAfterFirst = jest.mocked(HTMLMediaElement.prototype.load).mock + .calls.length; + const playsAfterFirst = jest.mocked(HTMLMediaElement.prototype.play).mock + .calls.length; + + rerender(); + rerender(); + + expect(jest.mocked(HTMLMediaElement.prototype.load).mock.calls.length).toBe( + loadsAfterFirst, + ); + expect(jest.mocked(HTMLMediaElement.prototype.play).mock.calls.length).toBe( + playsAfterFirst + 1, + ); + expect(container.querySelectorAll("source")).toHaveLength(2); + }); +}); diff --git a/app/components/ProjectVideo.test.tsx b/app/components/ProjectVideo.test.tsx index da1f834..5d90b31 100644 --- a/app/components/ProjectVideo.test.tsx +++ b/app/components/ProjectVideo.test.tsx @@ -18,6 +18,10 @@ beforeEach(() => { afterEach(() => { jest.restoreAllMocks(); + Object.defineProperty(navigator, "connection", { + configurable: true, + value: undefined, + }); }); describe("ProjectVideo", () => { @@ -31,7 +35,7 @@ describe("ProjectVideo", () => { expect(element).toHaveAttribute("autoplay"); expect(element).toHaveAttribute("loop"); expect(element).toHaveAttribute("playsinline"); - expect(element).toHaveAttribute("preload", "none"); + expect(element).toHaveAttribute("preload", "metadata"); expect(element).toHaveAttribute("poster", video.poster); expect(element?.muted).toBe(true); expect(element).toHaveAttribute("data-ready", "false"); @@ -70,6 +74,62 @@ describe("ProjectVideo", () => { expect(element).toHaveAttribute("data-ready", "true"); }); + it("does not even request the poster while the card is far from the screen", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("video")).not.toHaveAttribute("poster"); + + rerender( + , + ); + expect(container.querySelector("video")).toHaveAttribute( + "poster", + video.poster, + ); + }); + + it("swaps to the sources of the new device when the version changes", () => { + const mobile = getShowcaseAssets("nutriscan", "mobile"); + const { container, rerender } = render( + , + ); + const loads = jest.mocked(HTMLMediaElement.prototype.load).mock.calls + .length; + + rerender(); + expect( + [...container.querySelectorAll("source")].map((source) => + source.getAttribute("src"), + ), + ).toEqual(mobile.sources.map((source) => source.src)); + expect( + jest.mocked(HTMLMediaElement.prototype.load).mock.calls.length, + ).toBeGreaterThan(loads); + }); + + it("shows only the poster, with no video element, when the data saver is on", () => { + Object.defineProperty(navigator, "connection", { + configurable: true, + value: { saveData: true }, + }); + const { container } = render( + , + ); + + expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector("source")).toBeNull(); + expect( + screen.getByRole("img", { name: "Captura de tela de Demo" }), + ).toBeInTheDocument(); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + }); + it("shows only the poster, with no video element, when the user prefers reduced motion", () => { reducedMotion = true; const { container } = render( diff --git a/app/components/ProjectVideo.tsx b/app/components/ProjectVideo.tsx index 2e11f24..e6ab5ef 100644 --- a/app/components/ProjectVideo.tsx +++ b/app/components/ProjectVideo.tsx @@ -4,36 +4,44 @@ import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import type { ShowcaseAssets } from "@/lib/project-showcase"; +import { useSaveData } from "./showcase/useShowcaseMedia"; /** - * Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que - * está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o - * card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece. - * O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar. + * Vídeo de rolagem na capa do card. `video` já é a versão do aparelho (quem chama escolhe: celular abaixo de + * 768 px; no servidor, nenhuma). Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que está + * sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o card + * fica ativo (`preload="metadata"`, sem fontes até lá); fora de cena ele pausa. Com prefers-reduced-motion ou + * "economizar dados" só o pôster aparece. `showPoster` falso (seção longe da tela) não pede nem o pôster. + * O pôster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar. */ export function ProjectVideo({ video, title, active, + showPoster = true, }: { video: ShowcaseAssets; title: string; active: boolean; + showPoster?: boolean; }) { const reducedMotion = useReducedMotion(); + const saveData = useSaveData(); const ref = useRef(null); const [attached, setAttached] = useState(false); const [ready, setReady] = useState(false); - const playing = active && !reducedMotion; + const posterOnly = reducedMotion || saveData; + const playing = active && !posterOnly; + const firstSource = video.sources[0]?.src; useEffect(() => { if (playing) setAttached(true); }, [playing]); useEffect(() => { - // as fontes entram depois do primeiro render: o elemento precisa ler a lista nova - if (attached) ref.current?.load(); - }, [attached]); + // as fontes entram depois do primeiro render (ou mudam com o aparelho): o elemento precisa ler a lista nova + if (attached && firstSource) ref.current?.load(); + }, [attached, firstSource]); useEffect(() => { const element = ref.current; @@ -46,7 +54,8 @@ export function ProjectVideo({ } }, [attached, playing]); - if (reducedMotion) { + if (posterOnly) { + if (!showPoster) return null; return ( { @@ -11,3 +11,66 @@ describe("static cinematic effects", () => { expect(grain.container.querySelector("canvas")).not.toBeInTheDocument(); }); }); + +describe("film grain over the project screens", () => { + let screenTop = 5000; + + function addScreen(className: string) { + const element = document.createElement("div"); + element.className = className; + element.getBoundingClientRect = () => + ({ top: screenTop, bottom: screenTop + 600, height: 600 }) as DOMRect; + document.body.append(element); + return element; + } + + function scroll(to: number) { + screenTop = to; + act(() => { + window.dispatchEvent(new Event("scroll")); + jest.advanceTimersByTime(40); + }); + } + + beforeEach(() => { + jest.useFakeTimers(); + screenTop = 5000; + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 800, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("steps aside while a project screen is in view and comes back when it leaves", () => { + addScreen("ds-reel"); + const { container } = render(); + const grain = container.firstElementChild as HTMLElement; + expect(grain).not.toHaveAttribute("data-over-projects"); + + scroll(300); // a vitrine entrou na janela + expect(grain).toHaveAttribute("data-over-projects"); + + scroll(-900); // passou: a cena acabou 300 px acima do topo + expect(grain).not.toHaveAttribute("data-over-projects"); + }); + + it("also covers the browser and phone frames", () => { + addScreen("ds-bframe"); + const { container } = render(); + scroll(100); + expect(container.firstElementChild).toHaveAttribute("data-over-projects"); + }); + + it("stays on when there is no project screen on the page", () => { + const { container } = render(); + scroll(100); + expect(container.firstElementChild).not.toHaveAttribute( + "data-over-projects", + ); + }); +}); diff --git a/app/components/cinematic/FilmGrain.paths.test.tsx b/app/components/cinematic/FilmGrain.paths.test.tsx new file mode 100644 index 0000000..2d5e1eb --- /dev/null +++ b/app/components/cinematic/FilmGrain.paths.test.tsx @@ -0,0 +1,139 @@ +import { act, render } from "@testing-library/react"; +import FilmGrain from "./FilmGrain"; + +/** + * Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo + * de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros + * agrupados e a limpeza ao desmontar. + */ + +const INNER_HEIGHT = 800; + +function addScreen( + className: string, + rect: { top: number; bottom: number; height: number }, +) { + const element = document.createElement("div"); + element.className = className; + element.getBoundingClientRect = () => rect as DOMRect; + document.body.append(element); + return element; +} + +beforeEach(() => { + jest.useFakeTimers(); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: INNER_HEIGHT, + }); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function renderGrain() { + const view = render(); + return { ...view, grain: view.container.firstElementChild as HTMLElement }; +} + +describe("FilmGrain: is a project screen in view?", () => { + it("path: a visible screen -> the grain steps aside", () => { + addScreen("ds-phone", { top: 100, bottom: 700, height: 600 }); + + const { grain } = renderGrain(); + + expect(grain).toHaveAttribute("data-over-projects"); + }); + + it.each([ + ["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }], + ["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }], + ["ended above the window", { top: -900, bottom: -300, height: 600 }], + [ + "starts exactly at the bottom edge", + { top: INNER_HEIGHT, bottom: 1400, height: 600 }, + ], + ["starts below the window", { top: 2000, bottom: 2600, height: 600 }], + ])("path: a screen that %s -> does not count", (_label, rect) => { + addScreen("ds-bframe", rect); + + const { grain } = renderGrain(); + + expect(grain).not.toHaveAttribute("data-over-projects"); + }); + + it("path: just inside the window by one pixel at either edge -> counts", () => { + addScreen("ds-frame", { top: -599, bottom: 1, height: 600 }); + const top = renderGrain(); + expect(top.grain).toHaveAttribute("data-over-projects"); + top.unmount(); + document.body.innerHTML = ""; + + addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 }); + const bottom = renderGrain(); + expect(bottom.grain).toHaveAttribute("data-over-projects"); + }); + + it("path: one screen off the window and another in view -> counts (any is enough)", () => { + addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 }); + addScreen("ds-phone", { top: 100, bottom: 700, height: 600 }); + + const { grain } = renderGrain(); + + expect(grain).toHaveAttribute("data-over-projects"); + }); + + it("path: no project screen on the page -> the grain stays", () => { + const { grain } = renderGrain(); + + expect(grain).not.toHaveAttribute("data-over-projects"); + }); +}); + +describe("FilmGrain: frames and cleanup", () => { + it("path: many scroll events inside one frame -> the page is measured once", () => { + const screenEl = addScreen("ds-reel", { + top: 5000, + bottom: 5600, + height: 600, + }); + const measure = jest.spyOn(screenEl, "getBoundingClientRect"); + renderGrain(); + measure.mockClear(); + + act(() => { + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("resize")); + jest.advanceTimersByTime(40); + }); + + expect(measure).toHaveBeenCalledTimes(1); + }); + + it("path: unmount -> listeners removed and no measurement afterwards", () => { + const screenEl = addScreen("ds-reel", { + top: 5000, + bottom: 5600, + height: 600, + }); + const measure = jest.spyOn(screenEl, "getBoundingClientRect"); + const { unmount } = renderGrain(); + act(() => { + window.dispatchEvent(new Event("scroll")); + }); + + unmount(); + measure.mockClear(); + act(() => { + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("resize")); + jest.advanceTimersByTime(100); + }); + + expect(measure).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/cinematic/FilmGrain.tsx b/app/components/cinematic/FilmGrain.tsx index b2e01b1..008c0f0 100644 --- a/app/components/cinematic/FilmGrain.tsx +++ b/app/components/cinematic/FilmGrain.tsx @@ -1,3 +1,43 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */ +const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone"; + +/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */ export default function FilmGrain() { - return