diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx deleted file mode 100644 index 7d871dd..0000000 --- a/app/components/ProjectCover.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; -import { getProjectVideo } from "@/lib/project-showcase"; -import { ProjectMedia } from "./showcase/ProjectMedia"; - -type CoverProject = { - id: string; - title: string; - demoUrl?: string | null; -}; - -/** Capturas de tela publicadas dos projetos que têm interface pública. */ -const SCREENSHOTS: Record = { - nutriscan: "/assets/notebook/nutriscan.webp", - "sc-plus": "/assets/notebook/sc-plus.webp", - "sev-modaintima": "/assets/notebook/sev-modaintima.webp", - "as-servicos": "/assets/notebook/as-servicos.webp", -}; - -function initials(title: string) { - return title - .split(/\s+/) - .filter((word) => word.length > 2 || /^[A-Z]/.test(word)) - .slice(0, 2) - .map((word) => word[0]) - .join("") - .toUpperCase(); -} - -/** - * Capa do card de projeto: a captura de tela real quando existe; sem captura, um painel gerado em código - * (só o monograma, sem repetir a stack que já aparece nas tags do card) em vez de uma imagem inventada. - */ -export function ProjectCover({ - project, - priority = false, -}: { - project: CoverProject; - priority?: boolean; -}) { - const video = getProjectVideo(project.id); - const src = SCREENSHOTS[project.id]; - - if (video) return ; - - if (src) { - return ( -
- {`Captura -
- ); - } - - return ( - - ); -} diff --git a/app/components/ProjectVideo.paths.test.tsx b/app/components/ProjectVideo.paths.test.tsx deleted file mode 100644 index 1650cb9..0000000 --- a/app/components/ProjectVideo.paths.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -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 deleted file mode 100644 index 5d90b31..0000000 --- a/app/components/ProjectVideo.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { act, render, screen } from "@testing-library/react"; -import { getShowcaseAssets } from "@/lib/project-showcase"; -import { ProjectVideo } from "./ProjectVideo"; - -let reducedMotion = false; -jest.mock("@/app/hooks/useReducedMotion", () => ({ - useReducedMotion: () => reducedMotion, -})); - -const video = getShowcaseAssets("nutriscan", "desktop"); - -beforeEach(() => { - reducedMotion = false; - jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); - jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); - jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); -}); - -afterEach(() => { - jest.restoreAllMocks(); - Object.defineProperty(navigator, "connection", { - configurable: true, - value: undefined, - }); -}); - -describe("ProjectVideo", () => { - it("renders a muted looping inline video that downloads nothing until it is active", () => { - const { container } = render( - , - ); - const element = container.querySelector("video"); - - expect(element).not.toBeNull(); - expect(element).toHaveAttribute("autoplay"); - expect(element).toHaveAttribute("loop"); - expect(element).toHaveAttribute("playsinline"); - expect(element).toHaveAttribute("preload", "metadata"); - expect(element).toHaveAttribute("poster", video.poster); - expect(element?.muted).toBe(true); - expect(element).toHaveAttribute("data-ready", "false"); - expect(container.querySelectorAll("source")).toHaveLength(0); - expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); - }); - - it("attaches the sources and plays when it becomes the active card, then pauses when it stops being", () => { - const { container, rerender } = render( - , - ); - - rerender(); - const sources = [...container.querySelectorAll("source")]; - expect(sources.map((source) => source.getAttribute("src"))).toEqual( - video.sources.map((source) => source.src), - ); - expect(HTMLMediaElement.prototype.load).toHaveBeenCalled(); - expect(HTMLMediaElement.prototype.play).toHaveBeenCalled(); - - rerender(); - expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled(); - // sai de cena, mas o arquivo já baixado fica: voltar a tocar não baixa de novo - expect(container.querySelectorAll("source")).toHaveLength(2); - }); - - it("fades the video in once it is playing", () => { - const { container } = render( - , - ); - const element = container.querySelector("video") as HTMLVideoElement; - - act(() => { - element.dispatchEvent(new Event("playing")); - }); - 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( - , - ); - - expect(container.querySelector("video")).toBeNull(); - expect( - screen.getByRole("img", { name: "Captura de tela de Demo" }), - ).toBeInTheDocument(); - expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/ProjectVideo.tsx b/app/components/ProjectVideo.tsx deleted file mode 100644 index e6ab5ef..0000000 --- a/app/components/ProjectVideo.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -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. `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 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 (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; - if (!element || !attached) return; - if (playing) { - element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real - element.play().catch(() => {}); - } else { - element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar - } - }, [attached, playing]); - - if (posterOnly) { - if (!showPoster) return null; - return ( - {`Captura - ); - } - - return ( - - ); -} diff --git a/app/components/Reveal.test.tsx b/app/components/Reveal.test.tsx new file mode 100644 index 0000000..e3852f4 --- /dev/null +++ b/app/components/Reveal.test.tsx @@ -0,0 +1,245 @@ +import { act, render, screen } from "@testing-library/react"; +import { DrawLine } from "./animations/DrawLine"; +import { LazyRevealImage } from "./animations/LazyRevealImage"; +import { Reveal } from "./Reveal"; +import { Section } from "./Section"; + +/** + * Entradas por rolagem sem biblioteca de animação: o componente só marca data-shown (ou data-loaded) quando o + * IntersectionObserver avisa e o CSS (globals.css) faz a transição. Aqui vale o contrato com o CSS: as classes, + * as variáveis e o momento em que as marcas aparecem. + */ + +type Observer = { + callback: IntersectionObserverCallback; + observed: Element[]; +}; +let observers: Observer[] = []; +const original = global.IntersectionObserver; + +beforeEach(() => { + observers = []; + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 800, + }); + global.IntersectionObserver = class { + private readonly record: Observer; + constructor(callback: IntersectionObserverCallback) { + this.record = { callback, observed: [] }; + observers.push(this.record); + } + observe(target: Element) { + this.record.observed.push(target); + } + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } as unknown as typeof IntersectionObserver; +}); + +afterEach(() => { + global.IntersectionObserver = original; +}); + +/** Avisa que `target` entrou inteiro na janela. */ +function reveal(target: Element) { + act(() => { + for (const observer of observers) + if (observer.observed.includes(target)) + observer.callback( + [ + { + isIntersecting: true, + intersectionRatio: 1, + intersectionRect: { height: 400 }, + target, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); +} + +describe("Reveal", () => { + it("renders its children and waits for the viewport to mark them as shown", () => { + const { container } = render( + +

Conteúdo

+
, + ); + const wrapper = container.firstElementChild as HTMLElement; + + expect(screen.getByText("Conteúdo")).toBeInTheDocument(); + expect(wrapper).toHaveClass("ds-reveal"); + expect(wrapper).not.toHaveAttribute("data-shown"); + + reveal(wrapper); + expect(wrapper).toHaveAttribute("data-shown"); + }); + + it("hands the CSS its offset, duration and delay as custom properties", () => { + const { container } = render( + + x + , + ); + const wrapper = container.firstElementChild as HTMLElement; + + expect(wrapper.style.getPropertyValue("--reveal-y")).toBe("40px"); + expect(wrapper.style.getPropertyValue("--reveal-duration")).toBe("0.9s"); + expect(wrapper.style.getPropertyValue("--reveal-delay")).toBe("0.12s"); + expect(wrapper).toHaveClass("ds-reveal", "extra"); + }); + + it("uses no inline opacity or transform: the hidden state lives in the CSS, so no script means visible content", () => { + const { container } = render(x); + const wrapper = container.firstElementChild as HTMLElement; + + expect(wrapper.style.opacity).toBe(""); + expect(wrapper.style.transform).toBe(""); + expect(wrapper.style.clipPath).toBe(""); + }); +}); + +describe("DrawLine", () => { + it("draws once the line is mostly on screen, and takes its delay from the prop", () => { + const { container } = render(); + const line = container.firstElementChild as HTMLElement; + + expect(line).toHaveClass("ds-draw-line", "x"); + expect(line.style.getPropertyValue("--reveal-delay")).toBe("0.16s"); + expect(line).not.toHaveAttribute("data-shown"); + + reveal(line); + expect(line).toHaveAttribute("data-shown"); + }); +}); + +describe("LazyRevealImage", () => { + it("shows the picture (data-loaded) only after it has loaded", async () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + const image = screen.getByAltText("Foto"); + + expect(wrapper).toHaveClass("ds-lazy-reveal", "h-40"); + expect(wrapper).not.toHaveAttribute("data-loaded"); + + // o next/image só repassa o onLoad depois de decodificar a imagem (uma microtarefa) + await act(async () => { + image.dispatchEvent(new Event("load")); + }); + expect(wrapper).toHaveAttribute("data-loaded"); + }); + + it("does not wait forever for an image that was already complete before the page hydrated", async () => { + const complete = jest + .spyOn(HTMLImageElement.prototype, "complete", "get") + .mockReturnValue(true); + const width = jest + .spyOn(HTMLImageElement.prototype, "naturalWidth", "get") + .mockReturnValue(640); + + let view: ReturnType | undefined; + await act(async () => { + view = render(); + }); + + expect(view?.container.firstElementChild).toHaveAttribute("data-loaded"); + complete.mockRestore(); + width.mockRestore(); + }); +}); + +describe("Section", () => { + it("renders the heading, the description and the content, and marks only the header as it enters", () => { + const { container } = render( +
lateral} + > +

Corpo

+
, + ); + const header = container.querySelector(".ds-section-header") as HTMLElement; + + expect(container.querySelector("section#sobre")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 2, name: "Quem faz" }), + ).toBeInTheDocument(); + expect(screen.getByText("Texto de apoio")).toHaveClass( + "ds-section-description", + "ds-reveal", + ); + expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal"); + expect(screen.getByText("lateral").closest("aside")).toHaveClass( + "ds-reveal", + ); + expect(screen.getByText("Corpo")).toBeInTheDocument(); + expect(header).not.toHaveAttribute("data-shown"); + + reveal(header); + expect(header).toHaveAttribute("data-shown"); + }); + + it("staggers the header items with growing delays", () => { + render( +
lateral} + > + x +
, + ); + const delay = (element: HTMLElement) => + Number.parseFloat(element.style.getPropertyValue("--reveal-delay")); + + const eyebrow = delay(screen.getByText("Sobre")); + const description = delay(screen.getByText("Texto")); + const aside = delay( + screen.getByText("lateral").closest("aside") as HTMLElement, + ); + + expect(eyebrow).toBeLessThan(description); + expect(description).toBeLessThan(aside); + }); + + it("wraps the body in Reveal, unless the section animates its own children", () => { + const { container, rerender } = render( +
+

Corpo

+
, + ); + expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeInTheDocument(); + + rerender( +
+

Corpo

+
, + ); + expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeNull(); + expect(container.querySelector(".ds-section-shell")).toBeInTheDocument(); + }); + + it("uses the dark skin when asked", () => { + const { container } = render( +
+ x +
, + ); + + expect(container.querySelector("section")).toHaveClass( + "ds-section", + "ds-section-dark", + ); + }); +}); diff --git a/app/components/Reveal.tsx b/app/components/Reveal.tsx index 8b10922..9111eda 100644 --- a/app/components/Reveal.tsx +++ b/app/components/Reveal.tsx @@ -1,22 +1,21 @@ "use client"; -import { motion } from "motion/react"; -import type { ReactNode } from "react"; -import { useState } from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { type CSSProperties, type ReactNode, useRef } from "react"; +import { useInView } from "@/app/hooks/useInView"; interface RevealProps { children: ReactNode; y?: number; duration?: number; delay?: number; - ease?: string; - start?: string; - end?: string; - scrub?: boolean | number; className?: string; } +/** + * Conteúdo que sobe e aparece uma vez, quando chega à janela. A animação é CSS (.ds-reveal em globals.css): + * só opacidade e deslocamento, que a placa de vídeo compõe sem redesenhar. Sem JavaScript ou com movimento + * reduzido, o conteúdo já nasce visível. + */ export function Reveal({ children, y = 36, @@ -24,46 +23,23 @@ export function Reveal({ delay = 0, className = "", }: RevealProps) { - const shouldReduceMotion = useReducedMotion(); - const [animating, setAnimating] = useState(!shouldReduceMotion); + const ref = useRef(null); + const shown = useInView(ref); return ( - { - if (!shouldReduceMotion) { - setAnimating(true); - } - }} - onAnimationComplete={() => setAnimating(false)} > {children} - + ); } diff --git a/app/components/Section.paths.test.tsx b/app/components/Section.paths.test.tsx new file mode 100644 index 0000000..8807a03 --- /dev/null +++ b/app/components/Section.paths.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import { Section } from "./Section"; + +/** + * Testes de caminhos-base de Section (complemento de Reveal.test.tsx): cada parte opcional do cabeçalho + * (eyebrow, descrição, aside) aparece só quando é dada, a casca clara é a padrão e o id é opcional. + */ + +describe("Section", () => { + it("path: only the title and the body -> no eyebrow, no description, no aside", () => { + const { container } = render( +
+

Corpo

+
, + ); + + expect( + screen.getByRole("heading", { level: 2, name: "Quem faz" }), + ).toBeInTheDocument(); + expect(screen.getByText("Corpo")).toBeInTheDocument(); + expect(container.querySelector(".ds-eyebrow")).toBeNull(); + expect(container.querySelector(".ds-section-description")).toBeNull(); + expect(container.querySelector("aside")).toBeNull(); + // a linha que se desenha embaixo do título é sempre desenhada + expect(container.querySelector(".ds-draw-line")).toBeInTheDocument(); + }); + + it("path: light skin by default -> no dark class, and no id attribute without one", () => { + const { container } = render(
x
); + + const section = container.querySelector("section"); + expect(section).toHaveClass("ds-section"); + expect(section).not.toHaveClass("ds-section-dark"); + expect(section).not.toHaveAttribute("id"); + }); + + it("path: only the eyebrow -> the eyebrow shows, the description and the aside stay out", () => { + const { container } = render( +
+ x +
, + ); + + expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow"); + expect(container.querySelector(".ds-section-description")).toBeNull(); + expect(container.querySelector("aside")).toBeNull(); + }); + + it("path: only the description -> the description shows, the eyebrow and the aside stay out", () => { + const { container } = render( +
+ x +
, + ); + + expect(screen.getByText("Texto de apoio")).toHaveClass( + "ds-section-description", + ); + expect(container.querySelector(".ds-eyebrow")).toBeNull(); + expect(container.querySelector("aside")).toBeNull(); + }); + + it("path: only the aside -> the aside shows next to the title, the eyebrow and the description stay out", () => { + const { container } = render( +
lateral}> + x +
, + ); + + expect(screen.getByText("lateral").closest("aside")).toBeInTheDocument(); + expect(container.querySelector(".ds-eyebrow")).toBeNull(); + expect(container.querySelector(".ds-section-description")).toBeNull(); + }); + + it("path: children animation disabled -> the body is a plain block, not inside a reveal", () => { + const { container } = render( +
+

Corpo

+
, + ); + + const body = screen.getByText("Corpo").parentElement; + expect(body?.parentElement).toHaveClass("ds-section-shell"); + expect(container.querySelectorAll(".ds-reveal")).toHaveLength(0); + }); +}); diff --git a/app/components/Section.tsx b/app/components/Section.tsx index a7a1f10..7962d51 100644 --- a/app/components/Section.tsx +++ b/app/components/Section.tsx @@ -1,8 +1,7 @@ "use client"; -import { motion, type Variants } from "motion/react"; -import type React from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { type CSSProperties, type ReactNode, useRef } from "react"; +import { useInView } from "@/app/hooks/useInView"; import { DrawLine } from "./animations/DrawLine"; import { TextDiffuseReveal } from "./animations/TextDiffuseReveal"; import { Reveal } from "./Reveal"; @@ -13,11 +12,15 @@ interface SectionProps { title: string; description?: string; dark?: boolean; - aside?: React.ReactNode; - children: React.ReactNode; + aside?: ReactNode; + children: ReactNode; disableChildrenAnimation?: boolean; } +/** Atraso (s) de cada item do cabeçalho: entram em escada, como a linha que se desenha logo depois. */ +const stagger = (seconds: number) => + ({ "--reveal-delay": `${seconds}s` }) as CSSProperties; + export const Section = ({ id, eyebrow, @@ -28,50 +31,23 @@ export const Section = ({ children, disableChildrenAnimation = false, }: SectionProps) => { - const shouldReduceMotion = useReducedMotion(); + const headerRef = useRef(null); + const headerShown = useInView(headerRef); const baseClass = dark ? "ds-section ds-section-dark" : "ds-section"; - const containerVariants: Variants = { - hidden: {}, - visible: { - transition: { - delayChildren: 0.02, - staggerChildren: 0.08, - }, - }, - }; - - const itemVariants: Variants = { - hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 18 }, - visible: { - opacity: 1, - y: 0, - transition: shouldReduceMotion - ? { duration: 0.25, ease: [0.16, 1, 0.3, 1] } - : { - type: "spring", - stiffness: 60, - damping: 20, - mass: 0.85, - }, - }, - }; - return (
-
{eyebrow && ( - +

{eyebrow} - +

)}

@@ -79,21 +55,23 @@ export const Section = ({

{description && ( - {description} - +

)}
{aside && ( - {aside} + )} -
+
{!disableChildrenAnimation ? ( diff --git a/app/components/SectionLoading.tsx b/app/components/SectionLoading.tsx deleted file mode 100644 index 2f75465..0000000 --- a/app/components/SectionLoading.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { useId } from "react"; -import { TopProgressBar } from "./animations/TopProgressBar"; - -interface SectionLoadingProps { - message: string; - dark?: boolean; - skeletons?: number; -} - -export function SectionLoading({ - message, - dark = false, - skeletons = 2, -}: Readonly) { - const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section"; - const skeletonId = useId(); - const skeletonKeys = Array.from( - { length: skeletons }, - (_, index) => `${skeletonId}-${index}`, - ); - - return ( -
- -
-
{message}
-
- ); -} diff --git a/app/components/animations/CounterValue.tsx b/app/components/animations/CounterValue.tsx deleted file mode 100644 index b5cce28..0000000 --- a/app/components/animations/CounterValue.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { useInView } from "motion/react"; -import { useEffect, useRef, useState } from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; - -interface CounterValueProps { - value: number; - className?: string; - duration?: number; - prefix?: string; - suffix?: string; - once?: boolean; - amount?: number; -} - -export function CounterValue({ - value, - className = "", - duration = 1200, - prefix = "", - suffix = "", - once = true, - amount = 0.45, -}: Readonly) { - const shouldReduceMotion = useReducedMotion(); - const ref = useRef(null); - const isInView = useInView(ref, { once, amount }); - const [displayValue, setDisplayValue] = useState(0); - - useEffect(() => { - if (!isInView) return; - - if (shouldReduceMotion) { - setDisplayValue(value); - return; - } - - const start = performance.now(); - let rafId = 0; - - const frame = (now: number) => { - const elapsed = now - start; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - (1 - progress) ** 3; - setDisplayValue(Math.round(value * eased)); - - if (progress < 1) { - rafId = requestAnimationFrame(frame); - } - }; - - rafId = requestAnimationFrame(frame); - return () => cancelAnimationFrame(rafId); - }, [isInView, value, duration, shouldReduceMotion]); - - return ( - - {prefix} - {displayValue} - {suffix} - - ); -} diff --git a/app/components/animations/DrawLine.tsx b/app/components/animations/DrawLine.tsx index cde55e3..08deab6 100644 --- a/app/components/animations/DrawLine.tsx +++ b/app/components/animations/DrawLine.tsx @@ -1,40 +1,27 @@ "use client"; -import { motion } from "motion/react"; -import { useState } from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { type CSSProperties, useRef } from "react"; +import { useInView } from "@/app/hooks/useInView"; interface DrawLineProps { className?: string; delay?: number; } +/** Linha fina que se desenha da esquerda para a direita quando chega à janela (CSS em globals.css, .ds-draw-line). */ export function DrawLine({ className = "", delay = 0, }: Readonly) { - const shouldReduceMotion = useReducedMotion(); - const [animating, setAnimating] = useState(!shouldReduceMotion); + const ref = useRef(null); + const shown = useInView(ref, { amount: 0.7 }); return ( - setAnimating(false)} + data-shown={shown ? "" : undefined} + style={{ "--reveal-delay": `${delay}s` } as CSSProperties} /> ); } diff --git a/app/components/animations/LazyRevealImage.paths.test.tsx b/app/components/animations/LazyRevealImage.paths.test.tsx new file mode 100644 index 0000000..15aee47 --- /dev/null +++ b/app/components/animations/LazyRevealImage.paths.test.tsx @@ -0,0 +1,107 @@ +import { act, render, screen } from "@testing-library/react"; +import { LazyRevealImage } from "./LazyRevealImage"; + +/** + * Testes de caminhos-base de LazyRevealImage (complemento de Reveal.test.tsx): a imagem que ainda carrega, o desenho de baixa resolução só para + * imagem importada e os valores padrão de `sizes` e da classe da imagem. + */ + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function mockComplete(complete: boolean, naturalWidth: number) { + jest + .spyOn(HTMLImageElement.prototype, "complete", "get") + .mockReturnValue(complete); + jest + .spyOn(HTMLImageElement.prototype, "naturalWidth", "get") + .mockReturnValue(naturalWidth); +} + +async function renderImage(ui: React.ReactElement) { + let view: ReturnType | undefined; + await act(async () => { + view = render(ui); + }); + if (!view) throw new Error("render failed"); + return view; +} + +describe("LazyRevealImage", () => { + it("path: not complete yet -> waits for the load event, then marks it", async () => { + mockComplete(false, 0); + + const { container } = await renderImage( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper).not.toHaveAttribute("data-loaded"); + + await act(async () => { + screen.getByAltText("Foto").dispatchEvent(new Event("load")); + }); + + expect(wrapper).toHaveAttribute("data-loaded"); + }); + + it("path: defaults -> lazy loading, the half-width size hint and the cover class on the image", async () => { + const { container } = await renderImage( + , + ); + + const image = screen.getByAltText("Foto"); + expect(image).toHaveAttribute("loading", "lazy"); + expect(image).toHaveAttribute("sizes", "(max-width: 1024px) 100vw, 50vw"); + expect(image).toHaveClass("object-cover"); + expect(container.firstElementChild?.className).toBe( + "ds-lazy-reveal relative", + ); + }); + + it("path: custom sizes and image class -> used instead of the defaults", async () => { + await renderImage( + , + ); + + const image = screen.getByAltText("Foto"); + expect(image).toHaveAttribute("sizes", "320px"); + expect(image).toHaveClass("object-contain"); + expect(image).not.toHaveClass("object-cover"); + }); + + it("path: an imported image (with a blur) -> the low-resolution preview style is on the image until it loads", async () => { + mockComplete(false, 0); + await renderImage( + , + ); + + // o jsdom descarta o `background-image` do SVG que o next/image monta, mas o restante do estilo do desfoque fica + expect(screen.getByAltText("Importada").getAttribute("style")).toContain( + "background-size: cover", + ); + }); + + it("path: a path string -> no preview style on the image", async () => { + mockComplete(false, 0); + await renderImage(); + + expect( + screen.getByAltText("Foto").getAttribute("style") ?? "", + ).not.toContain("background-size"); + }); +}); diff --git a/app/components/animations/LazyRevealImage.tsx b/app/components/animations/LazyRevealImage.tsx index 291574b..5279e0c 100644 --- a/app/components/animations/LazyRevealImage.tsx +++ b/app/components/animations/LazyRevealImage.tsx @@ -1,9 +1,7 @@ "use client"; -import { motion } from "motion/react"; import Image, { type StaticImageData } from "next/image"; -import { useState } from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { useEffect, useRef, useState } from "react"; interface LazyRevealImageProps { src: string | StaticImageData; @@ -13,6 +11,7 @@ interface LazyRevealImageProps { sizes?: string; } +/** Imagem que entra com um fade curto quando termina de carregar (CSS em globals.css, .ds-lazy-reveal). */ export function LazyRevealImage({ src, alt, @@ -20,31 +19,20 @@ export function LazyRevealImage({ imageClassName = "object-cover", sizes = "(max-width: 1024px) 100vw, 50vw", }: Readonly) { - const shouldReduceMotion = useReducedMotion(); + const ref = useRef(null); const [loaded, setLoaded] = useState(false); + // uma imagem que já estava no cache termina antes de a página hidratar: o onLoad não vem mais + useEffect(() => { + const image = ref.current?.querySelector("img"); + if (image?.complete && image.naturalWidth > 0) setLoaded(true); + }, []); + return ( - setLoaded(true)} className={imageClassName} /> - + ); } diff --git a/app/components/animations/TextDiffuseReveal.test.tsx b/app/components/animations/TextDiffuseReveal.test.tsx index ebf0dd5..105700a 100644 --- a/app/components/animations/TextDiffuseReveal.test.tsx +++ b/app/components/animations/TextDiffuseReveal.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react"; import { TextDiffuseReveal } from "./TextDiffuseReveal"; let mockInView = false; -jest.mock("motion/react", () => ({ useInView: () => mockInView })); +jest.mock("@/app/hooks/useInView", () => ({ useInView: () => mockInView })); describe("TextDiffuseReveal", () => { beforeEach(() => { diff --git a/app/components/animations/TextDiffuseReveal.tsx b/app/components/animations/TextDiffuseReveal.tsx index a484673..d32577f 100644 --- a/app/components/animations/TextDiffuseReveal.tsx +++ b/app/components/animations/TextDiffuseReveal.tsx @@ -1,7 +1,7 @@ "use client"; -import { useInView } from "motion/react"; import { type CSSProperties, useRef } from "react"; +import { useInView } from "@/app/hooks/useInView"; interface TextDiffuseRevealProps { text: string; diff --git a/app/components/animations/TopProgressBar.tsx b/app/components/animations/TopProgressBar.tsx deleted file mode 100644 index c6ae023..0000000 --- a/app/components/animations/TopProgressBar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; - -interface TopProgressBarProps { - className?: string; -} - -export function TopProgressBar({ - className = "", -}: Readonly) { - const shouldReduceMotion = useReducedMotion(); - - return ( - - ); -} diff --git a/app/components/animations/__mocks__/PointCloud.tsx b/app/components/animations/__mocks__/PointCloud.tsx deleted file mode 100644 index e79b07a..0000000 --- a/app/components/animations/__mocks__/PointCloud.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function PointCloudScene() { - return
; -} diff --git a/app/components/cinematic/CinematicEffects.test.tsx b/app/components/cinematic/CinematicEffects.test.tsx index bab5af4..2bee24d 100644 --- a/app/components/cinematic/CinematicEffects.test.tsx +++ b/app/components/cinematic/CinematicEffects.test.tsx @@ -1,6 +1,68 @@ import { act, render } from "@testing-library/react"; import FilmGrain from "./FilmGrain"; +/* IntersectionObserver controlado: o teste decide quando cada tela de projeto entra ou sai da janela. */ +type Observer = { + callback: IntersectionObserverCallback; + observed: Set; + disconnect: jest.Mock; +}; +let observers: Observer[] = []; +const original = global.IntersectionObserver; + +beforeEach(() => { + observers = []; + global.IntersectionObserver = class { + private readonly record: Observer; + constructor(callback: IntersectionObserverCallback) { + this.record = { callback, observed: new Set(), disconnect: jest.fn() }; + observers.push(this.record); + } + observe(target: Element) { + this.record.observed.add(target); + } + unobserve(target: Element) { + this.record.observed.delete(target); + } + disconnect() { + this.record.disconnect(); + } + takeRecords() { + return []; + } + } as unknown as typeof IntersectionObserver; +}); + +afterEach(() => { + global.IntersectionObserver = original; + document.body.innerHTML = ""; +}); + +function addScreen(className: string) { + const element = document.createElement("div"); + element.className = className; + document.body.append(element); + return element; +} + +/** O navegador avisa que `target` entrou (ou saiu) da janela; `height` 0 é uma tela escondida por CSS. */ +function intersect(target: Element, isIntersecting: boolean, height = 600) { + act(() => { + for (const observer of observers) + if (observer.observed.has(target)) + observer.callback( + [ + { + target, + isIntersecting, + boundingClientRect: { height }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); +} + describe("static cinematic effects", () => { it("renders grain without a canvas", () => { const grain = render(); @@ -13,62 +75,36 @@ describe("static cinematic effects", () => { }); describe("film grain over the project screens", () => { - let screenTop = 5000; - - function addScreen(className: string) { - const element = document.createElement("div"); - element.className = className; - element.getBoundingClientRect = () => - ({ top: screenTop, bottom: screenTop + 600, height: 600 }) as DOMRect; - document.body.append(element); - return element; - } - - function scroll(to: number) { - screenTop = to; - act(() => { - window.dispatchEvent(new Event("scroll")); - jest.advanceTimersByTime(40); - }); - } - - beforeEach(() => { - jest.useFakeTimers(); - screenTop = 5000; - Object.defineProperty(window, "innerHeight", { - configurable: true, - value: 800, - }); - }); - - afterEach(() => { - jest.useRealTimers(); - document.body.innerHTML = ""; - }); - it("steps aside while a project screen is in view and comes back when it leaves", () => { - addScreen("ds-reel"); + const reel = addScreen("ds-reel"); const { container } = render(); const grain = container.firstElementChild as HTMLElement; expect(grain).not.toHaveAttribute("data-over-projects"); - scroll(300); // a vitrine entrou na janela + intersect(reel, true); // a vitrine entrou na janela expect(grain).toHaveAttribute("data-over-projects"); - scroll(-900); // passou: a cena acabou 300 px acima do topo + intersect(reel, false); // passou expect(grain).not.toHaveAttribute("data-over-projects"); }); - it("also covers the browser and phone frames", () => { - addScreen("ds-bframe"); - const { container } = render(); - scroll(100); - expect(container.firstElementChild).toHaveAttribute("data-over-projects"); + it("also covers the browser and phone frames and the card screens", () => { + for (const className of ["ds-bframe", "ds-phone", "ds-frame"]) { + const screen = addScreen(className); + const { container, unmount } = render(); + + intersect(screen, true); + + expect(container.firstElementChild).toHaveAttribute("data-over-projects"); + unmount(); + document.body.innerHTML = ""; + } }); it("stays on when there is no project screen on the page", () => { const { container } = render(); - scroll(100); + + expect(observers[0].observed.size).toBe(0); expect(container.firstElementChild).not.toHaveAttribute( "data-over-projects", ); diff --git a/app/components/cinematic/CinematicOverlays.paths.test.tsx b/app/components/cinematic/CinematicOverlays.paths.test.tsx new file mode 100644 index 0000000..4be94b2 --- /dev/null +++ b/app/components/cinematic/CinematicOverlays.paths.test.tsx @@ -0,0 +1,109 @@ +import { act, render, screen } from "@testing-library/react"; +import CinematicOverlays from "./CinematicOverlays"; + +/** + * Testes de caminhos-base de CinematicOverlays (complemento de CinematicOverlays.test.tsx): a troca de página + * com a página montada (de fora da home para a home e de volta) e as duas condições do grão juntas. + */ + +let mockPathname = "/"; +jest.mock("next/navigation", () => ({ usePathname: () => mockPathname })); + +const mockLoaded = jest.fn(); +jest.mock("./FilmGrain", () => { + mockLoaded(); + return { + __esModule: true, + default: function MockGrain() { + return
; + }, + }; +}); + +/** O import dinâmico do grão resolve em microtarefas. */ +const loaded = () => + act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + +const original = window.matchMedia; + +function mockMedia(active: string[] = []) { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: active.includes(query), + media: query, + addEventListener() {}, + removeEventListener() {}, + })); +} + +beforeEach(() => { + mockPathname = "/"; + mockLoaded.mockClear(); + mockMedia(); +}); + +afterEach(() => { + window.matchMedia = original; +}); + +describe("CinematicOverlays: troca de página", () => { + it("path: arriving on the home from another page with a mouse -> the grain and the progress bar appear", async () => { + mockPathname = "/cases"; + const { container, rerender } = render(); + await loaded(); + expect(container).toBeEmptyDOMElement(); + + mockPathname = "/"; + rerender(); + await loaded(); + + expect(screen.getByTestId("grain")).toBeInTheDocument(); + expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument(); + }); + + it("path: leaving the home after the grain loaded -> everything goes away, and comes back on return", async () => { + const { container, rerender } = render(); + await loaded(); + expect(screen.getByTestId("grain")).toBeInTheDocument(); + + mockPathname = "/servicos"; + rerender(); + expect(screen.queryByTestId("grain")).toBeNull(); + expect(container).toBeEmptyDOMElement(); + + mockPathname = "/"; + rerender(); + await loaded(); + expect(screen.getByTestId("grain")).toBeInTheDocument(); + expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument(); + }); + + it("path: a sub-route that merely starts like the home path -> not the home", async () => { + mockPathname = "/cases/sev-modaintima"; + const { container } = render(); + await loaded(); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByTestId("grain")).toBeNull(); + }); +}); + +describe("CinematicOverlays: condições do grão juntas", () => { + it("path: touch screen and reduced motion together -> no grain, the progress bar stays", async () => { + mockMedia(["(pointer: coarse)", "(prefers-reduced-motion: reduce)"]); + const { container } = render(); + await loaded(); + + expect(screen.queryByTestId("grain")).toBeNull(); + expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument(); + }); + + it("path: no matchMedia in the browser -> counts as mouse with motion allowed: the grain loads", async () => { + window.matchMedia = undefined as unknown as typeof window.matchMedia; + render(); + await loaded(); + + expect(screen.getByTestId("grain")).toBeInTheDocument(); + }); +}); diff --git a/app/components/cinematic/CinematicOverlays.test.tsx b/app/components/cinematic/CinematicOverlays.test.tsx new file mode 100644 index 0000000..80bca8f --- /dev/null +++ b/app/components/cinematic/CinematicOverlays.test.tsx @@ -0,0 +1,102 @@ +import { act, render, screen } from "@testing-library/react"; +import CinematicOverlays from "./CinematicOverlays"; + +let mockPathname = "/"; +jest.mock("next/navigation", () => ({ usePathname: () => mockPathname })); + +// o grão vem por import dinâmico (pacote próprio): aqui ele vira um marcador, e o contador diz se o pacote foi pedido +const mockLoaded = jest.fn(); +jest.mock("./FilmGrain", () => { + mockLoaded(); + return { + __esModule: true, + default: function MockGrain() { + return
; + }, + }; +}); + +/** O import dinâmico do grão resolve em microtarefas. */ +const loaded = () => + act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + +const original = window.matchMedia; + +function mockMedia(active: string[] = []) { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: active.includes(query), + media: query, + addEventListener() {}, + removeEventListener() {}, + })); +} + +beforeEach(() => { + mockPathname = "/"; + mockMedia(); +}); + +afterEach(() => { + window.matchMedia = original; +}); + +/* + * Os três primeiros testes vêm antes de qualquer um que carregue o grão: o módulo, depois de pedido uma vez, fica + * no registro do Jest, então "o pacote nunca foi pedido" só vale enquanto ninguém o pediu. + */ +describe("CinematicOverlays: where the grain package is never requested", () => { + it("does not request the grain on a touch screen (nothing invisible left measuring the page); the progress bar stays", async () => { + mockMedia(["(pointer: coarse)"]); + const { container } = render(); + await loaded(); + + expect(mockLoaded).not.toHaveBeenCalled(); + expect(screen.queryByTestId("grain")).toBeNull(); + // a barra de progresso é CSS puro: continua + expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument(); + }); + + it("does not request the grain for users who prefer reduced motion", async () => { + mockMedia(["(prefers-reduced-motion: reduce)"]); + render(); + await loaded(); + + expect(mockLoaded).not.toHaveBeenCalled(); + expect(screen.queryByTestId("grain")).toBeNull(); + }); + + it("shows nothing, and requests nothing, on the other pages", async () => { + mockPathname = "/cases"; + const { container } = render(); + await loaded(); + + expect(mockLoaded).not.toHaveBeenCalled(); + expect(screen.queryByTestId("grain")).toBeNull(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe("CinematicOverlays: with a mouse", () => { + it("shows the scroll progress at once and the film grain as soon as its package arrives", async () => { + const { container } = render(); + + // a barra vem no HTML; o grão só depois do import + expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument(); + expect(screen.queryByTestId("grain")).toBeNull(); + + await loaded(); + expect(screen.getByTestId("grain")).toBeInTheDocument(); + expect(mockLoaded).toHaveBeenCalled(); + }); + + it("does not mount the grain if the page unmounts while its package loads", async () => { + const { unmount } = render(); + unmount(); + + await loaded(); + + expect(screen.queryByTestId("grain")).toBeNull(); + }); +}); diff --git a/app/components/cinematic/CinematicOverlays.tsx b/app/components/cinematic/CinematicOverlays.tsx index 00f643f..0429f99 100644 --- a/app/components/cinematic/CinematicOverlays.tsx +++ b/app/components/cinematic/CinematicOverlays.tsx @@ -1,25 +1,35 @@ "use client"; -import dynamic from "next/dynamic"; import { usePathname } from "next/navigation"; +import { type ComponentType, useEffect, useState } from "react"; +import { isCoarsePointer, prefersReducedMotion } from "@/lib/device"; +import ScrollProgress from "./ScrollProgress"; -const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false }); -const ScrollProgress = dynamic(() => import("./ScrollProgress"), { - ssr: false, -}); - +/** + * Efeitos por cima da home: a barra de progresso (CSS puro) e o grão de filme. O grão só existe com mouse e sem + * movimento reduzido, e é importado só nesse caso: no toque o pacote dele nem é baixado, e nada fica montado, + * invisível, medindo a tela. + */ export default function CinematicOverlays() { - const pathname = usePathname(); - const isHome = pathname === "/"; + const isHome = usePathname() === "/"; + const [Grain, setGrain] = useState(null); + useEffect(() => { + if (!isHome || isCoarsePointer() || prefersReducedMotion()) return; + let alive = true; + import("./FilmGrain").then((module) => { + if (alive) setGrain(() => module.default); + }); + return () => { + alive = false; + }; + }, [isHome]); + + if (!isHome) return null; return ( <> - {isHome ? ( - <> - - - - ) : null} + {Grain && } + ); } diff --git a/app/components/cinematic/FilmGrain.paths.test.tsx b/app/components/cinematic/FilmGrain.paths.test.tsx index 2d5e1eb..7bbee02 100644 --- a/app/components/cinematic/FilmGrain.paths.test.tsx +++ b/app/components/cinematic/FilmGrain.paths.test.tsx @@ -2,88 +2,134 @@ import { act, render } from "@testing-library/react"; import FilmGrain from "./FilmGrain"; /** - * Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo - * de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros - * agrupados e a limpeza ao desmontar. + * Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): a medição é do + * IntersectionObserver (nada ouve a rolagem nem mede a página a cada quadro), uma tela escondida por CSS não + * conta, várias telas, telas que a página cria ou remove depois e a limpeza ao desmontar. */ -const INNER_HEIGHT = 800; +type Observer = { + callback: IntersectionObserverCallback; + observed: Set; + /** quantas vezes observe() foi chamado (um conjunto esconderia repetições) */ + calls: number; + disconnect: jest.Mock; +}; +let observers: Observer[] = []; +const original = global.IntersectionObserver; -function addScreen( - className: string, - rect: { top: number; bottom: number; height: number }, -) { +beforeEach(() => { + observers = []; + global.IntersectionObserver = class { + private readonly record: Observer; + constructor(callback: IntersectionObserverCallback) { + this.record = { + callback, + observed: new Set(), + calls: 0, + disconnect: jest.fn(), + }; + observers.push(this.record); + } + observe(target: Element) { + this.record.observed.add(target); + this.record.calls += 1; + } + unobserve(target: Element) { + this.record.observed.delete(target); + } + disconnect() { + this.record.disconnect(); + } + takeRecords() { + return []; + } + } as unknown as typeof IntersectionObserver; +}); + +afterEach(() => { + global.IntersectionObserver = original; + jest.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function addScreen(className: string) { const element = document.createElement("div"); element.className = className; - element.getBoundingClientRect = () => rect as DOMRect; document.body.append(element); return element; } -beforeEach(() => { - jest.useFakeTimers(); - Object.defineProperty(window, "innerHeight", { - configurable: true, - value: INNER_HEIGHT, +function intersect(target: Element, isIntersecting: boolean, height = 600) { + act(() => { + for (const observer of observers) + if (observer.observed.has(target)) + observer.callback( + [ + { + target, + isIntersecting, + boundingClientRect: { height }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); }); -}); - -afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - document.body.innerHTML = ""; -}); +} function renderGrain() { const view = render(); return { ...view, grain: view.container.firstElementChild as HTMLElement }; } +/** Deixa o MutationObserver (microtarefa) avisar. */ +const flush = () => + act(async () => { + await Promise.resolve(); + }); + describe("FilmGrain: is a project screen in view?", () => { it("path: a visible screen -> the grain steps aside", () => { - addScreen("ds-phone", { top: 100, bottom: 700, height: 600 }); - + const screen = addScreen("ds-phone"); const { grain } = renderGrain(); + intersect(screen, true); + expect(grain).toHaveAttribute("data-over-projects"); }); - it.each([ - ["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }], - ["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }], - ["ended above the window", { top: -900, bottom: -300, height: 600 }], - [ - "starts exactly at the bottom edge", - { top: INNER_HEIGHT, bottom: 1400, height: 600 }, - ], - ["starts below the window", { top: 2000, bottom: 2600, height: 600 }], - ])("path: a screen that %s -> does not count", (_label, rect) => { - addScreen("ds-bframe", rect); - + it("path: a screen with no height (hidden by CSS) -> does not count even if the browser says it intersects", () => { + const screen = addScreen("ds-bframe"); const { grain } = renderGrain(); + intersect(screen, true, 0); + expect(grain).not.toHaveAttribute("data-over-projects"); }); - it("path: just inside the window by one pixel at either edge -> counts", () => { - addScreen("ds-frame", { top: -599, bottom: 1, height: 600 }); - const top = renderGrain(); - expect(top.grain).toHaveAttribute("data-over-projects"); - top.unmount(); - document.body.innerHTML = ""; - - addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 }); - const bottom = renderGrain(); - expect(bottom.grain).toHaveAttribute("data-over-projects"); - }); - - it("path: one screen off the window and another in view -> counts (any is enough)", () => { - addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 }); - addScreen("ds-phone", { top: 100, bottom: 700, height: 600 }); - + it("path: a screen that is off the window -> does not count", () => { + const screen = addScreen("ds-frame"); const { grain } = renderGrain(); + intersect(screen, false); + + expect(grain).not.toHaveAttribute("data-over-projects"); + }); + + it("path: one screen off the window and another in view -> counts (any is enough), and leaves only when the last one leaves", () => { + const far = addScreen("ds-reel"); + const near = addScreen("ds-phone"); + const { grain } = renderGrain(); + + intersect(far, false); + intersect(near, true); expect(grain).toHaveAttribute("data-over-projects"); + + intersect(far, true); + intersect(near, false); + expect(grain).toHaveAttribute("data-over-projects"); + + intersect(far, false); + expect(grain).not.toHaveAttribute("data-over-projects"); }); it("path: no project screen on the page -> the grain stays", () => { @@ -93,47 +139,83 @@ describe("FilmGrain: is a project screen in view?", () => { }); }); -describe("FilmGrain: frames and cleanup", () => { - it("path: many scroll events inside one frame -> the page is measured once", () => { - const screenEl = addScreen("ds-reel", { - top: 5000, - bottom: 5600, - height: 600, - }); - const measure = jest.spyOn(screenEl, "getBoundingClientRect"); - renderGrain(); - measure.mockClear(); +describe("FilmGrain: screens that appear and disappear", () => { + it("path: a screen the page creates after the grain mounted (the pinned reel swapping the list) is observed too", async () => { + const { grain } = renderGrain(); + expect(observers[0].observed.size).toBe(0); - act(() => { - window.dispatchEvent(new Event("scroll")); - window.dispatchEvent(new Event("scroll")); - window.dispatchEvent(new Event("resize")); - jest.advanceTimersByTime(40); - }); + const reel = addScreen("ds-reel"); + await flush(); + expect(observers[0].observed.has(reel)).toBe(true); - expect(measure).toHaveBeenCalledTimes(1); + intersect(reel, true); + expect(grain).toHaveAttribute("data-over-projects"); }); - it("path: unmount -> listeners removed and no measurement afterwards", () => { - const screenEl = addScreen("ds-reel", { - top: 5000, - bottom: 5600, - height: 600, - }); - const measure = jest.spyOn(screenEl, "getBoundingClientRect"); - const { unmount } = renderGrain(); - act(() => { - window.dispatchEvent(new Event("scroll")); - }); + it("path: a screen that is removed while in view stops counting", async () => { + const reel = addScreen("ds-reel"); + const { grain } = renderGrain(); + intersect(reel, true); + expect(grain).toHaveAttribute("data-over-projects"); + + reel.remove(); + await flush(); + + expect(observers[0].observed.has(reel)).toBe(false); + expect(grain).not.toHaveAttribute("data-over-projects"); + }); + + it("path: the same screen is never observed twice", async () => { + addScreen("ds-reel"); + renderGrain(); + expect(observers[0].calls).toBe(1); + + addScreen("ds-phone"); + await flush(); + + // só a tela nova foi pedida ao observador; a primeira não se repete + expect(observers[0].calls).toBe(2); + expect(observers[0].observed.size).toBe(2); + }); +}); + +describe("FilmGrain: no work per frame, cleanup", () => { + it("path: nothing listens to the scroll or the window size, and the page is never measured", () => { + const screen = addScreen("ds-reel"); + const measure = jest.spyOn(screen, "getBoundingClientRect"); + const listen = jest.spyOn(window, "addEventListener"); + renderGrain(); - unmount(); - measure.mockClear(); act(() => { window.dispatchEvent(new Event("scroll")); window.dispatchEvent(new Event("resize")); - jest.advanceTimersByTime(100); }); expect(measure).not.toHaveBeenCalled(); + const types = listen.mock.calls.map(([type]) => type); + expect(types).not.toContain("scroll"); + expect(types).not.toContain("resize"); + }); + + it("path: unmount -> the observers are disconnected and later screens are ignored", async () => { + const { unmount } = renderGrain(); + + unmount(); + + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + addScreen("ds-reel"); + await flush(); + expect(observers[0].observed.size).toBe(0); + }); + + it("path: a browser without IntersectionObserver -> the grain renders and simply stays", () => { + // biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API + (global as any).IntersectionObserver = undefined; + addScreen("ds-reel"); + + const { grain } = renderGrain(); + + expect(grain).toHaveClass("film-grain"); + expect(grain).not.toHaveAttribute("data-over-projects"); }); }); diff --git a/app/components/cinematic/FilmGrain.tsx b/app/components/cinematic/FilmGrain.tsx index 008c0f0..3acd963 100644 --- a/app/components/cinematic/FilmGrain.tsx +++ b/app/components/cinematic/FilmGrain.tsx @@ -5,6 +5,48 @@ import { useEffect, useRef } from "react"; /** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */ const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone"; +/** + * Avisa `onChange(true)` enquanto qualquer tela de projeto está à vista. Usa IntersectionObserver (nada mede a + * página a cada quadro de rolagem) e um MutationObserver só para pegar as telas que a página cria ou remove + * depois (a vitrine fixa troca a lista ao hidratar). Devolve a função que desfaz tudo. + */ +function watchProjectScreens(onChange: (over: boolean) => void) { + if (typeof IntersectionObserver === "undefined") return () => {}; + const watched = new Set(); + const inView = new Set(); + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting && entry.boundingClientRect.height > 0) + inView.add(entry.target); + else inView.delete(entry.target); + } + onChange(inView.size > 0); + }); + + const scan = () => { + for (const screen of document.querySelectorAll(PROJECT_SCREENS)) { + if (watched.has(screen)) continue; + watched.add(screen); + observer.observe(screen); + } + for (const screen of watched) { + if (screen.isConnected) continue; + observer.unobserve(screen); + watched.delete(screen); + inView.delete(screen); + } + onChange(inView.size > 0); + }; + scan(); + const mutations = new MutationObserver(scan); + mutations.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + mutations.disconnect(); + }; +} + /** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */ export default function FilmGrain() { const ref = useRef(null); @@ -12,31 +54,10 @@ export default function FilmGrain() { useEffect(() => { const grain = ref.current; if (!grain) return; - let frame = 0; - const update = () => { - frame = 0; - const over = Array.from(document.querySelectorAll(PROJECT_SCREENS)).some( - (screen) => { - const rect = screen.getBoundingClientRect(); - return ( - rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight - ); - }, - ); + return watchProjectScreens((over) => { if (over) grain.dataset.overProjects = ""; else delete grain.dataset.overProjects; - }; - const queue = () => { - if (!frame) frame = requestAnimationFrame(update); - }; - update(); - window.addEventListener("scroll", queue, { passive: true }); - window.addEventListener("resize", queue); - return () => { - cancelAnimationFrame(frame); - window.removeEventListener("scroll", queue); - window.removeEventListener("resize", queue); - }; + }); }, []); return