perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
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.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -6,7 +6,6 @@ type CoverProject = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
demoUrl?: string | null;
|
demoUrl?: string | null;
|
||||||
githubUrl?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Capturas de tela publicadas dos projetos que têm interface pública. */
|
/** Capturas de tela publicadas dos projetos que têm interface pública. */
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active showPoster={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<ProjectVideo video={video} title="Demo" active />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProjectVideo
|
||||||
|
video={video}
|
||||||
|
title="Demo"
|
||||||
|
active={false}
|
||||||
|
showPoster={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("video")).not.toHaveAttribute("poster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: inactive and never activated -> no source, no load, no play, no pause", () => {
|
||||||
|
render(<ProjectVideo video={video} title="Demo" active={false} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||||
|
);
|
||||||
|
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(<ProjectVideo video={video} title="Demo" active />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={empty} title="Demo" active />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active />,
|
||||||
|
);
|
||||||
|
const loadsAfterFirst = jest.mocked(HTMLMediaElement.prototype.load).mock
|
||||||
|
.calls.length;
|
||||||
|
const playsAfterFirst = jest.mocked(HTMLMediaElement.prototype.play).mock
|
||||||
|
.calls.length;
|
||||||
|
|
||||||
|
rerender(<ProjectVideo video={video} title="Demo" active={false} />);
|
||||||
|
rerender(<ProjectVideo video={video} title="Demo" active />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,10 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ProjectVideo", () => {
|
describe("ProjectVideo", () => {
|
||||||
@@ -31,7 +35,7 @@ describe("ProjectVideo", () => {
|
|||||||
expect(element).toHaveAttribute("autoplay");
|
expect(element).toHaveAttribute("autoplay");
|
||||||
expect(element).toHaveAttribute("loop");
|
expect(element).toHaveAttribute("loop");
|
||||||
expect(element).toHaveAttribute("playsinline");
|
expect(element).toHaveAttribute("playsinline");
|
||||||
expect(element).toHaveAttribute("preload", "none");
|
expect(element).toHaveAttribute("preload", "metadata");
|
||||||
expect(element).toHaveAttribute("poster", video.poster);
|
expect(element).toHaveAttribute("poster", video.poster);
|
||||||
expect(element?.muted).toBe(true);
|
expect(element?.muted).toBe(true);
|
||||||
expect(element).toHaveAttribute("data-ready", "false");
|
expect(element).toHaveAttribute("data-ready", "false");
|
||||||
@@ -70,6 +74,62 @@ describe("ProjectVideo", () => {
|
|||||||
expect(element).toHaveAttribute("data-ready", "true");
|
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(
|
||||||
|
<ProjectVideo
|
||||||
|
video={video}
|
||||||
|
title="Demo"
|
||||||
|
active={false}
|
||||||
|
showPoster={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("video")).not.toHaveAttribute("poster");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ProjectVideo video={video} title="Demo" active={false} showPoster />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active />,
|
||||||
|
);
|
||||||
|
const loads = jest.mocked(HTMLMediaElement.prototype.load).mock.calls
|
||||||
|
.length;
|
||||||
|
|
||||||
|
rerender(<ProjectVideo video={mobile} title="Demo" active />);
|
||||||
|
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(
|
||||||
|
<ProjectVideo video={video} title="Demo" active />,
|
||||||
|
);
|
||||||
|
|
||||||
|
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", () => {
|
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
|
||||||
reducedMotion = true;
|
reducedMotion = true;
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -4,36 +4,44 @@ import Image from "next/image";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import type { ShowcaseAssets } from "@/lib/project-showcase";
|
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
|
* Vídeo de rolagem na capa do card. `video` já é a versão do aparelho (quem chama escolhe: celular abaixo de
|
||||||
* está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o
|
* 768 px; no servidor, nenhuma). Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que está
|
||||||
* card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece.
|
* sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o card
|
||||||
* O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
|
* 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({
|
export function ProjectVideo({
|
||||||
video,
|
video,
|
||||||
title,
|
title,
|
||||||
active,
|
active,
|
||||||
|
showPoster = true,
|
||||||
}: {
|
}: {
|
||||||
video: ShowcaseAssets;
|
video: ShowcaseAssets;
|
||||||
title: string;
|
title: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
showPoster?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
|
const saveData = useSaveData();
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
const [attached, setAttached] = useState(false);
|
const [attached, setAttached] = useState(false);
|
||||||
const [ready, setReady] = 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(() => {
|
useEffect(() => {
|
||||||
if (playing) setAttached(true);
|
if (playing) setAttached(true);
|
||||||
}, [playing]);
|
}, [playing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// as fontes entram depois do primeiro render: o elemento precisa ler a lista nova
|
// as fontes entram depois do primeiro render (ou mudam com o aparelho): o elemento precisa ler a lista nova
|
||||||
if (attached) ref.current?.load();
|
if (attached && firstSource) ref.current?.load();
|
||||||
}, [attached]);
|
}, [attached, firstSource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = ref.current;
|
const element = ref.current;
|
||||||
@@ -46,7 +54,8 @@ export function ProjectVideo({
|
|||||||
}
|
}
|
||||||
}, [attached, playing]);
|
}, [attached, playing]);
|
||||||
|
|
||||||
if (reducedMotion) {
|
if (posterOnly) {
|
||||||
|
if (!showPoster) return null;
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={video.poster}
|
src={video.poster}
|
||||||
@@ -65,8 +74,8 @@ export function ProjectVideo({
|
|||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="metadata"
|
||||||
poster={video.poster}
|
poster={showPoster ? video.poster : undefined}
|
||||||
width={video.width}
|
width={video.width}
|
||||||
height={video.height}
|
height={video.height}
|
||||||
data-ready={ready}
|
data-ready={ready}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render } from "@testing-library/react";
|
import { act, render } from "@testing-library/react";
|
||||||
import FilmGrain from "./FilmGrain";
|
import FilmGrain from "./FilmGrain";
|
||||||
|
|
||||||
describe("static cinematic effects", () => {
|
describe("static cinematic effects", () => {
|
||||||
@@ -11,3 +11,66 @@ describe("static cinematic effects", () => {
|
|||||||
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
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(<FilmGrain />);
|
||||||
|
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(<FilmGrain />);
|
||||||
|
scroll(100);
|
||||||
|
expect(container.firstElementChild).toHaveAttribute("data-over-projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays on when there is no project screen on the page", () => {
|
||||||
|
const { container } = render(<FilmGrain />);
|
||||||
|
scroll(100);
|
||||||
|
expect(container.firstElementChild).not.toHaveAttribute(
|
||||||
|
"data-over-projects",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(<FilmGrain />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() {
|
export default function FilmGrain() {
|
||||||
return <div aria-hidden="true" className="film-grain" />;
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (over) grain.dataset.overProjects = "";
|
||||||
|
else delete grain.dataset.overProjects;
|
||||||
|
};
|
||||||
|
const queue = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener("scroll", queue, { passive: true });
|
||||||
|
window.addEventListener("resize", queue);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener("scroll", queue);
|
||||||
|
window.removeEventListener("resize", queue);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={ref} aria-hidden="true" className="film-grain" />;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import FramePulse from "./FramePulse";
|
||||||
|
|
||||||
|
const mockInvalidate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@react-three/fiber", () => ({
|
||||||
|
useThree: (select: (state: { invalidate: () => void }) => unknown) =>
|
||||||
|
select({ invalidate: mockInvalidate }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockInvalidate.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FramePulse", () => {
|
||||||
|
it("asks for frames at about 30 per second while active, not 60", () => {
|
||||||
|
render(<FramePulse active />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(20);
|
||||||
|
expect(mockInvalidate.mock.calls.length).toBeLessThanOrEqual(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws nothing at all when it is not active (off screen)", () => {
|
||||||
|
const { rerender } = render(<FramePulse active={false} />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(<FramePulse active />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
const running = mockInvalidate.mock.calls.length;
|
||||||
|
expect(running).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
rerender(<FramePulse active={false} />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(mockInvalidate.mock.calls.length).toBe(running);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batida das cenas WebGL em `frameloop="demand"`: enquanto `active`, pede um quadro a no máximo `fps` por
|
||||||
|
* segundo (fundo ambiente, sem gastar 60 quadros por segundo); com `active` falso, nenhum quadro é desenhado.
|
||||||
|
* Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame.
|
||||||
|
*/
|
||||||
|
export default function FramePulse({
|
||||||
|
active,
|
||||||
|
fps = 30,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
fps?: number;
|
||||||
|
}) {
|
||||||
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const gap = 1000 / fps - 2;
|
||||||
|
let id = 0;
|
||||||
|
let last = 0;
|
||||||
|
const beat = (now: number) => {
|
||||||
|
id = requestAnimationFrame(beat);
|
||||||
|
if (now - last < gap) return;
|
||||||
|
last = now;
|
||||||
|
invalidate();
|
||||||
|
};
|
||||||
|
id = requestAnimationFrame(beat);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [active, fps, invalidate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { isLowPowerDevice } from "@/lib/device";
|
||||||
|
import HeroBackground from "./HeroBackground";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de HeroBackground: movimento reduzido (nada), aparelho fraco (só as camadas escuras,
|
||||||
|
* sem baixar a cena 3D), aparelho capaz (cena montada depois do efeito) e o HTML do servidor (sem cena).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let reducedMotion = false;
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => reducedMotion,
|
||||||
|
}));
|
||||||
|
jest.mock("@/lib/device", () => ({ isLowPowerDevice: jest.fn() }));
|
||||||
|
jest.mock("next/dynamic", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () =>
|
||||||
|
function MockScene() {
|
||||||
|
return require("react").createElement("canvas", {
|
||||||
|
"data-testid": "hero-scene",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lowPower = jest.mocked(isLowPowerDevice);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reducedMotion = false;
|
||||||
|
lowPower.mockReset();
|
||||||
|
lowPower.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroBackground", () => {
|
||||||
|
it("path: reduced motion -> renders nothing, even on a capable device", () => {
|
||||||
|
reducedMotion = true;
|
||||||
|
|
||||||
|
const { container } = render(<HeroBackground />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: capable device -> the 3D scene plus the three dark layers", () => {
|
||||||
|
const { container, getByTestId } = render(<HeroBackground />);
|
||||||
|
|
||||||
|
expect(getByTestId("hero-scene")).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll(".pointer-events-none")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: low-power device -> only the dark layers, the 3D scene is never mounted", () => {
|
||||||
|
lowPower.mockReturnValue(true);
|
||||||
|
|
||||||
|
const { container, queryByTestId } = render(<HeroBackground />);
|
||||||
|
|
||||||
|
expect(queryByTestId("hero-scene")).toBeNull();
|
||||||
|
expect(container.querySelectorAll(".pointer-events-none")).toHaveLength(3);
|
||||||
|
expect(lowPower).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: server HTML -> layers without the scene (capability is only known in the browser)", () => {
|
||||||
|
const html = renderToString(<HeroBackground />);
|
||||||
|
|
||||||
|
expect(html).not.toContain("canvas");
|
||||||
|
expect(html).toContain("pointer-events-none");
|
||||||
|
expect(lowPower).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import { isLowPowerDevice } from "@/lib/device";
|
||||||
|
|
||||||
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable 3D animated background for internal hero sections.
|
* Reusable 3D animated background for internal hero sections.
|
||||||
* Drop this inside any `relative overflow-hidden` dark section.
|
* Drop this inside any `relative overflow-hidden` dark section.
|
||||||
|
* A cena WebGL só monta em aparelho que aguenta (sem toque, sem pouca CPU/memória, sem "economizar dados"):
|
||||||
|
* nos outros fica o fundo escuro, sem baixar o Three.js.
|
||||||
*/
|
*/
|
||||||
export default function HeroBackground() {
|
export default function HeroBackground() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const [capable, setCapable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCapable(!isLowPowerDevice());
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (shouldReduceMotion) return null;
|
if (shouldReduceMotion) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 3D particle scene */}
|
{/* 3D particle scene */}
|
||||||
<HeroScene />
|
{capable && <HeroScene />}
|
||||||
|
|
||||||
{/* Cinematic radial vignette */}
|
{/* Cinematic radial vignette */}
|
||||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.5)_55%,var(--ink)_100%)]" />
|
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.5)_55%,var(--ink)_100%)]" />
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import HeroField from "./HeroField";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): a decisão de montar o WebGL
|
||||||
|
* (requestIdleCallback x temporizador, desmontagem antes da hora, falhas ao pedir o contexto WebGL2),
|
||||||
|
* o fundo sem a seção `.hero`, as variáveis de rolagem e a visibilidade (observador, aba oculta, fim do mergulho).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SceneProps = {
|
||||||
|
visible: boolean;
|
||||||
|
progressRef: { current: number };
|
||||||
|
onReady: () => void;
|
||||||
|
onError: () => void;
|
||||||
|
};
|
||||||
|
const mockScenes: SceneProps[] = [];
|
||||||
|
|
||||||
|
jest.mock("next/dynamic", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () =>
|
||||||
|
function MockScene(props: SceneProps) {
|
||||||
|
mockScenes.push(props);
|
||||||
|
return require("react").createElement("canvas", {
|
||||||
|
"data-testid": "scene",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const win = window as unknown as Record<string, unknown>;
|
||||||
|
const original = {
|
||||||
|
matchMedia: window.matchMedia,
|
||||||
|
webgl2: win.WebGL2RenderingContext,
|
||||||
|
getContext: HTMLCanvasElement.prototype.getContext,
|
||||||
|
observer: global.IntersectionObserver,
|
||||||
|
idle: win.requestIdleCallback,
|
||||||
|
cancelIdle: win.cancelIdleCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockMedia(active: string[]) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: active.includes(query),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockNavigator(values: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContext(factory: () => unknown) {
|
||||||
|
HTMLCanvasElement.prototype.getContext = factory as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Observer = {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
disconnect: jest.Mock;
|
||||||
|
};
|
||||||
|
let observers: Observer[] = [];
|
||||||
|
|
||||||
|
function Hero({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="hero">
|
||||||
|
<HeroField />
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHidden(value: boolean) {
|
||||||
|
Object.defineProperty(document, "hidden", { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportIntersecting(value: boolean) {
|
||||||
|
act(() => {
|
||||||
|
for (const observer of observers)
|
||||||
|
observer.callback(
|
||||||
|
[{ isIntersecting: value } as IntersectionObserverEntry],
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockScenes.length = 0;
|
||||||
|
observers = [];
|
||||||
|
mockMedia([]);
|
||||||
|
mockNavigator({
|
||||||
|
hardwareConcurrency: 8,
|
||||||
|
deviceMemory: undefined,
|
||||||
|
connection: undefined,
|
||||||
|
});
|
||||||
|
win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
|
||||||
|
setContext(() => ({ getExtension: () => null }));
|
||||||
|
win.requestIdleCallback = undefined;
|
||||||
|
win.cancelIdleCallback = undefined;
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
private readonly record: Observer;
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.record = { callback, disconnect: jest.fn() };
|
||||||
|
observers.push(this.record);
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {
|
||||||
|
this.record.disconnect();
|
||||||
|
}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
window.matchMedia = original.matchMedia;
|
||||||
|
win.WebGL2RenderingContext = original.webgl2;
|
||||||
|
win.requestIdleCallback = original.idle;
|
||||||
|
win.cancelIdleCallback = original.cancelIdle;
|
||||||
|
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||||
|
global.IntersectionObserver = original.observer;
|
||||||
|
Reflect.deleteProperty(document, "hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroField: quando o WebGL monta", () => {
|
||||||
|
it("path: no requestIdleCallback -> the scene mounts after 1.2 s of timer, not before", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1199);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: requestIdleCallback available -> mounts when the browser is idle (2.5 s timeout), not on the timer", () => {
|
||||||
|
let idle: (() => void) | undefined;
|
||||||
|
const request = jest.fn((callback: () => void) => {
|
||||||
|
idle = callback;
|
||||||
|
return 42;
|
||||||
|
});
|
||||||
|
win.requestIdleCallback = request;
|
||||||
|
win.cancelIdleCallback = jest.fn();
|
||||||
|
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
expect(request).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
|
timeout: 2500,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => idle?.());
|
||||||
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
|
||||||
|
const cancel = jest.fn();
|
||||||
|
win.requestIdleCallback = jest.fn(() => 7);
|
||||||
|
win.cancelIdleCallback = cancel;
|
||||||
|
|
||||||
|
const view = render(<Hero />);
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalledWith(7);
|
||||||
|
expect(mockScenes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
|
||||||
|
const view = render(<Hero />);
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockScenes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
|
||||||
|
setContext(() => {
|
||||||
|
throw new Error("sem GPU");
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: WebGL2 constructor exists but the context is null -> static background", () => {
|
||||||
|
setContext(() => null);
|
||||||
|
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
|
||||||
|
const loseContext = jest.fn();
|
||||||
|
setContext(() => ({
|
||||||
|
getExtension: (name: string) =>
|
||||||
|
name === "WEBGL_lose_context" ? { loseContext } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the lose-context extension is missing -> the probe still succeeds", () => {
|
||||||
|
setContext(() => ({ getExtension: () => null }));
|
||||||
|
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: low power -> the WebGL probe and the timer are never started", () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||||
|
setContext(probe);
|
||||||
|
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
expect(mockScenes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroField: rolagem e visibilidade", () => {
|
||||||
|
function section() {
|
||||||
|
return document.querySelector(".hero") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(top: number, height = 2000) {
|
||||||
|
section().getBoundingClientRect = () =>
|
||||||
|
({ top, height, bottom: top + height }) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scroll(top: number) {
|
||||||
|
place(top);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 800,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
|
||||||
|
render(<HeroField />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observers).toHaveLength(0);
|
||||||
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
const setProperty = jest.spyOn(section().style, "setProperty");
|
||||||
|
|
||||||
|
scroll(-600);
|
||||||
|
const afterFirst = setProperty.mock.calls.length;
|
||||||
|
expect(afterFirst).toBeGreaterThan(0);
|
||||||
|
scroll(-600);
|
||||||
|
|
||||||
|
expect(setProperty.mock.calls).toHaveLength(afterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: resize recomputes the progress like a scroll", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
place(-600);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scroll above the hero (positive top) -> progress clamps at 0", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
scroll(300);
|
||||||
|
|
||||||
|
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: end of the dive then back up -> drawing stops, then resumes", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
reportIntersecting(true);
|
||||||
|
|
||||||
|
scroll(-1200);
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
|
|
||||||
|
scroll(-300);
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the hero leaves the screen -> not visible; returns -> visible", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
reportIntersecting(true);
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||||
|
|
||||||
|
reportIntersecting(false);
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
|
|
||||||
|
reportIntersecting(true);
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
reportIntersecting(true);
|
||||||
|
|
||||||
|
setHidden(true);
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
|
|
||||||
|
setHidden(false);
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the tab shows but the hero is off screen -> still not visible", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
reportIntersecting(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled", () => {
|
||||||
|
const removeListener = jest.spyOn(window, "removeEventListener");
|
||||||
|
const removeDocListener = jest.spyOn(document, "removeEventListener");
|
||||||
|
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
|
||||||
|
const view = render(<Hero />);
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
const removed = removeListener.mock.calls.map(([type]) => type);
|
||||||
|
expect(removed).toEqual(expect.arrayContaining(["scroll", "resize"]));
|
||||||
|
expect(removeDocListener.mock.calls.map(([type]) => type)).toContain(
|
||||||
|
"visibilitychange",
|
||||||
|
);
|
||||||
|
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cancelFrame).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import HeroField from "./HeroField";
|
||||||
|
|
||||||
|
type SceneProps = {
|
||||||
|
visible: boolean;
|
||||||
|
progressRef: { current: number };
|
||||||
|
labelsRef?: unknown;
|
||||||
|
onReady: () => void;
|
||||||
|
onError: () => void;
|
||||||
|
};
|
||||||
|
const mockScenes: SceneProps[] = [];
|
||||||
|
|
||||||
|
jest.mock("next/dynamic", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () =>
|
||||||
|
function MockScene(props: SceneProps) {
|
||||||
|
mockScenes.push(props);
|
||||||
|
return require("react").createElement("canvas", {
|
||||||
|
"data-testid": "scene",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
matchMedia: window.matchMedia,
|
||||||
|
webgl2: (window as unknown as Record<string, unknown>).WebGL2RenderingContext,
|
||||||
|
getContext: HTMLCanvasElement.prototype.getContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockMedia(active: string[]) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: active.includes(query),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockNavigator(values: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="hero">
|
||||||
|
<HeroField />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O navegador ocioso: o Three.js só entra depois do tempo de espera. */
|
||||||
|
function idle() {
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockScenes.length = 0;
|
||||||
|
mockMedia([]);
|
||||||
|
mockNavigator({
|
||||||
|
hardwareConcurrency: 8,
|
||||||
|
deviceMemory: undefined,
|
||||||
|
connection: undefined,
|
||||||
|
});
|
||||||
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||||
|
function WebGL2RenderingContext() {};
|
||||||
|
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||||
|
getExtension: () => null,
|
||||||
|
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
window.matchMedia = original.matchMedia;
|
||||||
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||||
|
original.webgl2;
|
||||||
|
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroField (ambient background)", () => {
|
||||||
|
it("mounts the WebGL scene only after the page is idle, on a capable desktop", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
idle();
|
||||||
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
|
// o IntersectionObserver do jest.setup avisa que o hero está à vista: o desenho está liberado
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is decorative: hidden from assistive technology and with no labels over the 3D", () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
idle();
|
||||||
|
const field = container.querySelector(".hero-field");
|
||||||
|
expect(field).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(container.querySelector(".hero-tag")).toBeNull();
|
||||||
|
for (const label of ["A tela", "As integrações", "Os dados"])
|
||||||
|
expect(screen.queryByText(label, { exact: false })).toBeNull();
|
||||||
|
expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
|
||||||
|
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||||
|
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||||
|
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||||
|
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
|
||||||
|
[
|
||||||
|
"no WebGL2",
|
||||||
|
() => {
|
||||||
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||||
|
undefined;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"keeps the light static background (no WebGL) with %s",
|
||||||
|
(_label, arrange) => {
|
||||||
|
arrange();
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
idle();
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
expect(mockScenes).toHaveLength(0);
|
||||||
|
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||||
|
"data-ready",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("follows the hero scroll with --hero-p and --hero-open, and stops drawing once the dive is over", () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
idle();
|
||||||
|
const section = container.querySelector(".hero") as HTMLElement;
|
||||||
|
let top = 0;
|
||||||
|
section.getBoundingClientRect = () =>
|
||||||
|
({ top, height: 2000, bottom: top + 2000 }) as DOMRect;
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 800,
|
||||||
|
});
|
||||||
|
const scroll = (to: number) => {
|
||||||
|
top = -to;
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scroll(0);
|
||||||
|
expect(mockScenes.at(-1)?.progressRef.current).toBe(0);
|
||||||
|
|
||||||
|
scroll(600); // meio do mergulho (1200 px de trilho)
|
||||||
|
expect(Number(section.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
||||||
|
// a máscara já se abriu por completo: o fundo aparece inteiro
|
||||||
|
expect(Number(section.style.getPropertyValue("--hero-open"))).toBe(1);
|
||||||
|
|
||||||
|
scroll(1200);
|
||||||
|
expect(Number(section.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||||
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the scene (data-ready) once it has drawn its first frames", () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
idle();
|
||||||
|
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||||
|
"data-ready",
|
||||||
|
);
|
||||||
|
act(() => mockScenes.at(-1)?.onReady());
|
||||||
|
expect(container.querySelector(".hero-field")).toHaveAttribute(
|
||||||
|
"data-ready",
|
||||||
|
);
|
||||||
|
act(() => mockScenes.at(-1)?.onError());
|
||||||
|
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||||
|
"data-ready",
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { isLowPowerDevice } from "@/lib/device";
|
||||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||||
|
|
||||||
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
|
|
||||||
const TAGS = [
|
|
||||||
["A tela", "o que sua equipe e seus clientes usam"],
|
|
||||||
["As integrações", "seus sistemas conversando entre si"],
|
|
||||||
["Os dados", "suas informações organizadas"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const smooth = (a: number, b: number, x: number) => {
|
||||||
|
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
|
||||||
function supportsWebGL2() {
|
function supportsWebGL2() {
|
||||||
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
@@ -27,13 +26,15 @@ function supportsWebGL2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
* Fundo ambiente do hero. Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
||||||
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
|
* e --hero-open (0 em repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e monta a cena
|
||||||
|
* WebGL só quando a página fica ociosa e o aparelho aguenta: WebGL2, sem toque (pointer: coarse), com mais de
|
||||||
|
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido. Nos outros casos fica o fundo
|
||||||
|
* estático leve do CSS (.hero-glow). Sem legendas sobre o 3D: o espaço da direita é das molduras.
|
||||||
*/
|
*/
|
||||||
export default function HeroField() {
|
export default function HeroField() {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(0);
|
||||||
const labelsRef = useRef<(HTMLElement | null)[]>([]);
|
|
||||||
const [mount, setMount] = useState(false);
|
const [mount, setMount] = useState(false);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -53,8 +54,12 @@ export default function HeroField() {
|
|||||||
window.innerHeight * 0.8,
|
window.innerHeight * 0.8,
|
||||||
);
|
);
|
||||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||||
|
// depois do mergulho (ou antes dele) a rolagem não muda mais nada: sem reescrever as variáveis CSS
|
||||||
|
if (p === progressRef.current) return;
|
||||||
progressRef.current = p;
|
progressRef.current = p;
|
||||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||||
|
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
|
||||||
|
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
|
||||||
setDone(p >= 0.999);
|
setDone(p >= 0.999);
|
||||||
};
|
};
|
||||||
const queue = () => {
|
const queue = () => {
|
||||||
@@ -82,7 +87,7 @@ export default function HeroField() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
if (isLowPowerDevice()) return;
|
||||||
if (!supportsWebGL2()) return;
|
if (!supportsWebGL2()) return;
|
||||||
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
||||||
if (typeof window.requestIdleCallback === "function") {
|
if (typeof window.requestIdleCallback === "function") {
|
||||||
@@ -108,26 +113,11 @@ export default function HeroField() {
|
|||||||
{mount && !failed && (
|
{mount && !failed && (
|
||||||
<Scene
|
<Scene
|
||||||
progressRef={progressRef}
|
progressRef={progressRef}
|
||||||
labelsRef={labelsRef}
|
|
||||||
visible={visible && !done}
|
visible={visible && !done}
|
||||||
onReady={handleReady}
|
onReady={handleReady}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mount &&
|
|
||||||
!failed &&
|
|
||||||
TAGS.map(([title, text], index) => (
|
|
||||||
<span
|
|
||||||
key={title}
|
|
||||||
ref={(element) => {
|
|
||||||
labelsRef.current[index] = element;
|
|
||||||
}}
|
|
||||||
className="hero-tag"
|
|
||||||
>
|
|
||||||
<span className="hero-tag-title">{title}</span>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import HeroFieldScene from "./HeroFieldScene";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
|
||||||
|
* - fim antecipado sem grupo, onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente);
|
||||||
|
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
||||||
|
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
||||||
|
* - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
|
||||||
|
* - perda do contexto WebGL, limpeza e a falha de um filho (SceneBoundary).
|
||||||
|
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega o callback do useFrame; o `three`
|
||||||
|
* é o de verdade, com ShaderMaterial e BufferGeometry registrando as instâncias criadas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type FrameState = {
|
||||||
|
clock: { elapsedTime: number };
|
||||||
|
invalidate: () => void;
|
||||||
|
};
|
||||||
|
const mockState: {
|
||||||
|
frame: ((state: FrameState, delta: number) => void) | null;
|
||||||
|
three: unknown;
|
||||||
|
throwOnUseThree: boolean;
|
||||||
|
} = { frame: null, three: null, throwOnUseThree: false };
|
||||||
|
const mockMaterials: THREE.ShaderMaterial[] = [];
|
||||||
|
const mockGeometries: THREE.BufferGeometry[] = [];
|
||||||
|
|
||||||
|
jest.mock("three", () => {
|
||||||
|
const actual = jest.requireActual("three");
|
||||||
|
class CapturingMaterial extends actual.ShaderMaterial {
|
||||||
|
constructor(parameters: unknown) {
|
||||||
|
super(parameters);
|
||||||
|
mockMaterials.push(this as unknown as THREE.ShaderMaterial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class CapturingGeometry extends actual.BufferGeometry {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ShaderMaterial: CapturingMaterial,
|
||||||
|
BufferGeometry: CapturingGeometry,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
jest.mock("@react-three/fiber", () => ({
|
||||||
|
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
|
useFrame: (callback: (state: FrameState, delta: number) => void) => {
|
||||||
|
mockState.frame = callback;
|
||||||
|
},
|
||||||
|
useThree: () => {
|
||||||
|
if (mockState.throwOnUseThree) throw new Error("sem WebGL");
|
||||||
|
return mockState.three;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("./FramePulse", () => ({ __esModule: true, default: () => null }));
|
||||||
|
|
||||||
|
type Setup = {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
/** `null` simula uma ref sem valor (o código trata como 0). */
|
||||||
|
progress?: number | null;
|
||||||
|
visible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup({
|
||||||
|
width = 1200,
|
||||||
|
height = 800,
|
||||||
|
progress = 0,
|
||||||
|
visible = true,
|
||||||
|
}: Setup = {}) {
|
||||||
|
const domElement = document.createElement("canvas");
|
||||||
|
const camera = new THREE.PerspectiveCamera(35, width / height, 0.05, 120);
|
||||||
|
camera.position.set(0, 0, 9);
|
||||||
|
camera.updateMatrixWorld();
|
||||||
|
mockState.three = {
|
||||||
|
gl: { domElement, getPixelRatio: () => 2 },
|
||||||
|
camera,
|
||||||
|
size: { width, height },
|
||||||
|
};
|
||||||
|
const progressRef = {
|
||||||
|
current: progress === null ? undefined : progress,
|
||||||
|
} as { current: number };
|
||||||
|
const onReady = jest.fn();
|
||||||
|
const onError = jest.fn();
|
||||||
|
const element = (isVisible: boolean) => (
|
||||||
|
<HeroFieldScene
|
||||||
|
progressRef={progressRef}
|
||||||
|
visible={isVisible}
|
||||||
|
onReady={onReady}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const view = render(element(visible));
|
||||||
|
|
||||||
|
// <group> é um elemento desconhecido no jsdom: ganha o que o três espera de um THREE.Group
|
||||||
|
const group = view.container.querySelector("group") as HTMLElement | null;
|
||||||
|
const real = new THREE.Group();
|
||||||
|
if (group)
|
||||||
|
Object.assign(group, {
|
||||||
|
rotation: real.rotation,
|
||||||
|
position: real.position,
|
||||||
|
matrixWorld: real.matrixWorld,
|
||||||
|
updateMatrixWorld: () => real.updateMatrixWorld(true),
|
||||||
|
worldToLocal: (point: THREE.Vector3) => point,
|
||||||
|
});
|
||||||
|
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
||||||
|
|
||||||
|
function frame(time: number, delta = 1 / 60) {
|
||||||
|
const invalidate = jest.fn();
|
||||||
|
act(() => {
|
||||||
|
mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
|
||||||
|
});
|
||||||
|
return invalidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
||||||
|
function settle(count: number, delta = 1 / 20, startTime = 100) {
|
||||||
|
let last = jest.fn();
|
||||||
|
for (let i = 0; i < count; i++) last = frame(startTime + i * delta, delta);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...view,
|
||||||
|
camera,
|
||||||
|
domElement,
|
||||||
|
progressRef,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
group: real,
|
||||||
|
uniforms,
|
||||||
|
frame,
|
||||||
|
settle,
|
||||||
|
rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalHardware = Object.getOwnPropertyDescriptor(
|
||||||
|
navigator,
|
||||||
|
"hardwareConcurrency",
|
||||||
|
);
|
||||||
|
|
||||||
|
function setCores(value: number | undefined) {
|
||||||
|
Object.defineProperty(navigator, "hardwareConcurrency", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWindowWidth(value: number) {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerMove(type: string, x = 600, y = 400) {
|
||||||
|
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
||||||
|
Object.defineProperty(event, "pointerType", { value: type });
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMaterials.length = 0;
|
||||||
|
mockGeometries.length = 0;
|
||||||
|
mockState.frame = null;
|
||||||
|
mockState.throwOnUseThree = false;
|
||||||
|
setCores(8);
|
||||||
|
setWindowWidth(1200);
|
||||||
|
// as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
if (originalHardware)
|
||||||
|
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||||
|
it("path: the group is gone (unmounted) -> the callback returns at once, nothing is requested", () => {
|
||||||
|
const scene = setup();
|
||||||
|
const callback = mockState.frame;
|
||||||
|
scene.unmount();
|
||||||
|
const invalidate = jest.fn();
|
||||||
|
|
||||||
|
callback?.({ clock: { elapsedTime: 1 }, invalidate }, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(scene.onReady).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: onReady fires on the second frame, once", () => {
|
||||||
|
const scene = setup();
|
||||||
|
|
||||||
|
scene.frame(0);
|
||||||
|
expect(scene.onReady).not.toHaveBeenCalled();
|
||||||
|
scene.frame(0.02);
|
||||||
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
|
scene.frame(0.04);
|
||||||
|
scene.frame(0.06);
|
||||||
|
|
||||||
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
||||||
|
const scene = setup({ progress: 1 });
|
||||||
|
|
||||||
|
scene.frame(0, 5); // delta enorme: o passo é limitado a 1/20 de segundo
|
||||||
|
|
||||||
|
const expected = 1 - Math.exp(-(1 / 20) * 5);
|
||||||
|
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
||||||
|
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no progress available (undefined) -> treated as the top of the page", () => {
|
||||||
|
const scene = setup({ progress: null });
|
||||||
|
|
||||||
|
scene.settle(5);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uScroll.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the intro advances every frame and stops at 1", () => {
|
||||||
|
const scene = setup();
|
||||||
|
|
||||||
|
scene.frame(0, 1 / 20);
|
||||||
|
expect(scene.uniforms().uIntro.value).toBeCloseTo(0.05 / 2.6, 9);
|
||||||
|
|
||||||
|
scene.settle(80);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uIntro.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the scene fades out only at the very end of the dive", () => {
|
||||||
|
const scene = setup({ progress: 0 });
|
||||||
|
scene.settle(10);
|
||||||
|
expect(scene.uniforms().uFade.value).toBe(1);
|
||||||
|
|
||||||
|
scene.progressRef.current = 1;
|
||||||
|
scene.settle(120);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uFade.value).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: layers spread apart only while they face the viewer", () => {
|
||||||
|
const scene = setup({ progress: 0 });
|
||||||
|
scene.settle(5);
|
||||||
|
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
|
||||||
|
|
||||||
|
scene.progressRef.current = 0.5;
|
||||||
|
scene.settle(150);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Field: enquadramento e foco", () => {
|
||||||
|
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
||||||
|
const scene = setup({ width: 1200, height: 800 });
|
||||||
|
|
||||||
|
scene.frame(0);
|
||||||
|
|
||||||
|
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
||||||
|
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
||||||
|
const scene = setup({ width: 400, height: 800 });
|
||||||
|
|
||||||
|
scene.frame(0);
|
||||||
|
|
||||||
|
expect(scene.group.position.y).toBeLessThan(-0.5);
|
||||||
|
expect(scene.camera.position.z).toBeGreaterThan(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: end of the dive -> the group has settled back to the center and the camera went through the layers", () => {
|
||||||
|
const scene = setup({ progress: 1 });
|
||||||
|
|
||||||
|
scene.settle(200);
|
||||||
|
|
||||||
|
expect(scene.group.position.y).toBeCloseTo(0, 6);
|
||||||
|
expect(scene.camera.position.z).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||||
|
const scene = setup({ progress: 0 });
|
||||||
|
|
||||||
|
scene.frame(0);
|
||||||
|
|
||||||
|
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
||||||
|
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: camera past every layer -> the focus never goes below 1.5", () => {
|
||||||
|
const scene = setup({ progress: 1 });
|
||||||
|
|
||||||
|
scene.settle(200);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uFocus.value).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Field: mouse", () => {
|
||||||
|
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
||||||
|
const scene = setup();
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||||
|
pointerMove("touch");
|
||||||
|
pointerMove("pen");
|
||||||
|
|
||||||
|
scene.frame(5.5);
|
||||||
|
|
||||||
|
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
||||||
|
scene.settle(120, 1 / 20, 5.5);
|
||||||
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
||||||
|
const scene = setup();
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
||||||
|
const scene = setup();
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
||||||
|
const scene = setup();
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(0);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
||||||
|
const scene = setup({ progress: 1 });
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
||||||
|
const scene = setup();
|
||||||
|
jest
|
||||||
|
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
||||||
|
.mockImplementation(((_plane: THREE.Plane, target: THREE.Vector3) =>
|
||||||
|
target.set(1, 0, 2)) as never);
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
scene.frame(5.1, 1 / 20);
|
||||||
|
|
||||||
|
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
||||||
|
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(factor * 1, 6);
|
||||||
|
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(factor * 2, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
||||||
|
const scene = setup();
|
||||||
|
jest
|
||||||
|
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
||||||
|
.mockReturnValue(null as never);
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
scene.frame(5.1, 1 / 20);
|
||||||
|
|
||||||
|
expect(scene.uniforms().uMouse.value.x).toBe(0);
|
||||||
|
expect(scene.uniforms().uMouse.value.y).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
||||||
|
const scene = setup();
|
||||||
|
|
||||||
|
scene.frame(10, 1 / 20);
|
||||||
|
|
||||||
|
const { x, y } = scene.uniforms().uMouse.value;
|
||||||
|
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Field: quando pedir o próximo quadro", () => {
|
||||||
|
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
||||||
|
const scene = setup({ visible: false });
|
||||||
|
|
||||||
|
const invalidate = scene.frame(0, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
||||||
|
const scene = setup({ visible: true });
|
||||||
|
scene.settle(200);
|
||||||
|
|
||||||
|
const invalidate = scene.frame(500, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: everything settled and the hero is not visible -> no frame", () => {
|
||||||
|
const scene = setup({ visible: false });
|
||||||
|
scene.settle(200);
|
||||||
|
|
||||||
|
const invalidate = scene.frame(500, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
||||||
|
const scene = setup({ visible: true });
|
||||||
|
scene.settle(200);
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
const invalidate = scene.frame(600.1, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||||
|
const scene = setup({ visible: false });
|
||||||
|
scene.settle(200);
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
|
const invalidate = scene.frame(600.1, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||||
|
const scene = setup({ visible: false });
|
||||||
|
scene.settle(200);
|
||||||
|
scene.progressRef.current = 0.5;
|
||||||
|
|
||||||
|
const invalidate = scene.frame(500, 1 / 60);
|
||||||
|
|
||||||
|
expect(invalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: becoming visible later -> the new callback sees the new flag", () => {
|
||||||
|
const scene = setup({ visible: false });
|
||||||
|
scene.settle(200);
|
||||||
|
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||||
|
pointerMove("mouse");
|
||||||
|
expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
scene.rerender(true);
|
||||||
|
|
||||||
|
expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||||
|
const pointCount = () => {
|
||||||
|
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||||
|
return geometry.getAttribute("position").count;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("path: wide window and 4+ cores -> the dense field", () => {
|
||||||
|
setWindowWidth(1200);
|
||||||
|
setCores(8);
|
||||||
|
setup();
|
||||||
|
const dense = pointCount();
|
||||||
|
|
||||||
|
mockGeometries.length = 0;
|
||||||
|
setWindowWidth(600);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
expect(dense).toBeGreaterThan(pointCount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: wide window but 2 cores -> the light field", () => {
|
||||||
|
setWindowWidth(1200);
|
||||||
|
setCores(2);
|
||||||
|
setup();
|
||||||
|
const light = pointCount();
|
||||||
|
|
||||||
|
mockGeometries.length = 0;
|
||||||
|
setCores(8);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
expect(pointCount()).toBeGreaterThan(light);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
||||||
|
setWindowWidth(1200);
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const [label, cores] of [
|
||||||
|
["dense", 8],
|
||||||
|
["light", 2],
|
||||||
|
["unknown", undefined],
|
||||||
|
] as const) {
|
||||||
|
mockGeometries.length = 0;
|
||||||
|
setCores(cores);
|
||||||
|
setup();
|
||||||
|
counts[label] = pointCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
|
||||||
|
Math.abs(counts.unknown - counts.light),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
|
||||||
|
const scene = setup();
|
||||||
|
const event = new Event("webglcontextlost", { cancelable: true });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scene.domElement.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> geometry and material are disposed and the listeners go away", () => {
|
||||||
|
const scene = setup();
|
||||||
|
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||||
|
const material = mockMaterials[mockMaterials.length - 1];
|
||||||
|
const disposeGeometry = jest.spyOn(geometry, "dispose");
|
||||||
|
const disposeMaterial = jest.spyOn(material, "dispose");
|
||||||
|
const event = new Event("webglcontextlost", { cancelable: true });
|
||||||
|
|
||||||
|
scene.unmount();
|
||||||
|
scene.domElement.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
expect(scene.onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SceneBoundary", () => {
|
||||||
|
it("path: a child fails while drawing -> nothing is rendered and the page is told once", () => {
|
||||||
|
mockState.throwOnUseThree = true;
|
||||||
|
const onError = jest.fn();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<HeroFieldScene
|
||||||
|
progressRef={{ current: 0 }}
|
||||||
|
visible
|
||||||
|
onReady={() => {}}
|
||||||
|
onError={onError}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(onError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no failure -> the scene is rendered and onError is not called", () => {
|
||||||
|
const scene = setup();
|
||||||
|
|
||||||
|
expect(scene.container.querySelector("group")).not.toBeNull();
|
||||||
|
expect(scene.onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import HeroFieldScene from "./HeroFieldScene";
|
||||||
|
|
||||||
|
type CanvasProps = {
|
||||||
|
dpr?: [number, number];
|
||||||
|
frameloop?: string;
|
||||||
|
gl?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const mockCanvas: CanvasProps[] = [];
|
||||||
|
|
||||||
|
jest.mock("@react-three/fiber", () => ({
|
||||||
|
Canvas: (props: CanvasProps) => {
|
||||||
|
mockCanvas.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
useFrame: jest.fn(),
|
||||||
|
useThree: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HeroFieldScene (ambient 3D)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCanvas.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws on demand, never on a fixed 60 fps loop, and caps the pixel ratio at 1.5", () => {
|
||||||
|
render(
|
||||||
|
<HeroFieldScene
|
||||||
|
progressRef={{ current: 0 }}
|
||||||
|
visible
|
||||||
|
onReady={() => {}}
|
||||||
|
onError={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const props = mockCanvas.at(-1);
|
||||||
|
expect(props?.frameloop).toBe("demand");
|
||||||
|
expect(props?.dpr).toEqual([1, 1.5]);
|
||||||
|
expect(props?.gl).toMatchObject({ alpha: true, antialias: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => {
|
||||||
|
render(
|
||||||
|
<HeroFieldScene
|
||||||
|
progressRef={{ current: 0 }}
|
||||||
|
visible={false}
|
||||||
|
onReady={() => {}}
|
||||||
|
onError={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(mockCanvas.at(-1)?.frameloop).toBe("demand");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,10 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import FramePulse from "./FramePulse";
|
||||||
|
|
||||||
export type HeroFieldSceneProps = {
|
export type HeroFieldSceneProps = {
|
||||||
progressRef: RefObject<number>;
|
progressRef: RefObject<number>;
|
||||||
/** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
|
|
||||||
labelsRef: RefObject<(HTMLElement | null)[]>;
|
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
@@ -26,8 +25,12 @@ export type HeroFieldSceneProps = {
|
|||||||
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
|
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
|
||||||
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
|
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
|
||||||
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
|
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
|
||||||
* frente e a câmera atravessa uma a uma (as legendas de HeroField explicam cada camada nessa hora).
|
* frente e a câmera atravessa uma a uma (as legendas de Hero explicam cada camada nessa hora).
|
||||||
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
|
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
|
||||||
|
*
|
||||||
|
* É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
|
||||||
|
* molduras da direita. O desenho é sob demanda (frameloop "demand"): a cena pede um quadro a 30 por segundo
|
||||||
|
* e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, não desenha nada.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||||
@@ -389,16 +392,9 @@ function cameraFrames(rest: number, spread: number, frame: number) {
|
|||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
|
|
||||||
const LABEL_ANCHORS: [number, Layer, number][] = [
|
|
||||||
[2.1, 1, 1.4],
|
|
||||||
[1.95, 0, 0.9],
|
|
||||||
[2, -1, 1.3],
|
|
||||||
];
|
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
progressRef,
|
progressRef,
|
||||||
labelsRef,
|
visible,
|
||||||
onReady,
|
onReady,
|
||||||
onError,
|
onError,
|
||||||
}: HeroFieldSceneProps) {
|
}: HeroFieldSceneProps) {
|
||||||
@@ -444,7 +440,6 @@ function Field({
|
|||||||
ray: new THREE.Raycaster(),
|
ray: new THREE.Raycaster(),
|
||||||
plane: new THREE.Plane(),
|
plane: new THREE.Plane(),
|
||||||
hit: new THREE.Vector3(),
|
hit: new THREE.Vector3(),
|
||||||
anchor: new THREE.Vector3(),
|
|
||||||
zero: new THREE.Vector2(),
|
zero: new THREE.Vector2(),
|
||||||
target: new THREE.Vector2(),
|
target: new THREE.Vector2(),
|
||||||
}),
|
}),
|
||||||
@@ -521,12 +516,9 @@ function Field({
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const settle = 1 - smooth(0, 0.28, p);
|
const settle = 1 - smooth(0, 0.28, p);
|
||||||
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
|
// em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras
|
||||||
g.position.set(
|
// da direita); no celular, mais abaixo, atrás dos botões
|
||||||
wide ? 1.7 * settle : 0,
|
g.position.set(0, (wide ? -0.5 : -rest * tanHalf * 0.38) * settle, 0);
|
||||||
(wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
|
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
|
||||||
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
||||||
const spread = u.uSpread.value;
|
const spread = u.uSpread.value;
|
||||||
@@ -561,23 +553,19 @@ function Field({
|
|||||||
u.uMouseAmt.value +=
|
u.uMouseAmt.value +=
|
||||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||||
|
|
||||||
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
|
|
||||||
const labels = labelsRef.current ?? [];
|
|
||||||
const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
|
|
||||||
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
|
|
||||||
const label = labels[index];
|
|
||||||
if (!label) return;
|
|
||||||
helpers.anchor
|
|
||||||
.set(x, layer * u.uSpread.value, z)
|
|
||||||
.applyMatrix4(g.matrixWorld)
|
|
||||||
.project(camera);
|
|
||||||
const px = ((helpers.anchor.x + 1) / 2) * size.width;
|
|
||||||
const py = ((1 - helpers.anchor.y) / 2) * size.height;
|
|
||||||
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
|
|
||||||
label.style.opacity = shown.toFixed(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (++s.frames === 2) onReady();
|
if (++s.frames === 2) onReady();
|
||||||
|
|
||||||
|
// sob demanda: o próximo quadro vem do FramePulse (30 por segundo); a 60 só enquanto algo ainda muda.
|
||||||
|
// A entrada e a rolagem terminam de assentar mesmo depois de o hero sair de cena (o último quadro tem de
|
||||||
|
// ser o final, com tudo já apagado); o cursor só conta com o hero à vista.
|
||||||
|
const settling =
|
||||||
|
u.uIntro.value < 1 ||
|
||||||
|
Math.abs((progressRef.current ?? 0) - s.progress) > 4e-4;
|
||||||
|
const interacting =
|
||||||
|
mouseActive === 1 ||
|
||||||
|
Math.abs(targetAmt - u.uMouseAmt.value) > 0.01 ||
|
||||||
|
s.frames < 3;
|
||||||
|
if (settling || (visible && interacting)) frame.invalidate();
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -610,14 +598,15 @@ export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
||||||
dpr={[1, 1.75]}
|
dpr={[1, 1.5]}
|
||||||
frameloop={props.visible ? "always" : "never"}
|
frameloop="demand"
|
||||||
gl={{
|
gl={{
|
||||||
alpha: true,
|
alpha: true,
|
||||||
antialias: false,
|
antialias: false,
|
||||||
powerPreference: "high-performance",
|
powerPreference: "high-performance",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<FramePulse active={props.visible} />
|
||||||
<Field {...props} />
|
<Field {...props} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</SceneBoundary>
|
</SceneBoundary>
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import HeroScene from "./HeroScene";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de HeroScene (fundo 3D das páginas internas): quando o desenho está ligado
|
||||||
|
* (visível E à vista), a câmera que segue o mouse (CameraRig) e o relógio do chão em grade (GridFloor).
|
||||||
|
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega os callbacks do useFrame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockFrames: Array<(state: unknown, delta: number) => void> = [];
|
||||||
|
const mockCamera = {
|
||||||
|
position: { x: 0, y: 0.5, z: 8 },
|
||||||
|
lookAt: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockPulse = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@react-three/fiber", () => ({
|
||||||
|
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
|
useFrame: (callback: (state: unknown, delta: number) => void) => {
|
||||||
|
mockFrames.push(callback);
|
||||||
|
},
|
||||||
|
useThree: () => ({ camera: mockCamera }),
|
||||||
|
}));
|
||||||
|
jest.mock("./FramePulse", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: { active: boolean }) => {
|
||||||
|
mockPulse(props.active);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Observer = {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
disconnect: jest.Mock;
|
||||||
|
};
|
||||||
|
let observers: Observer[] = [];
|
||||||
|
const OriginalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFrames.length = 0;
|
||||||
|
mockPulse.mockClear();
|
||||||
|
mockCamera.position = { x: 0, y: 0.5, z: 8 };
|
||||||
|
mockCamera.lookAt.mockClear();
|
||||||
|
observers = [];
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
private readonly record: Observer;
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.record = { callback, disconnect: jest.fn() };
|
||||||
|
observers.push(this.record);
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {
|
||||||
|
this.record.disconnect();
|
||||||
|
}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
// as tags do R3F (fog, mesh, ...) são elementos desconhecidos no DOM do jsdom
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.IntersectionObserver = OriginalObserver;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastPulse = () =>
|
||||||
|
mockPulse.mock.calls[mockPulse.mock.calls.length - 1][0];
|
||||||
|
|
||||||
|
function report(isIntersecting: boolean) {
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[{ isIntersecting } as IntersectionObserverEntry],
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HeroScene: quando desenha", () => {
|
||||||
|
it("path: visible by prop and in view (default) -> the pulse is on", () => {
|
||||||
|
render(<HeroScene />);
|
||||||
|
|
||||||
|
expect(lastPulse()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: isVisible=false -> the pulse is off even though the scene is in view", () => {
|
||||||
|
render(<HeroScene isVisible={false} />);
|
||||||
|
|
||||||
|
expect(lastPulse()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scrolled out of view -> off; back in view -> on", () => {
|
||||||
|
render(<HeroScene />);
|
||||||
|
|
||||||
|
report(false);
|
||||||
|
expect(lastPulse()).toBe(false);
|
||||||
|
|
||||||
|
report(true);
|
||||||
|
expect(lastPulse()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: out of view and isVisible=false -> off", () => {
|
||||||
|
render(<HeroScene isVisible={false} />);
|
||||||
|
|
||||||
|
report(false);
|
||||||
|
|
||||||
|
expect(lastPulse()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the observer is disconnected", () => {
|
||||||
|
const view = render(<HeroScene />);
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroScene: câmera que segue o mouse", () => {
|
||||||
|
// a cada renderização CameraRig e GridFloor chamam useFrame, nessa ordem: valem os dois últimos
|
||||||
|
const rigFrame = () => mockFrames[mockFrames.length - 2];
|
||||||
|
|
||||||
|
it("path: mouse at the center -> the camera eases toward the resting height and looks at the origin", () => {
|
||||||
|
render(<HeroScene />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rigFrame()({}, 0.016);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCamera.position.x).toBeCloseTo(0, 9);
|
||||||
|
expect(mockCamera.position.y).toBeCloseTo(0.5, 9);
|
||||||
|
expect(mockCamera.lookAt).toHaveBeenCalledWith(0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: mouse moved to the top-right corner -> the camera drifts right and up, a little per frame", () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: 1000,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 800,
|
||||||
|
});
|
||||||
|
render(<HeroScene />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MouseEvent("mousemove", { clientX: 1000, clientY: 0 }),
|
||||||
|
);
|
||||||
|
rigFrame()({}, 0.016);
|
||||||
|
});
|
||||||
|
|
||||||
|
// alvo x = 1 * 0.5; alvo y = 1 * 0.3 + 0.5; passo de 2% por quadro
|
||||||
|
expect(mockCamera.position.x).toBeCloseTo(0.5 * 0.02, 9);
|
||||||
|
expect(mockCamera.position.y).toBeCloseTo(0.5 + (0.8 - 0.5) * 0.02, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the mouse listener is removed", () => {
|
||||||
|
const remove = jest.spyOn(window, "removeEventListener");
|
||||||
|
const view = render(<HeroScene />);
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(remove.mock.calls.map(([type]) => type)).toContain("mousemove");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HeroScene: relógio do chão em grade", () => {
|
||||||
|
const gridFrame = () => mockFrames[mockFrames.length - 1];
|
||||||
|
|
||||||
|
function attachMaterial(material: unknown) {
|
||||||
|
const mesh = document.querySelector("mesh") as HTMLElement & {
|
||||||
|
material?: unknown;
|
||||||
|
};
|
||||||
|
mesh.material = material;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("path: the mesh has shader uniforms -> uTime follows the clock", () => {
|
||||||
|
render(<HeroScene />);
|
||||||
|
const uniforms = { uTime: { value: 0 } };
|
||||||
|
attachMaterial({ uniforms });
|
||||||
|
|
||||||
|
gridFrame()({ clock: { elapsedTime: 12.5 } }, 0.016);
|
||||||
|
|
||||||
|
expect(uniforms.uTime.value).toBe(12.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the material has no uniforms yet -> nothing happens", () => {
|
||||||
|
render(<HeroScene />);
|
||||||
|
attachMaterial({});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
gridFrame()({ clock: { elapsedTime: 3 } }, 0.016),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the mesh is gone (unmounted) -> the frame callback does nothing", () => {
|
||||||
|
const view = render(<HeroScene />);
|
||||||
|
const frame = gridFrame();
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(() => frame({ clock: { elapsedTime: 3 } }, 0.016)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import FramePulse from "./FramePulse";
|
||||||
|
|
||||||
function GridFloor() {
|
function GridFloor() {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
@@ -95,20 +96,36 @@ function CameraRig() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fundo ambiente das páginas internas: desenha sob demanda, a 30 quadros por segundo e só à vista. */
|
||||||
export default function HeroScene({
|
export default function HeroScene({
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
}: {
|
}: {
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [inView, setInView] = useState(true);
|
||||||
|
|
||||||
|
// fora da tela não desenha nada
|
||||||
|
useEffect(() => {
|
||||||
|
const element = rootRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
const observer = new IntersectionObserver(([entry]) =>
|
||||||
|
setInView(entry.isIntersecting),
|
||||||
|
);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
|
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
|
||||||
>
|
>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
frameloop={isVisible ? "always" : "never"}
|
frameloop="demand"
|
||||||
gl={{
|
gl={{
|
||||||
antialias: false,
|
antialias: false,
|
||||||
alpha: true,
|
alpha: true,
|
||||||
@@ -133,6 +150,7 @@ export default function HeroScene({
|
|||||||
/>
|
/>
|
||||||
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||||
|
|
||||||
|
<FramePulse active={isVisible && inView} />
|
||||||
<CameraRig />
|
<CameraRig />
|
||||||
<GridFloor />
|
<GridFloor />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|||||||
@@ -0,0 +1,720 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx):
|
||||||
|
* - onClick (âncoras): cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena
|
||||||
|
* x fora de qualquer cena, desconto do cabeçalho, pushState só quando a âncora muda, foco no alvo;
|
||||||
|
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos;
|
||||||
|
* - criação do Lenis (opções, ligação com o ScrollTrigger), relógio que dorme e acorda, desmontagem antes das
|
||||||
|
* importações terminarem, ref vazia, e o ResizeObserver que remede as passagens com atraso.
|
||||||
|
* GSAP, ScrollTrigger e Lenis são trocados por fakes que registram o que recebem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Call = unknown[];
|
||||||
|
const mockTimelines: Array<{ calls: Call[] }> = [];
|
||||||
|
const mockTicker = {
|
||||||
|
add: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
lagSmoothing: jest.fn(),
|
||||||
|
time: 0,
|
||||||
|
};
|
||||||
|
const mockLenis: MockLenis[] = [];
|
||||||
|
|
||||||
|
class MockLenis {
|
||||||
|
isScrolling: false | "smooth" = false;
|
||||||
|
on = jest.fn();
|
||||||
|
raf = jest.fn();
|
||||||
|
scrollTo = jest.fn();
|
||||||
|
destroy = jest.fn();
|
||||||
|
constructor(public options: unknown) {
|
||||||
|
mockLenis.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock("gsap", () => ({
|
||||||
|
gsap: {
|
||||||
|
registerPlugin: jest.fn(),
|
||||||
|
ticker: mockTicker,
|
||||||
|
context: (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
return { revert: jest.fn() };
|
||||||
|
},
|
||||||
|
timeline: () => {
|
||||||
|
const record = { calls: [] as Call[] };
|
||||||
|
mockTimelines.push(record);
|
||||||
|
const chain = {
|
||||||
|
fromTo: (...args: Call) => {
|
||||||
|
record.calls.push(args);
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("gsap/ScrollTrigger", () => ({
|
||||||
|
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.mock("lenis", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
get default() {
|
||||||
|
return MockLenis;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TOPS: Record<string, number> = {
|
||||||
|
home: 0,
|
||||||
|
projects: 1800,
|
||||||
|
"projects-title": 1900,
|
||||||
|
edge: 2400,
|
||||||
|
contact: 25000,
|
||||||
|
"footer-note": 26000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Home({ attach = true }: { attach?: boolean }) {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
useCinematicScroll(ref);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main ref={attach ? ref : undefined}>
|
||||||
|
<section id="home" data-top={TOPS.home}>
|
||||||
|
<a href="/#contact">contato</a>
|
||||||
|
<a href="/#projects">projetos</a>
|
||||||
|
<a href="/#projects-title">titulo dos projetos</a>
|
||||||
|
<a href="/#edge">limite</a>
|
||||||
|
<a href="/#footer-note">nota fora da cena</a>
|
||||||
|
<a href="/#nao-existe">fantasma</a>
|
||||||
|
<a href="/#%E0%A4%A">malformado</a>
|
||||||
|
<a href="/outra#contact">outra pagina</a>
|
||||||
|
<a href="https://example.com/#contact">outro site</a>
|
||||||
|
<a href="/">sem ancora</a>
|
||||||
|
<a href="/#">ancora vazia</a>
|
||||||
|
<a href="/#contact" target="_blank" rel="noopener">
|
||||||
|
nova aba
|
||||||
|
</a>
|
||||||
|
<a href="/#contact" target="_self">
|
||||||
|
mesma aba
|
||||||
|
</a>
|
||||||
|
<svg aria-hidden="true">
|
||||||
|
<a href="/#contact">
|
||||||
|
<text>svg</text>
|
||||||
|
</a>
|
||||||
|
</svg>
|
||||||
|
<p id="plain">texto</p>
|
||||||
|
</section>
|
||||||
|
<section id="projects" data-top={TOPS.projects}>
|
||||||
|
<h2 id="projects-title" data-top={TOPS["projects-title"]}>
|
||||||
|
Projetos
|
||||||
|
</h2>
|
||||||
|
</section>
|
||||||
|
<section id="edge" data-top={TOPS.edge}>
|
||||||
|
<h2>Limite</h2>
|
||||||
|
</section>
|
||||||
|
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: alvo de âncora que já traz tabindex, para testar que ele é mantido */}
|
||||||
|
<section id="contact" data-top={TOPS.contact} tabIndex={0}>
|
||||||
|
<h2>Contato</h2>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<p id="footer-note" data-top={TOPS["footer-note"]}>
|
||||||
|
Nota
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
const OriginalResizeObserver = global.ResizeObserver;
|
||||||
|
const originalOffsetTop = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetTop",
|
||||||
|
);
|
||||||
|
|
||||||
|
function mockMedia(active: string[]) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: active.includes(query),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushImports() {
|
||||||
|
await act(async () => {
|
||||||
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mount(props: { attach?: boolean } = {}) {
|
||||||
|
const view = render(<Home {...props} />);
|
||||||
|
await flushImports();
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function link(text: string) {
|
||||||
|
const found = [...document.querySelectorAll("a, svg a")].find(
|
||||||
|
(element) => element.textContent === text,
|
||||||
|
);
|
||||||
|
if (!found) throw new Error(`Missing link ${text}`);
|
||||||
|
return found as Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispara um clique e diz se o hook cancelou a ação padrão. A ação padrão do navegador (navegar) é cancelada
|
||||||
|
* por um ouvinte na fase de bolha, depois de o hook ter decidido, para o jsdom não navegar de verdade.
|
||||||
|
*/
|
||||||
|
function click(
|
||||||
|
target: Element | Document,
|
||||||
|
init: MouseEventInit = {},
|
||||||
|
): { prevented: boolean } {
|
||||||
|
const event = new MouseEvent("click", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
button: 0,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
let prevented = false;
|
||||||
|
const observe = (clickEvent: Event) => {
|
||||||
|
prevented = clickEvent.defaultPrevented;
|
||||||
|
clickEvent.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener("click", observe);
|
||||||
|
try {
|
||||||
|
act(() => {
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener("click", observe);
|
||||||
|
}
|
||||||
|
return { prevented };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockHeaderOffset(px: string) {
|
||||||
|
jest
|
||||||
|
.spyOn(window, "getComputedStyle")
|
||||||
|
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return Number(this.dataset.top ?? 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalOffsetTop)
|
||||||
|
Object.defineProperty(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetTop",
|
||||||
|
originalOffsetTop,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTimelines.length = 0;
|
||||||
|
mockLenis.length = 0;
|
||||||
|
mockTicker.add.mockClear();
|
||||||
|
mockTicker.remove.mockClear();
|
||||||
|
mockTicker.lagSmoothing.mockClear();
|
||||||
|
mockTicker.time = 0;
|
||||||
|
jest.mocked(ScrollTrigger.update).mockClear();
|
||||||
|
jest.mocked(ScrollTrigger.refresh).mockClear();
|
||||||
|
mockMedia([]);
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 800,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
global.ResizeObserver = OriginalResizeObserver;
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: cliques que voltam ao navegador", () => {
|
||||||
|
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
|
||||||
|
"path: click with %s held -> left alone (new tab / selection behavior)",
|
||||||
|
async (modifier) => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("contato"), { [modifier]: true });
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([1, 2])(
|
||||||
|
"path: click with mouse button %i -> left alone",
|
||||||
|
async (button) => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("contato"), { button });
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: another handler already cancelled the click -> not handled again", async () => {
|
||||||
|
await mount();
|
||||||
|
const cancel = (event: Event) => event.preventDefault();
|
||||||
|
window.addEventListener("click", cancel, true);
|
||||||
|
try {
|
||||||
|
click(link("contato"));
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener("click", cancel, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click on text that is not inside a link -> ignored", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(document.getElementById("plain") as Element);
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the click target is the document itself (not an element) -> ignored", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(document);
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the closest link is an SVG anchor -> ignored", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(
|
||||||
|
document.querySelector("svg a text") as Element,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: link opens in a new tab -> ignored", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("nova aba"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: link with target=_self -> handled like a plain link", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("mesma aba"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(true);
|
||||||
|
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["to another site", "outro site"],
|
||||||
|
["to another page of this site", "outra pagina"],
|
||||||
|
["without an anchor", "sem ancora"],
|
||||||
|
["with an empty anchor (#)", "ancora vazia"],
|
||||||
|
])("path: link %s -> ignored", async (_label, text) => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link(text));
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: anchor with broken percent-encoding -> no error, left to the browser", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("malformado"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: anchor to an id that does not exist -> left to the browser", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("fantasma"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: para onde a página vai", () => {
|
||||||
|
it("path: target inside a scene -> goes to the target minus the fixed header, never above the scene top", async () => {
|
||||||
|
await mount();
|
||||||
|
mockHeaderOffset("80px");
|
||||||
|
|
||||||
|
click(link("titulo dos projetos"));
|
||||||
|
|
||||||
|
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900 - 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: header taller than the gap to the scene top -> clamped to the scene top", async () => {
|
||||||
|
await mount();
|
||||||
|
mockHeaderOffset("300px");
|
||||||
|
|
||||||
|
click(link("titulo dos projetos"));
|
||||||
|
|
||||||
|
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: target is the whole scene -> the scene top, ignoring the header offset", async () => {
|
||||||
|
await mount();
|
||||||
|
mockHeaderOffset("80px");
|
||||||
|
|
||||||
|
click(link("projetos"));
|
||||||
|
|
||||||
|
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: target outside every scene -> measured from the page top, minus the header", async () => {
|
||||||
|
await mount();
|
||||||
|
mockHeaderOffset("100px");
|
||||||
|
|
||||||
|
const { prevented } = click(link("nota fora da cena"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(true);
|
||||||
|
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(26000 - 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no readable header offset -> treated as zero", async () => {
|
||||||
|
await mount();
|
||||||
|
mockHeaderOffset("");
|
||||||
|
|
||||||
|
click(link("titulo dos projetos"));
|
||||||
|
|
||||||
|
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: histórico e foco", () => {
|
||||||
|
it("path: a new anchor -> pushes it to the history and cancels the default jump", async () => {
|
||||||
|
await mount();
|
||||||
|
const push = jest.spyOn(window.history, "pushState");
|
||||||
|
|
||||||
|
const { prevented } = click(link("projetos"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(true);
|
||||||
|
expect(push).toHaveBeenCalledWith(null, "", "#projects");
|
||||||
|
expect(window.location.hash).toBe("#projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the anchor is already the current one -> the history is not touched, the page still scrolls", async () => {
|
||||||
|
window.history.replaceState(null, "", "/#projects");
|
||||||
|
await mount();
|
||||||
|
const push = jest.spyOn(window.history, "pushState");
|
||||||
|
|
||||||
|
click(link("projetos"));
|
||||||
|
|
||||||
|
expect(push).not.toHaveBeenCalled();
|
||||||
|
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: destination without tabindex -> gets tabindex -1 and the focus (no scroll jump)", async () => {
|
||||||
|
await mount();
|
||||||
|
const focus = jest.spyOn(HTMLElement.prototype, "focus");
|
||||||
|
const target = document.getElementById("projects-title") as HTMLElement;
|
||||||
|
expect(target.hasAttribute("tabindex")).toBe(false);
|
||||||
|
|
||||||
|
click(link("titulo dos projetos"));
|
||||||
|
|
||||||
|
expect(target.getAttribute("tabindex")).toBe("-1");
|
||||||
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
expect(document.activeElement).toBe(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: destination that already has a tabindex -> kept as it was", async () => {
|
||||||
|
await mount();
|
||||||
|
const target = document.getElementById("contact") as HTMLElement;
|
||||||
|
expect(target.getAttribute("tabindex")).toBe("0");
|
||||||
|
|
||||||
|
click(link("contato"));
|
||||||
|
|
||||||
|
expect(target.getAttribute("tabindex")).toBe("0");
|
||||||
|
expect(document.activeElement).toBe(target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: salto direto x rolagem curta", () => {
|
||||||
|
it("path: anchor exactly 3 screens away -> still animated, capped at 0.8 s", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
click(link("limite")); // 2400 px = 3 telas de 800 px
|
||||||
|
|
||||||
|
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||||
|
expect(options.immediate).toBe(false);
|
||||||
|
expect(options.duration).toBe(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: anchor just beyond 3 screens -> instant jump with zero duration", async () => {
|
||||||
|
await mount();
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 799,
|
||||||
|
});
|
||||||
|
|
||||||
|
click(link("limite")); // 2400 px passam de 3 x 799
|
||||||
|
|
||||||
|
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||||
|
expect(options.immediate).toBe(true);
|
||||||
|
expect(options.duration).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: anchor already at the current position -> a minimal animation (0.3 s), not a jump", async () => {
|
||||||
|
Object.defineProperty(window, "scrollY", {
|
||||||
|
configurable: true,
|
||||||
|
value: 1800,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
click(link("projetos"));
|
||||||
|
|
||||||
|
const [top, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||||
|
expect(top).toBe(1800);
|
||||||
|
expect(options.immediate).toBe(false);
|
||||||
|
expect(options.duration).toBeCloseTo(0.3, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scrolled far down, a near anchor above -> distance counts in absolute value", async () => {
|
||||||
|
Object.defineProperty(window, "scrollY", {
|
||||||
|
configurable: true,
|
||||||
|
value: 2400,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
click(link("projetos")); // 600 px acima
|
||||||
|
|
||||||
|
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||||
|
expect(options.immediate).toBe(false);
|
||||||
|
expect(options.duration).toBeCloseTo(0.3 + (600 / 800) * 0.2, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: touch screen (no Lenis) -> the native scroll is used: smooth when near, instant when far", async () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
click(link("projetos"));
|
||||||
|
click(link("contato"));
|
||||||
|
|
||||||
|
expect(window.scrollTo).toHaveBeenNthCalledWith(1, {
|
||||||
|
top: 1800,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenNthCalledWith(2, {
|
||||||
|
top: 25000,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: Lenis e o relógio do GSAP", () => {
|
||||||
|
it("path: mouse -> Lenis gets the smoothing options and drives ScrollTrigger; the GSAP lag smoothing is turned off", async () => {
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
expect(mockLenis).toHaveLength(1);
|
||||||
|
expect(mockLenis[0].options).toEqual({
|
||||||
|
lerp: 0.075,
|
||||||
|
wheelMultiplier: 0.85,
|
||||||
|
});
|
||||||
|
expect(mockLenis[0].on).toHaveBeenCalledWith(
|
||||||
|
"scroll",
|
||||||
|
ScrollTrigger.update,
|
||||||
|
);
|
||||||
|
expect(mockTicker.lagSmoothing).toHaveBeenCalledWith(0);
|
||||||
|
expect(mockTicker.add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: touch -> no Lenis, no ticker, no lag-smoothing change and the passages are still created", async () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
expect(mockLenis).toHaveLength(0);
|
||||||
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||||
|
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
|
||||||
|
expect(mockTimelines.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: touch -> window scroll events do not touch the clock", async () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the clock went to sleep, then a native scroll event -> wakes it", async () => {
|
||||||
|
await mount();
|
||||||
|
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||||
|
tick(IDLE_SECONDS + 1); // parado há mais que o tempo de espera
|
||||||
|
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||||
|
mockTicker.time = 5;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the clock went to sleep, then an anchor click -> wakes it before scrolling", async () => {
|
||||||
|
await mount();
|
||||||
|
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||||
|
tick(IDLE_SECONDS + 1);
|
||||||
|
|
||||||
|
click(link("projetos"));
|
||||||
|
|
||||||
|
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the clock is already asleep and the page unmounts -> it is not removed twice", async () => {
|
||||||
|
const view = await mount();
|
||||||
|
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||||
|
tick(IDLE_SECONDS + 1);
|
||||||
|
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLenis[0].destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: Lenis still scrolling -> the tick keeps the clock awake and feeds it in milliseconds", async () => {
|
||||||
|
await mount();
|
||||||
|
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||||
|
mockLenis[0].isScrolling = "smooth";
|
||||||
|
|
||||||
|
tick(IDLE_SECONDS * 10);
|
||||||
|
tick(IDLE_SECONDS * 10 + IDLE_SECONDS / 2);
|
||||||
|
|
||||||
|
expect(mockLenis[0].raf).toHaveBeenLastCalledWith(
|
||||||
|
(IDLE_SECONDS * 10 + IDLE_SECONDS / 2) * 1000,
|
||||||
|
);
|
||||||
|
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: montagem e desmontagem", () => {
|
||||||
|
it("path: unmounted before the dynamic imports finish -> nothing is created", async () => {
|
||||||
|
const view = render(<Home />);
|
||||||
|
view.unmount();
|
||||||
|
await flushImports();
|
||||||
|
|
||||||
|
expect(mockTimelines).toHaveLength(0);
|
||||||
|
expect(mockLenis).toHaveLength(0);
|
||||||
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: ref not attached to an element -> the hook does nothing", async () => {
|
||||||
|
await mount({ attach: false });
|
||||||
|
|
||||||
|
expect(mockTimelines).toHaveLength(0);
|
||||||
|
expect(mockLenis).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
|
||||||
|
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
const { prevented } = click(link("projetos"));
|
||||||
|
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
expect(mockLenis).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
|
||||||
|
const view = await mount();
|
||||||
|
const removeDocument = jest.spyOn(document, "removeEventListener");
|
||||||
|
const removeWindow = jest.spyOn(window, "removeEventListener");
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(removeDocument).toHaveBeenCalledWith(
|
||||||
|
"click",
|
||||||
|
expect.any(Function),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(removeWindow.mock.calls.map(([type]) => type)).toEqual(
|
||||||
|
expect.arrayContaining(["wheel", "scroll"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll: remedida depois de a página mudar de altura", () => {
|
||||||
|
type Resize = { callback: () => void; disconnect: jest.Mock };
|
||||||
|
let resizers: Resize[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resizers = [];
|
||||||
|
global.ResizeObserver = class {
|
||||||
|
private readonly record: Resize;
|
||||||
|
constructor(callback: () => void) {
|
||||||
|
this.record = { callback, disconnect: jest.fn() };
|
||||||
|
resizers.push(this.record);
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {
|
||||||
|
this.record.disconnect();
|
||||||
|
}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: several size changes in a row -> one refresh, 150 ms after the last", async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
resizers[0].callback();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
resizers[0].callback();
|
||||||
|
jest.advanceTimersByTime(149);
|
||||||
|
});
|
||||||
|
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
expect(ScrollTrigger.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount with a refresh pending -> the refresh never happens and the observer is disconnected", async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const view = await mount();
|
||||||
|
act(() => {
|
||||||
|
resizers[0].callback();
|
||||||
|
});
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||||
|
expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
anchorDuration,
|
||||||
|
ENTER_OFFSET,
|
||||||
|
enterScene,
|
||||||
|
exitScene,
|
||||||
|
IDLE_SECONDS,
|
||||||
|
JUMP_SCREENS,
|
||||||
|
MAX_ANCHOR_SECONDS,
|
||||||
|
PASSAGE_END,
|
||||||
|
useCinematicScroll,
|
||||||
|
} from "./useCinematicScroll";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------------------------------
|
||||||
|
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
|
||||||
|
* ------------------------------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
|
||||||
|
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
|
||||||
|
|
||||||
|
function scene() {
|
||||||
|
const element = document.createElement("section");
|
||||||
|
document.body.append(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeline() {
|
||||||
|
return realGsap.timeline({
|
||||||
|
paused: true,
|
||||||
|
defaults: { ease: "none", duration: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const translateY = (element: HTMLElement) =>
|
||||||
|
Number(
|
||||||
|
/translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec(
|
||||||
|
element.style.transform,
|
||||||
|
)?.[1] ?? Number.NaN,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("the single passage between scenes", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings the next scene in with a fade and a short vertical offset, nothing else", () => {
|
||||||
|
expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24);
|
||||||
|
expect(ENTER_OFFSET).toBeLessThanOrEqual(40);
|
||||||
|
|
||||||
|
const next = scene();
|
||||||
|
const tl = timeline();
|
||||||
|
enterScene(tl, next);
|
||||||
|
|
||||||
|
tl.progress(0.0001);
|
||||||
|
expect(Number(next.style.opacity)).toBeLessThan(0.01);
|
||||||
|
expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0);
|
||||||
|
|
||||||
|
tl.progress(0.5);
|
||||||
|
expect(Number(next.style.opacity)).toBeGreaterThan(0.2);
|
||||||
|
expect(Number(next.style.opacity)).toBeLessThan(1);
|
||||||
|
expect(translateY(next)).toBeGreaterThan(0);
|
||||||
|
expect(translateY(next)).toBeLessThan(ENTER_OFFSET);
|
||||||
|
|
||||||
|
// sem recorte, escala, giro nem visibility em nenhum ponto
|
||||||
|
for (const progress of [0.0001, 0.3, 0.7]) {
|
||||||
|
tl.progress(progress);
|
||||||
|
expect(next.style.clipPath).toBe("");
|
||||||
|
expect(next.style.visibility).toBe("");
|
||||||
|
expect(next.style.transform).not.toMatch(/scale|rotate/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => {
|
||||||
|
const next = scene();
|
||||||
|
const tl = timeline();
|
||||||
|
enterScene(tl, next);
|
||||||
|
|
||||||
|
tl.progress(1);
|
||||||
|
expect(next.style.opacity).toBe("");
|
||||||
|
expect(next.style.transform).toBe("");
|
||||||
|
|
||||||
|
tl.progress(0.4);
|
||||||
|
expect(Number(next.style.opacity)).toBeLessThan(1);
|
||||||
|
expect(next.style.transform).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fades the previous scene all the way to opacity 0 at the end of every passage", () => {
|
||||||
|
const previous = scene();
|
||||||
|
const tl = timeline();
|
||||||
|
exitScene(tl, previous);
|
||||||
|
|
||||||
|
tl.progress(0.5);
|
||||||
|
expect(Number(previous.style.opacity)).toBeGreaterThan(0);
|
||||||
|
expect(Number(previous.style.opacity)).toBeLessThan(1);
|
||||||
|
expect(previous.style.visibility).toBe("");
|
||||||
|
expect(previous.style.clipPath).toBe("");
|
||||||
|
|
||||||
|
tl.progress(1);
|
||||||
|
expect(previous.style.opacity).toBe("0");
|
||||||
|
|
||||||
|
// rolando de volta, a cena reaparece inteira
|
||||||
|
tl.progress(0.0001);
|
||||||
|
expect(Number(previous.style.opacity)).toBeGreaterThan(0.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => {
|
||||||
|
const middle = scene();
|
||||||
|
const arrive = timeline();
|
||||||
|
const leave = timeline();
|
||||||
|
enterScene(arrive, middle);
|
||||||
|
exitScene(leave, middle);
|
||||||
|
|
||||||
|
arrive.progress(1);
|
||||||
|
expect(middle.style.opacity).toBe("");
|
||||||
|
leave.progress(1);
|
||||||
|
expect(middle.style.opacity).toBe("0");
|
||||||
|
leave.progress(0);
|
||||||
|
arrive.progress(0.2);
|
||||||
|
expect(Number(middle.style.opacity)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("anchor travel", () => {
|
||||||
|
it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
|
||||||
|
expect(MAX_ANCHOR_SECONDS).toBe(0.8);
|
||||||
|
const viewport = 800;
|
||||||
|
expect(anchorDuration(25000, viewport)).toBeNull();
|
||||||
|
expect(anchorDuration(-25000, viewport)).toBeNull();
|
||||||
|
expect(
|
||||||
|
anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
|
||||||
|
const seconds = anchorDuration(distance, viewport);
|
||||||
|
expect(seconds).not.toBeNull();
|
||||||
|
expect(seconds as number).toBeGreaterThan(0);
|
||||||
|
expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------------------------------
|
||||||
|
* O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem.
|
||||||
|
* ------------------------------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type Call = unknown[];
|
||||||
|
const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = [];
|
||||||
|
const mockTicker = {
|
||||||
|
add: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
lagSmoothing: jest.fn(),
|
||||||
|
time: 0,
|
||||||
|
};
|
||||||
|
const mockLenis: MockLenis[] = [];
|
||||||
|
|
||||||
|
interface ScrollTriggerLike {
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: HTMLElement;
|
||||||
|
start: () => number;
|
||||||
|
end: () => number;
|
||||||
|
scrub: boolean;
|
||||||
|
onEnter?: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockLenis {
|
||||||
|
isScrolling: false | "smooth" = false;
|
||||||
|
on = jest.fn();
|
||||||
|
raf = jest.fn();
|
||||||
|
scrollTo = jest.fn();
|
||||||
|
destroy = jest.fn();
|
||||||
|
constructor(public options: unknown) {
|
||||||
|
mockLenis.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock("gsap", () => ({
|
||||||
|
gsap: {
|
||||||
|
registerPlugin: jest.fn(),
|
||||||
|
ticker: mockTicker,
|
||||||
|
context: (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
return { revert: jest.fn() };
|
||||||
|
},
|
||||||
|
timeline: (vars: ScrollTriggerLike) => {
|
||||||
|
const record = { vars, calls: [] as Call[] };
|
||||||
|
mockTimelines.push(record);
|
||||||
|
const chain = {
|
||||||
|
fromTo: (...args: Call) => {
|
||||||
|
record.calls.push(args);
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("gsap/ScrollTrigger", () => ({
|
||||||
|
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.mock("lenis", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
get default() {
|
||||||
|
return MockLenis;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TOPS: Record<string, number> = {
|
||||||
|
home: 0,
|
||||||
|
projects: 1800,
|
||||||
|
short: 3000,
|
||||||
|
about: 3300,
|
||||||
|
contact: 25000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
useCinematicScroll(ref);
|
||||||
|
return (
|
||||||
|
<main ref={ref}>
|
||||||
|
<section id="home" data-top={TOPS.home}>
|
||||||
|
<a href="/#contact">Conversar</a>
|
||||||
|
<a href="/#projects">Projetos</a>
|
||||||
|
</section>
|
||||||
|
<section id="projects" data-top={TOPS.projects}>
|
||||||
|
<h2>Projetos</h2>
|
||||||
|
</section>
|
||||||
|
{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
|
||||||
|
<section id="short" data-top={TOPS.short}>
|
||||||
|
<h2>Curta</h2>
|
||||||
|
</section>
|
||||||
|
<section id="about" data-top={TOPS.about}>
|
||||||
|
<h2>Sobre</h2>
|
||||||
|
</section>
|
||||||
|
<section id="contact" data-top={TOPS.contact}>
|
||||||
|
<h2>Contato</h2>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
const originalOffsetTop = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetTop",
|
||||||
|
);
|
||||||
|
|
||||||
|
function mockMedia(active: string[]) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: active.includes(query),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mount() {
|
||||||
|
const view = render(<Home />);
|
||||||
|
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
|
||||||
|
await act(async () => {
|
||||||
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||||
|
});
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenes = () =>
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>("main > section"));
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return Number(this.dataset.top ?? 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalOffsetTop)
|
||||||
|
Object.defineProperty(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetTop",
|
||||||
|
originalOffsetTop,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTimelines.length = 0;
|
||||||
|
mockLenis.length = 0;
|
||||||
|
mockTicker.add.mockClear();
|
||||||
|
mockTicker.remove.mockClear();
|
||||||
|
mockTicker.lagSmoothing.mockClear();
|
||||||
|
mockTicker.time = 0;
|
||||||
|
mockMedia([]);
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 800,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCinematicScroll", () => {
|
||||||
|
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
|
||||||
|
await mount();
|
||||||
|
for (const element of scenes()) {
|
||||||
|
expect(element.style.visibility).toBe("");
|
||||||
|
expect(element.style.position).toBe("");
|
||||||
|
expect(element.style.top).toBe("");
|
||||||
|
expect(element.style.opacity).toBe("");
|
||||||
|
expect(element.style.clipPath).toBe("");
|
||||||
|
}
|
||||||
|
// o texto do contato continua achável (Ctrl+F) e visível para leitores de tela
|
||||||
|
const contact = document.getElementById("contact") as HTMLElement;
|
||||||
|
expect(contact).toBeVisible();
|
||||||
|
expect(contact).toHaveTextContent("Contato");
|
||||||
|
expect(contact.closest("[aria-hidden]")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => {
|
||||||
|
await mount();
|
||||||
|
const allowed = new Set([
|
||||||
|
"opacity",
|
||||||
|
"y",
|
||||||
|
"ease",
|
||||||
|
"clearProps",
|
||||||
|
"immediateRender",
|
||||||
|
]);
|
||||||
|
const entering: HTMLElement[] = [];
|
||||||
|
const leaving: HTMLElement[] = [];
|
||||||
|
|
||||||
|
for (const { vars, calls } of mockTimelines) {
|
||||||
|
expect(vars.scrollTrigger.scrub).toBe(true);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const [target, from, to] = calls[0] as [
|
||||||
|
HTMLElement,
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
for (const key of [...Object.keys(from), ...Object.keys(to)])
|
||||||
|
expect(allowed.has(key)).toBe(true);
|
||||||
|
expect(JSON.stringify([from, to])).not.toMatch(
|
||||||
|
/autoAlpha|visibility|clipPath|scale|rotation|xPercent/,
|
||||||
|
);
|
||||||
|
if (to.opacity === 1) {
|
||||||
|
entering.push(target);
|
||||||
|
expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET });
|
||||||
|
expect(vars.scrollTrigger.trigger).toBe(target);
|
||||||
|
} else {
|
||||||
|
leaving.push(target);
|
||||||
|
expect(from).toMatchObject({ opacity: 1 });
|
||||||
|
expect(to.opacity).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = scenes();
|
||||||
|
// todas entram, menos a primeira; todas saem, menos a última
|
||||||
|
expect(entering).toEqual(all.slice(1));
|
||||||
|
expect(leaving).toEqual(all.slice(0, -1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => {
|
||||||
|
await mount();
|
||||||
|
const all = scenes();
|
||||||
|
const ranges = new Map<
|
||||||
|
HTMLElement,
|
||||||
|
{ enter?: [number, number]; exit?: [number, number] }
|
||||||
|
>();
|
||||||
|
for (const { vars, calls } of mockTimelines) {
|
||||||
|
const target = calls[0][0] as HTMLElement;
|
||||||
|
const to = calls[0][2] as { opacity: number };
|
||||||
|
const { start, end } = vars.scrollTrigger;
|
||||||
|
const entry = ranges.get(target) ?? {};
|
||||||
|
if (to.opacity === 1) entry.enter = [start(), end()];
|
||||||
|
else entry.exit = [start(), end()];
|
||||||
|
ranges.set(target, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
for (const element of all.slice(1)) {
|
||||||
|
const top = TOPS[element.id];
|
||||||
|
expect(ranges.get(element)?.enter).toEqual([
|
||||||
|
top - vh,
|
||||||
|
top - vh * PASSAGE_END,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const element of all.slice(0, -1)) {
|
||||||
|
const { enter, exit } = ranges.get(element) ?? {};
|
||||||
|
expect(exit).toBeDefined();
|
||||||
|
const [start, end] = exit as [number, number];
|
||||||
|
expect(start).toBeLessThan(end);
|
||||||
|
// termina quando o topo da cena seguinte chegou ao fim da passagem dela
|
||||||
|
const next = all[all.indexOf(element) + 1];
|
||||||
|
expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END);
|
||||||
|
if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]);
|
||||||
|
}
|
||||||
|
// a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes
|
||||||
|
const short = ranges.get(document.getElementById("short") as HTMLElement);
|
||||||
|
expect(short?.exit?.[0]).toBe(short?.enter?.[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does no scroll work for users who prefer reduced motion", async () => {
|
||||||
|
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||||
|
await mount();
|
||||||
|
expect(mockTimelines).toHaveLength(0);
|
||||||
|
expect(mockLenis).toHaveLength(0);
|
||||||
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smooth scroll", () => {
|
||||||
|
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
await mount();
|
||||||
|
expect(mockLenis).toHaveLength(0);
|
||||||
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||||
|
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
|
||||||
|
expect(mockTimelines.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
|
||||||
|
await mount();
|
||||||
|
expect(mockLenis).toHaveLength(1);
|
||||||
|
const lenis = mockLenis[0];
|
||||||
|
expect(mockTicker.add).toHaveBeenCalledTimes(1);
|
||||||
|
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||||
|
|
||||||
|
// rolando: segue ligado e alimenta o Lenis em milissegundos
|
||||||
|
lenis.isScrolling = "smooth";
|
||||||
|
tick(0.5);
|
||||||
|
expect(lenis.raf).toHaveBeenLastCalledWith(500);
|
||||||
|
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// parado há mais que o tempo de espera: o relógio é solto
|
||||||
|
lenis.isScrolling = false;
|
||||||
|
tick(0.5 + IDLE_SECONDS / 2);
|
||||||
|
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||||
|
tick(0.5 + IDLE_SECONDS + 0.1);
|
||||||
|
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockTicker.remove).toHaveBeenCalledWith(tick);
|
||||||
|
|
||||||
|
// a roda do mouse o acorda de novo
|
||||||
|
mockTicker.time = 10;
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||||
|
// e uma roda seguinte, com o relógio já ligado, não o liga duas vezes
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
|
||||||
|
await mount();
|
||||||
|
const lenis = mockLenis[0];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(document.querySelector('a[href="/#contact"]') as HTMLElement).click();
|
||||||
|
});
|
||||||
|
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
|
||||||
|
expect(farTop).toBe(TOPS.contact);
|
||||||
|
expect(farOptions).toMatchObject({ immediate: true });
|
||||||
|
expect(window.location.hash).toBe("#contact");
|
||||||
|
expect(document.getElementById("contact")).toHaveFocus();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||||
|
});
|
||||||
|
const [nearTop, nearOptions] = lenis.scrollTo.mock.calls[1];
|
||||||
|
expect(nearTop).toBe(TOPS.projects);
|
||||||
|
expect(nearOptions.immediate).toBe(false);
|
||||||
|
expect(nearOptions.duration).toBeGreaterThan(0);
|
||||||
|
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without Lenis (touch) a far anchor is an instant jump", async () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
await mount();
|
||||||
|
act(() => {
|
||||||
|
(document.querySelector('a[href="/#contact"]') as HTMLElement).click();
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: TOPS.contact,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenLastCalledWith({
|
||||||
|
top: TOPS.projects,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
|
||||||
|
const view = await mount();
|
||||||
|
const lenis = mockLenis[0];
|
||||||
|
view.unmount();
|
||||||
|
expect(lenis.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockTicker.remove).toHaveBeenCalled();
|
||||||
|
mockTicker.add.mockClear();
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type RefObject, useEffect } from "react";
|
import { type RefObject, useEffect } from "react";
|
||||||
|
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||||
type Transition = (
|
|
||||||
tl: gsap.core.Timeline,
|
|
||||||
prev: HTMLElement,
|
|
||||||
next: HTMLElement,
|
|
||||||
touch: boolean,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
|
* Rolagem como um vídeo: o GSAP liga cada passagem de uma cena para a seguinte à posição da rolagem (rolar
|
||||||
* da rolagem (rolar para cima volta o "vídeo").
|
* para cima volta o "vídeo") e, no mouse, o Lenis suaviza a roda.
|
||||||
*
|
*
|
||||||
* Quem segura as seções paradas durante uma passagem:
|
* Uma única passagem para a home inteira, só com opacidade e um deslocamento vertical curto:
|
||||||
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
|
* - a cena que entra chega com fade e sobe ENTER_OFFSET px até o lugar dela;
|
||||||
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
|
* - a cena que sai some com fade e termina SEMPRE em opacidade 0 (nenhuma cena anterior fica "fantasma" atrás
|
||||||
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
|
* das seguintes);
|
||||||
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
|
* - nada de recorte (clip-path), escala ou rotação, e nenhuma cena é escondida com visibility: o conteúdo
|
||||||
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
|
* nasce visível no HTML do servidor e a opacidade só muda por rolagem (Ctrl+F e leitores de tela enxergam
|
||||||
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
|
* tudo, e sem JavaScript tudo aparece).
|
||||||
|
* As cenas ficam no fluxo normal da página, uma depois da outra. A única rolagem "presa" é a do CSS: o hero
|
||||||
|
* (sticky) e a vitrine de projetos (sticky); este arquivo não prende nada.
|
||||||
*
|
*
|
||||||
* Regras que mantêm tudo estável:
|
* Cada cena tem uma entrada e uma saída, cada uma com o próprio gatilho, e a saída só começa depois que a
|
||||||
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
|
* entrada da mesma cena terminou (cenas curtas não brigam pela mesma opacidade). Qualquer filho direto de
|
||||||
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
|
* <main> é uma cena; nada aqui depende de uma seção específica existir.
|
||||||
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
|
|
||||||
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
|
|
||||||
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const vh = () => window.innerHeight;
|
/** Deslocamento vertical, em px, com que a cena chega (entre 24 e 40). */
|
||||||
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
|
export const ENTER_OFFSET = 32;
|
||||||
const visibleCenter = (el: HTMLElement) => () =>
|
/** A passagem corre enquanto o topo da cena que entra sobe de 1 altura de tela até esta fração dela. */
|
||||||
`50% ${el.offsetHeight - vh() / 2}px`;
|
export const PASSAGE_END = 0.2;
|
||||||
|
/** Âncoras mais distantes que isto (em alturas de tela) são um salto direto. */
|
||||||
|
export const JUMP_SCREENS = 3;
|
||||||
|
/** Nenhuma âncora anima por mais que isto. */
|
||||||
|
export const MAX_ANCHOR_SECONDS = 0.8;
|
||||||
|
/** Depois deste tempo parada, a rolagem suave solta o relógio do GSAP. */
|
||||||
|
export const IDLE_SECONDS = 1;
|
||||||
|
|
||||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
|
const vh = () => window.innerHeight;
|
||||||
|
|
||||||
|
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das passagens, getBoundingClientRect não). */
|
||||||
function pageTop(el: HTMLElement) {
|
function pageTop(el: HTMLElement) {
|
||||||
let top = 0;
|
let top = 0;
|
||||||
for (
|
for (
|
||||||
@@ -46,125 +47,38 @@ function pageTop(el: HTMLElement) {
|
|||||||
return top;
|
return top;
|
||||||
}
|
}
|
||||||
|
|
||||||
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
|
/** A cena chega: fade e subida curta. Ao terminar, o estilo inline some (sem transform sobrando na cena). */
|
||||||
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
|
export function enterScene(tl: gsap.core.Timeline, scene: HTMLElement) {
|
||||||
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
|
tl.fromTo(
|
||||||
|
scene,
|
||||||
|
{ opacity: 0, y: ENTER_OFFSET },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
ease: "power2.out",
|
||||||
|
clearProps: "opacity,transform",
|
||||||
|
immediateRender: false,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// uma linguagem diferente por passagem, na ordem das seções da home
|
/** A cena sai: fade até 0. É o último estado dela até a rolagem voltar. */
|
||||||
const TRANSITIONS: Transition[] = [
|
export function exitScene(tl: gsap.core.Timeline, scene: HTMLElement) {
|
||||||
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
|
tl.fromTo(
|
||||||
(tl, prev, next, touch) => {
|
scene,
|
||||||
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
|
{ opacity: 1 },
|
||||||
if (touch) {
|
{ opacity: 0, ease: "power2.in", immediateRender: false },
|
||||||
tl.fromTo(
|
0,
|
||||||
next,
|
);
|
||||||
{
|
}
|
||||||
opacity: 0.3,
|
|
||||||
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
|
/** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */
|
||||||
},
|
export function anchorDuration(distance: number, viewport: number) {
|
||||||
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
|
const screens = Math.abs(distance) / Math.max(1, viewport);
|
||||||
0,
|
if (screens > JUMP_SCREENS) return null;
|
||||||
);
|
return Math.min(MAX_ANCHOR_SECONDS, 0.3 + screens * 0.2);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
holdNext(tl, next);
|
|
||||||
const band = (open: number) => () =>
|
|
||||||
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
|
|
||||||
tl.fromTo(
|
|
||||||
next,
|
|
||||||
{ opacity: 0, clipPath: band(0.36) },
|
|
||||||
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
|
||||||
0.35,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
|
|
||||||
(tl, prev, next, touch) => {
|
|
||||||
tl.fromTo(
|
|
||||||
prev,
|
|
||||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
|
||||||
{ scale: 1.18, opacity: 0.2 },
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
|
|
||||||
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
|
|
||||||
const radius = () =>
|
|
||||||
touch
|
|
||||||
? Math.hypot(window.innerWidth / 2, vh())
|
|
||||||
: Math.hypot(window.innerWidth / 2, vh() / 2);
|
|
||||||
if (!touch) holdNext(tl, next);
|
|
||||||
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
|
|
||||||
tl.fromTo(
|
|
||||||
next,
|
|
||||||
{ clipPath: () => `circle(0px ${center()})` },
|
|
||||||
{
|
|
||||||
clipPath: () => `circle(${radius()}px ${center()})`,
|
|
||||||
ease: "power1.in",
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
|
||||||
(tl, prev, next, touch) => {
|
|
||||||
if (!touch) holdNext(tl, next);
|
|
||||||
tl.fromTo(
|
|
||||||
prev,
|
|
||||||
{ xPercent: 0, opacity: 1 },
|
|
||||||
{ xPercent: -35, opacity: 0.3 },
|
|
||||||
0,
|
|
||||||
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
|
|
||||||
},
|
|
||||||
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
|
|
||||||
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
|
|
||||||
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
|
|
||||||
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
|
|
||||||
(tl, prev, next, touch) => {
|
|
||||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
|
|
||||||
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
|
|
||||||
if (!touch)
|
|
||||||
tl.fromTo(
|
|
||||||
next,
|
|
||||||
{ y: () => vh() * 0.35 },
|
|
||||||
{ y: 0, ease: "power2.out" },
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
|
|
||||||
(tl, prev, next, touch) => {
|
|
||||||
if (!touch) holdNext(tl, next);
|
|
||||||
tl.fromTo(
|
|
||||||
prev,
|
|
||||||
{ opacity: 1, xPercent: 0 },
|
|
||||||
{ opacity: 0.25, xPercent: 20 },
|
|
||||||
0,
|
|
||||||
).fromTo(
|
|
||||||
next,
|
|
||||||
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
|
|
||||||
{ xPercent: 0, rotation: 0, ease: "power2.out" },
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// sobre → contato: o contato cresce do centro, como um zoom de câmera
|
|
||||||
(tl, prev, next, touch) => {
|
|
||||||
if (!touch) holdNext(tl, next);
|
|
||||||
tl.fromTo(
|
|
||||||
prev,
|
|
||||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
|
||||||
{ scale: 0.92, opacity: 0.2 },
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
.fromTo(
|
|
||||||
next,
|
|
||||||
{
|
|
||||||
scale: touch ? 0.8 : 0.55,
|
|
||||||
clipPath: "inset(0% round 56px)",
|
|
||||||
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
|
|
||||||
},
|
|
||||||
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const easeInOutCubic = (t: number) =>
|
const easeInOutCubic = (t: number) =>
|
||||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||||
@@ -173,109 +87,103 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const main = mainRef.current;
|
const main = mainRef.current;
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
if (prefersReducedMotion()) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let cleanup = () => {};
|
let cleanup = () => {};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
|
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
||||||
await Promise.all([
|
const smooth = !isCoarsePointer();
|
||||||
import("gsap"),
|
const [{ gsap }, { ScrollTrigger }, lenisModule] = await Promise.all([
|
||||||
import("gsap/ScrollTrigger"),
|
import("gsap"),
|
||||||
import("lenis"),
|
import("gsap/ScrollTrigger"),
|
||||||
]);
|
smooth ? import("lenis") : Promise.resolve(null),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
|
// Lenis: o relógio do GSAP só roda enquanto a rolagem anima; parada por IDLE_SECONDS, ele é solto
|
||||||
lenis.on("scroll", ScrollTrigger.update);
|
const lenis = lenisModule
|
||||||
const tick = (time: number) => lenis.raf(time * 1000);
|
? new lenisModule.default({ lerp: 0.075, wheelMultiplier: 0.85 })
|
||||||
gsap.ticker.add(tick);
|
: null;
|
||||||
gsap.ticker.lagSmoothing(0);
|
let awake = false;
|
||||||
|
let lastBusy = 0;
|
||||||
|
const tick = (time: number) => {
|
||||||
|
lenis?.raf(time * 1000);
|
||||||
|
if (lenis?.isScrolling) lastBusy = time;
|
||||||
|
else if (time - lastBusy > IDLE_SECONDS) sleep();
|
||||||
|
};
|
||||||
|
function sleep() {
|
||||||
|
if (!awake) return;
|
||||||
|
awake = false;
|
||||||
|
gsap.ticker.remove(tick);
|
||||||
|
}
|
||||||
|
const wake = () => {
|
||||||
|
if (!lenis) return;
|
||||||
|
lastBusy = gsap.ticker.time;
|
||||||
|
if (awake) return;
|
||||||
|
awake = true;
|
||||||
|
gsap.ticker.add(tick);
|
||||||
|
};
|
||||||
|
if (lenis) {
|
||||||
|
lenis.on("scroll", ScrollTrigger.update);
|
||||||
|
gsap.ticker.lagSmoothing(0);
|
||||||
|
wake();
|
||||||
|
window.addEventListener("wheel", wake, { passive: true });
|
||||||
|
window.addEventListener("scroll", wake, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
|
|
||||||
const touch = window.matchMedia(
|
|
||||||
"(hover: none) and (pointer: coarse)",
|
|
||||||
).matches;
|
|
||||||
const scenes = Array.from(main.children) as HTMLElement[];
|
const scenes = Array.from(main.children) as HTMLElement[];
|
||||||
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
|
|
||||||
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
|
|
||||||
const sceneTops = () => {
|
|
||||||
let top = pageTop(main);
|
|
||||||
return scenes.map((scene) => {
|
|
||||||
const current = top;
|
|
||||||
top += scene.offsetHeight;
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const stick = () => {
|
|
||||||
for (const scene of scenes)
|
|
||||||
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const context = gsap.context(() => {
|
const context = gsap.context(() => {
|
||||||
scenes.forEach((scene, index) => {
|
scenes.forEach((scene, index) => {
|
||||||
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
|
const following = scenes[index + 1];
|
||||||
gsap.set(scene, {
|
const trigger = (
|
||||||
position: "sticky",
|
start: () => number,
|
||||||
zIndex: index === 0 ? 30 : index + 1,
|
end: () => number,
|
||||||
|
extra: ScrollTrigger.Vars = {},
|
||||||
|
) => ({
|
||||||
|
trigger: scene,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
scrub: true,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
});
|
const timeline = (vars: ScrollTrigger.Vars) =>
|
||||||
stick();
|
gsap.timeline({
|
||||||
TRANSITIONS.forEach((transition, index) => {
|
defaults: { ease: "none", duration: 1 },
|
||||||
const prev = scenes[index];
|
scrollTrigger: vars,
|
||||||
const next = scenes[index + 1];
|
});
|
||||||
if (!prev || !next) return;
|
|
||||||
const tl = gsap.timeline({
|
if (index > 0)
|
||||||
defaults: { ease: "none", duration: 1 },
|
enterScene(
|
||||||
scrollTrigger: {
|
timeline(
|
||||||
trigger: next,
|
trigger(
|
||||||
start: () => sceneTops()[index + 1] - vh(),
|
() => pageTop(scene) - vh(),
|
||||||
end: () => sceneTops()[index + 1],
|
() => pageTop(scene) - vh() * PASSAGE_END,
|
||||||
scrub: true,
|
),
|
||||||
invalidateOnRefresh: true,
|
),
|
||||||
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
|
scene,
|
||||||
onLeave: () => gsap.set(next, { clipPath: "none" }),
|
);
|
||||||
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
|
if (following)
|
||||||
onEnter: () => next.classList.add("is-drawing"),
|
exitScene(
|
||||||
},
|
timeline(
|
||||||
});
|
trigger(
|
||||||
transition(tl, prev, next, touch);
|
// só depois de a própria entrada terminar, mesmo numa cena mais curta que a tela
|
||||||
|
() =>
|
||||||
|
Math.max(
|
||||||
|
pageTop(scene) - vh() * PASSAGE_END,
|
||||||
|
pageTop(following) - vh(),
|
||||||
|
),
|
||||||
|
() => pageTop(following) - vh() * PASSAGE_END,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scene,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, main);
|
}, main);
|
||||||
|
|
||||||
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
|
// a página mudou de altura (fontes, imagens, seções que abrem e fecham): o GSAP remede as passagens
|
||||||
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
|
|
||||||
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
|
|
||||||
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
|
|
||||||
let frame = 0;
|
|
||||||
const syncVisibility = () => {
|
|
||||||
frame = 0;
|
|
||||||
const y = window.scrollY;
|
|
||||||
const tops = sceneTops();
|
|
||||||
scenes.forEach((scene, index) => {
|
|
||||||
const from =
|
|
||||||
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
|
|
||||||
const to =
|
|
||||||
index === scenes.length - 1
|
|
||||||
? Infinity
|
|
||||||
: tops[index + 1] + vh() * 0.25;
|
|
||||||
const visibility = y >= from && y <= to ? "" : "hidden";
|
|
||||||
if (scene.style.visibility !== visibility)
|
|
||||||
scene.style.visibility = visibility;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const queueSync = () => {
|
|
||||||
if (!frame) frame = requestAnimationFrame(syncVisibility);
|
|
||||||
};
|
|
||||||
const onRefresh = () => {
|
|
||||||
stick();
|
|
||||||
queueSync();
|
|
||||||
};
|
|
||||||
syncVisibility();
|
|
||||||
ScrollTrigger.addEventListener("refresh", onRefresh);
|
|
||||||
window.addEventListener("scroll", queueSync, { passive: true });
|
|
||||||
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
|
|
||||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
@@ -283,7 +191,25 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
|||||||
});
|
});
|
||||||
resizeObserver.observe(main);
|
resizeObserver.observe(main);
|
||||||
|
|
||||||
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
|
const scrollToTop = (top: number) => {
|
||||||
|
const distance = top - window.scrollY;
|
||||||
|
const duration = anchorDuration(distance, vh());
|
||||||
|
if (lenis) {
|
||||||
|
wake();
|
||||||
|
lenis.scrollTo(top, {
|
||||||
|
immediate: duration === null,
|
||||||
|
duration: duration ?? 0,
|
||||||
|
easing: easeInOutCubic,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.scrollTo({
|
||||||
|
top,
|
||||||
|
behavior: duration === null ? "instant" : "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino
|
||||||
const onClick = (event: MouseEvent) => {
|
const onClick = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
event.defaultPrevented ||
|
event.defaultPrevented ||
|
||||||
@@ -318,55 +244,37 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
|
// o alvo é a cena inteira: para no topo dela; alvo interno: desconta o cabeçalho fixo, sem passar do topo da cena
|
||||||
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
|
const destination = target;
|
||||||
const index = scenes.findIndex((candidate) =>
|
const scene = scenes.find((candidate) =>
|
||||||
candidate.contains(target),
|
candidate.contains(destination),
|
||||||
);
|
);
|
||||||
const offset =
|
const offset =
|
||||||
Number.parseFloat(
|
Number.parseFloat(
|
||||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||||
) || 0;
|
) || 0;
|
||||||
const sceneTop = index < 0 ? 0 : sceneTops()[index];
|
const sceneTop = scene ? pageTop(scene) : 0;
|
||||||
const top =
|
const top =
|
||||||
index <= 0
|
scene === destination
|
||||||
? 0
|
? sceneTop
|
||||||
: Math.max(
|
: Math.max(sceneTop, pageTop(destination) - offset);
|
||||||
sceneTop,
|
|
||||||
scenes[index] === target
|
|
||||||
? 0
|
|
||||||
: sceneTop +
|
|
||||||
pageTop(target) -
|
|
||||||
pageTop(scenes[index]) -
|
|
||||||
offset,
|
|
||||||
);
|
|
||||||
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
|
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
|
||||||
lenis.scrollTo(top, {
|
scrollToTop(top);
|
||||||
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
|
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
|
||||||
easing: easeInOutCubic,
|
if (!destination.hasAttribute("tabindex")) destination.tabIndex = -1;
|
||||||
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
|
destination.focus({ preventScroll: true });
|
||||||
onComplete: () => {
|
|
||||||
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
|
||||||
target.focus({ preventScroll: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
document.addEventListener("click", onClick, true);
|
document.addEventListener("click", onClick, true);
|
||||||
|
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
document.removeEventListener("click", onClick, true);
|
document.removeEventListener("click", onClick, true);
|
||||||
ScrollTrigger.removeEventListener("refresh", onRefresh);
|
window.removeEventListener("wheel", wake);
|
||||||
window.removeEventListener("scroll", queueSync);
|
window.removeEventListener("scroll", wake);
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
context.revert();
|
context.revert();
|
||||||
for (const scene of scenes) {
|
sleep();
|
||||||
scene.style.visibility = "";
|
lenis?.destroy();
|
||||||
scene.style.top = "";
|
|
||||||
}
|
|
||||||
gsap.ticker.remove(tick);
|
|
||||||
lenis.destroy();
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de displayUrl e BrowserFrame (complemento de BrowserFrame.test.tsx): cada condição
|
||||||
|
* da escolha do endereço (demoUrl válido, caminho, www, porta, esquema, github e parentes, URL inválida,
|
||||||
|
* título vazio) e as variações da moldura (cadeado, texto padrão, className).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("displayUrl", () => {
|
||||||
|
it.each([
|
||||||
|
["https://sevmodaintima.com.br", "sevmodaintima.com.br", true],
|
||||||
|
["https://sevmodaintima.com.br/", "sevmodaintima.com.br", true],
|
||||||
|
[
|
||||||
|
"https://www.sevmodaintima.com.br/loja/",
|
||||||
|
"sevmodaintima.com.br/loja",
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
["https://x.example.com/a/b/", "x.example.com/a/b", true],
|
||||||
|
["https://x.example.com/a?q=1#topo", "x.example.com/a", true],
|
||||||
|
["http://x.example.com", "x.example.com", false],
|
||||||
|
["https://x.example.com:8443/app", "x.example.com:8443/app", true],
|
||||||
|
["https://sub.www.example.com", "sub.www.example.com", true],
|
||||||
|
["https://notgithub.com", "notgithub.com", true],
|
||||||
|
["https://github.com.evil.example", "github.com.evil.example", true],
|
||||||
|
["ftp://files.example.com/pasta", "files.example.com/pasta", false],
|
||||||
|
])("path: demoUrl %s -> %s (secure: %s)", (demoUrl, text, secure) => {
|
||||||
|
expect(displayUrl({ demoUrl, title: "Projeto" })).toEqual({ text, secure });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://github.com/evandrodevbr/projeto",
|
||||||
|
"https://GITHUB.COM/evandrodevbr",
|
||||||
|
"https://gist.github.com/x",
|
||||||
|
"https://www.github.com",
|
||||||
|
])(
|
||||||
|
"path: demoUrl %s points at GitHub -> falls back to the title",
|
||||||
|
(demoUrl) => {
|
||||||
|
expect(displayUrl({ demoUrl, title: "Projeto" })).toEqual({
|
||||||
|
text: "Projeto",
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: demoUrl does not parse -> falls back to the title", () => {
|
||||||
|
expect(displayUrl({ demoUrl: "isto nao e url", title: "Projeto" })).toEqual(
|
||||||
|
{
|
||||||
|
text: "Projeto",
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["null demoUrl", { demoUrl: null, title: "Projeto" }],
|
||||||
|
["empty demoUrl", { demoUrl: "", title: "Projeto" }],
|
||||||
|
["missing demoUrl", { title: "Projeto" }],
|
||||||
|
])("path: %s with a title -> the title, never secure", (_label, project) => {
|
||||||
|
expect(displayUrl(project)).toEqual({ text: "Projeto", secure: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["no fields", {}],
|
||||||
|
["empty title", { title: "" }],
|
||||||
|
["null title and no demo", { title: null, demoUrl: null }],
|
||||||
|
["github demo and no title", { demoUrl: "https://github.com/x" }],
|
||||||
|
["invalid demo and empty title", { demoUrl: "???", title: "" }],
|
||||||
|
])("path: %s -> null", (_label, project) => {
|
||||||
|
expect(displayUrl(project)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BrowserFrame", () => {
|
||||||
|
it("path: secure address -> lock icon plus the address text", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame url={{ text: "exemplo.com.br", secure: true }}>
|
||||||
|
<p>tela</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe-url svg")).not.toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"exemplo.com.br",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: insecure address -> text without the lock", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame url={{ text: "Projeto", secure: false }}>
|
||||||
|
<p>tela</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"Projeto",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no address -> neutral label and no lock", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame url={null}>
|
||||||
|
<p>tela</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||||
|
"pré-visualização",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: className and style given -> both reach the wrapper, with no stray space", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame
|
||||||
|
url={null}
|
||||||
|
className="ds-reel-laptop"
|
||||||
|
style={{ width: "88%" }}
|
||||||
|
>
|
||||||
|
<p>tela</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const frame = container.firstElementChild as HTMLElement;
|
||||||
|
expect(frame.className).toBe("ds-bframe ds-reel-laptop");
|
||||||
|
expect(frame.style.width).toBe("88%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no className -> the class has no trailing space", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame url={null}>
|
||||||
|
<p>tela</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((container.firstElementChild as HTMLElement).className).toBe(
|
||||||
|
"ds-bframe",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,24 +20,39 @@ describe("displayUrl", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the code address when there is no live demo", () => {
|
it("shows only the project name, never a code address, when there is no live demo", () => {
|
||||||
expect(
|
expect(displayUrl({ title: "AS Serviços", demoUrl: null })).toEqual({
|
||||||
displayUrl({ demoUrl: null, githubUrl: "https://github.com/user/repo" }),
|
text: "AS Serviços",
|
||||||
).toEqual({ text: "github.com/user/repo", secure: true });
|
secure: false,
|
||||||
|
});
|
||||||
|
expect(displayUrl({ title: "AS Serviços" })).toEqual({
|
||||||
|
text: "AS Serviços",
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
// um projeto completo, com o endereço do código ainda nos dados, não vaza para a barra
|
||||||
|
const project = {
|
||||||
|
title: "Twins Lab",
|
||||||
|
demoUrl: "",
|
||||||
|
githubUrl: "https://github.com/user/repo",
|
||||||
|
};
|
||||||
|
expect(displayUrl(project)?.text).toBe("Twins Lab");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips an address that does not parse and uses the next one", () => {
|
it("falls back to the project name when the address does not parse or points at github.com", () => {
|
||||||
expect(
|
const title = "Meu projeto";
|
||||||
displayUrl({
|
const name = { text: title, secure: false };
|
||||||
demoUrl: "isso não é uma url",
|
expect(displayUrl({ title, demoUrl: "isso não é uma url" })).toEqual(name);
|
||||||
githubUrl: "https://github.com/a/b",
|
expect(displayUrl({ title, demoUrl: "https://github.com/a/b" })).toEqual(
|
||||||
}),
|
name,
|
||||||
).toEqual({ text: "github.com/a/b", secure: true });
|
);
|
||||||
|
expect(displayUrl({ title, demoUrl: "https://www.github.com/a" })).toEqual(
|
||||||
|
name,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when there is nothing to show", () => {
|
it("returns null when there is nothing to show", () => {
|
||||||
expect(displayUrl({})).toBeNull();
|
expect(displayUrl({})).toBeNull();
|
||||||
expect(displayUrl({ demoUrl: "", githubUrl: null })).toBeNull();
|
expect(displayUrl({ title: "", demoUrl: "" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
/** Endereço mostrado na barra: o do site publicado, ou o do código quando não há demonstração. */
|
/**
|
||||||
|
* Endereço mostrado na barra: o domínio real do site publicado (`demoUrl`). Sem demonstração, só o nome do
|
||||||
|
* projeto: o endereço do código nunca aparece (nada de github.com na interface).
|
||||||
|
*/
|
||||||
export function displayUrl(project: {
|
export function displayUrl(project: {
|
||||||
|
title?: string | null;
|
||||||
demoUrl?: string | null;
|
demoUrl?: string | null;
|
||||||
githubUrl?: string | null;
|
|
||||||
}): { text: string; secure: boolean } | null {
|
}): { text: string; secure: boolean } | null {
|
||||||
for (const raw of [project.demoUrl, project.githubUrl]) {
|
if (project.demoUrl) {
|
||||||
if (!raw) continue;
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(raw);
|
const url = new URL(project.demoUrl);
|
||||||
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
if (!/(^|\.)github\.com$/i.test(url.hostname)) {
|
||||||
return {
|
const path =
|
||||||
text: `${url.host.replace(/^www\./, "")}${path}`,
|
url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||||
secure: url.protocol === "https:",
|
return {
|
||||||
};
|
text: `${url.host.replace(/^www\./, "")}${path}`,
|
||||||
|
secure: url.protocol === "https:",
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return null;
|
return project.title ? { text: project.title, secure: false } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
/** O site publicado abre em nova aba quando existe e não foi dado como fora do ar. */
|
||||||
|
export function canOpenSite(project: ShowcaseItem["project"]) {
|
||||||
|
return Boolean(project.demoUrl) && project.demoAvailability !== "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function ProjectActions({
|
||||||
|
item,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
item: ShowcaseItem;
|
||||||
|
/** Sem a janela ampliada (antes de o navegador montar a página) o botão nem aparece. */
|
||||||
|
onExpand?: () => void;
|
||||||
|
}) {
|
||||||
|
const { project } = item;
|
||||||
|
return (
|
||||||
|
<div className="ds-reel-actions">
|
||||||
|
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||||||
|
Ver case
|
||||||
|
<span className="sr-only"> de {project.title}</span>
|
||||||
|
</Link>
|
||||||
|
{onExpand && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ds-link ds-link-muted ds-reel-expand"
|
||||||
|
onClick={onExpand}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M9.5 2.5h4v4M6.5 13.5h-4v-4M13.5 2.5 9 7M2.5 13.5 7 9" />
|
||||||
|
</svg>
|
||||||
|
Ampliar vídeo
|
||||||
|
<span className="sr-only"> de {project.title}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canOpenSite(project) && (
|
||||||
|
<a
|
||||||
|
className="ds-link ds-link-text"
|
||||||
|
href={project.demoUrl ?? undefined}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Abrir site
|
||||||
|
<span className="sr-only">
|
||||||
|
{" "}
|
||||||
|
de {project.title} (abre em nova aba)
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,7 +177,7 @@ describe("ProjectLightbox", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(video.currentTime).toBeCloseTo(
|
expect(video.currentTime).toBeCloseTo(
|
||||||
chapterFraction(lavacao, 2, "desktop") * lavacao.durations.desktop,
|
chapterFraction(lavacao, 1, "desktop") * lavacao.durations.desktop,
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -226,14 +226,72 @@ describe("ProjectLightbox", () => {
|
|||||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the site and the code when they exist", () => {
|
it("offers the case and, when the site exists, a text link to it in a new tab; never the code", () => {
|
||||||
const dialog = setup("lavacao-047");
|
const dialog = setup("lavacao-047");
|
||||||
expect(
|
expect(
|
||||||
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
|
within(dialog).getByRole("link", { name: /Ver case/ }),
|
||||||
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
|
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||||
|
const site = within(dialog).getByRole("link", { name: /Abrir site/ });
|
||||||
|
expect(site).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://landing-page-lavacao047.vercel.app",
|
||||||
|
);
|
||||||
|
expect(site).toHaveAttribute("target", "_blank");
|
||||||
|
expect(site).toHaveClass("ds-link-text");
|
||||||
|
expect(dialog.querySelector(".ds-link-accent")).toBeNull();
|
||||||
|
expect(within(dialog).queryByRole("link", { name: /código/i })).toBeNull();
|
||||||
|
expect(dialog.innerHTML).not.toContain("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no site link, and an address bar with only the project name, for a project without a live site", () => {
|
||||||
|
const dialog = setup("as-servicos");
|
||||||
expect(
|
expect(
|
||||||
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
|
within(dialog).queryByRole("link", { name: /Abrir site/ }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("link", { name: /Ver case/ }),
|
||||||
|
).toHaveAttribute("href", "/cases/as-servicos");
|
||||||
|
expect(dialog.querySelector(".ds-bframe-url")?.textContent).toBe(
|
||||||
|
"AS Serviços",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on the phone version below 768 px", () => {
|
||||||
|
const original = window.matchMedia;
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query === "(max-width: 767px)",
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const dialog = setup();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Celular" }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
|
||||||
|
} finally {
|
||||||
|
window.matchMedia = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not autoplay, nor preload the whole file, with the data saver on", () => {
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: { saveData: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const dialog = setup();
|
||||||
|
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||||
|
expect(video).not.toHaveAttribute("autoplay");
|
||||||
|
expect(video).toHaveAttribute("preload", "metadata");
|
||||||
|
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
|
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import { isMobileViewport, isSaveData } from "@/lib/device";
|
||||||
import {
|
import {
|
||||||
chapterFraction,
|
chapterFraction,
|
||||||
chapterIndexAt,
|
chapterIndexAt,
|
||||||
@@ -56,12 +58,17 @@ export function ProjectLightbox({
|
|||||||
const pendingFraction = useRef<number | null>(null);
|
const pendingFraction = useRef<number | null>(null);
|
||||||
const pressedOnBackdrop = useRef(false);
|
const pressedOnBackdrop = useRef(false);
|
||||||
|
|
||||||
|
// celular abaixo de 768 px (lib/device.ts); a troca de aparelho continua à escolha do visitante
|
||||||
const [device, setDevice] = useState<Device>(() =>
|
const [device, setDevice] = useState<Device>(() =>
|
||||||
window.matchMedia("(max-width: 639px)").matches ? "mobile" : "desktop",
|
isMobileViewport() ? "mobile" : "desktop",
|
||||||
);
|
);
|
||||||
// o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento
|
// o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento
|
||||||
|
// "economizar dados": o arquivo só chega quando o visitante pede (play), não antes
|
||||||
|
const [saveData] = useState(isSaveData);
|
||||||
const [autoplay, setAutoplay] = useState(
|
const [autoplay, setAutoplay] = useState(
|
||||||
() => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
() =>
|
||||||
|
!window.matchMedia("(prefers-reduced-motion: reduce)").matches &&
|
||||||
|
!saveData,
|
||||||
);
|
);
|
||||||
const wantsToPlay = useRef(autoplay);
|
const wantsToPlay = useRef(autoplay);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
@@ -199,7 +206,6 @@ export function ProjectLightbox({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const video = (
|
const video = (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: demonstração sem áudio; a descrição está no título e nos capítulos
|
|
||||||
<video
|
<video
|
||||||
key={`${config.id}-${device}`}
|
key={`${config.id}-${device}`}
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -208,7 +214,7 @@ export function ProjectLightbox({
|
|||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload={saveData ? "metadata" : "auto"}
|
||||||
poster={assets.poster}
|
poster={assets.poster}
|
||||||
width={assets.width}
|
width={assets.width}
|
||||||
height={assets.height}
|
height={assets.height}
|
||||||
@@ -249,9 +255,9 @@ export function ProjectLightbox({
|
|||||||
{String(index + 1).padStart(2, "0")} /{" "}
|
{String(index + 1).padStart(2, "0")} /{" "}
|
||||||
{String(items.length).padStart(2, "0")}
|
{String(items.length).padStart(2, "0")}
|
||||||
</p>
|
</p>
|
||||||
<h3 id={titleId} className="ds-lb-title">
|
<h2 id={titleId} className="ds-lb-title">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h3>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset className="ds-lb-toggle">
|
<fieldset className="ds-lb-toggle">
|
||||||
@@ -371,25 +377,18 @@ export function ProjectLightbox({
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div className="ds-lb-links">
|
<div className="ds-lb-links">
|
||||||
|
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||||||
|
Ver case
|
||||||
|
<span className="sr-only"> de {project.title}</span>
|
||||||
|
</Link>
|
||||||
{canOpenSite && (
|
{canOpenSite && (
|
||||||
<a
|
<a
|
||||||
className="ds-link ds-link-accent"
|
className="ds-link ds-link-text"
|
||||||
href={project.demoUrl ?? undefined}
|
href={project.demoUrl ?? undefined}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Abrir projeto
|
Abrir site
|
||||||
<span className="sr-only"> (abre em nova aba)</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.githubUrl && (
|
|
||||||
<a
|
|
||||||
className="ds-link ds-link-muted"
|
|
||||||
href={project.githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Ver codigo
|
|
||||||
<span className="sr-only"> (abre em nova aba)</span>
|
<span className="sr-only"> (abre em nova aba)</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import { ProjectList } from "./ProjectList";
|
||||||
|
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
|
||||||
|
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente" e o botão de ampliar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./ProjectLightbox", () => ({
|
||||||
|
ProjectLightbox: ({ index }: { index: number }) => (
|
||||||
|
<div role="dialog" aria-label="ampliado">
|
||||||
|
aberto:{index}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Callback = IntersectionObserverCallback;
|
||||||
|
/** Cada observador criado, com o elemento que observa. */
|
||||||
|
const observers: Array<{ callback: Callback; target?: Element }> = [];
|
||||||
|
const Original = global.IntersectionObserver;
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
const items = buildShowcaseItems(projects);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
observers.length = 0;
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
private readonly record: { callback: Callback; target?: Element };
|
||||||
|
constructor(callback: Callback) {
|
||||||
|
this.record = { callback };
|
||||||
|
observers.push(this.record);
|
||||||
|
}
|
||||||
|
observe(target: Element) {
|
||||||
|
this.record.target = target;
|
||||||
|
}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.IntersectionObserver = Original;
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderList(props: Partial<Parameters<typeof ProjectList>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectList items={items} device="desktop" {...props} />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bringNear(target: Element) {
|
||||||
|
act(() => {
|
||||||
|
for (const observer of observers)
|
||||||
|
if (observer.target === target)
|
||||||
|
observer.callback(
|
||||||
|
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bringAllNear(container: HTMLElement) {
|
||||||
|
for (const item of container.querySelectorAll("li.ds-reel-item"))
|
||||||
|
bringNear(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectList", () => {
|
||||||
|
it("path: device unknown (server / before mount) -> text and links only, no media and no expand button", () => {
|
||||||
|
const { container } = renderList({ device: null });
|
||||||
|
|
||||||
|
expect(container.querySelectorAll("li")).toHaveLength(items.length);
|
||||||
|
expect(container.querySelector("[data-device]")).toBeNull();
|
||||||
|
expect(container.querySelector(".ds-reel-item-media")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /Ampliar vídeo/ })).toBeNull();
|
||||||
|
expect(screen.getAllByRole("link", { name: /Ver case/ })).toHaveLength(
|
||||||
|
items.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: desktop -> browser frame, poster only once the item is near the window", () => {
|
||||||
|
const { container } = renderList({ device: "desktop" });
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
|
||||||
|
expect(container.querySelector(".ds-phone")).toBeNull();
|
||||||
|
expect(container.querySelector("img")).toBeNull();
|
||||||
|
|
||||||
|
bringAllNear(container);
|
||||||
|
|
||||||
|
const images = [...container.querySelectorAll("img")];
|
||||||
|
expect(images).toHaveLength(items.length);
|
||||||
|
expect(images[0]).toHaveAttribute(
|
||||||
|
"alt",
|
||||||
|
`Captura de tela de ${items[0].project.title}`,
|
||||||
|
);
|
||||||
|
expect(decodeURIComponent(images[0].getAttribute("src") ?? "")).toContain(
|
||||||
|
"desktop.webp",
|
||||||
|
);
|
||||||
|
expect(images[0]).toHaveAttribute("sizes", "50vw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
|
||||||
|
const { container } = renderList({ device: "mobile" });
|
||||||
|
bringAllNear(container);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".ds-phone")).toHaveLength(items.length);
|
||||||
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
const image = container.querySelector("img") as HTMLImageElement;
|
||||||
|
expect(decodeURIComponent(image.getAttribute("src") ?? "")).toContain(
|
||||||
|
"mobile.webp",
|
||||||
|
);
|
||||||
|
expect(image).toHaveAttribute("sizes", "(max-width: 767px) 70vw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: an item that is not near yet keeps its frame but no image", () => {
|
||||||
|
const { container } = renderList({ device: "desktop" });
|
||||||
|
bringNear(container.querySelector("li.ds-reel-item") as Element);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll("img")).toHaveLength(1);
|
||||||
|
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: project with a subtitle -> shown; without -> no subtitle paragraph", () => {
|
||||||
|
const withSubtitle = items.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
project: { ...item.project, subtitle: index === 0 ? "Loja virtual" : "" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectList items={withSubtitle} device="desktop" />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subs = container.querySelectorAll(".ds-reel-sub");
|
||||||
|
expect(subs).toHaveLength(1);
|
||||||
|
expect(subs[0]).toHaveTextContent("Loja virtual");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: pending -> the section is marked so CSS can hide it; not pending -> unmarked", () => {
|
||||||
|
const pending = renderList({ pending: true, device: null });
|
||||||
|
expect(pending.container.querySelector("section")).toHaveAttribute(
|
||||||
|
"data-pending",
|
||||||
|
);
|
||||||
|
pending.unmount();
|
||||||
|
|
||||||
|
const settled = renderList({ pending: false });
|
||||||
|
expect(settled.container.querySelector("section")).not.toHaveAttribute(
|
||||||
|
"data-pending",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: pressing 'Ampliar vídeo' on an item -> opens the enlarged window on that project", () => {
|
||||||
|
renderList({ device: "desktop" });
|
||||||
|
const second = screen
|
||||||
|
.getByRole("heading", { level: 3, name: items[1].project.title })
|
||||||
|
.closest("li") as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(second).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||||
|
"aberto:1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { type CSSProperties, useId, useRef } from "react";
|
||||||
|
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
|
import { PhoneFrame } from "./PhoneFrame";
|
||||||
|
import { ProjectActions } from "./ProjectActions";
|
||||||
|
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||||
|
import { useNearViewport } from "./useShowcaseMedia";
|
||||||
|
|
||||||
|
function ListItem({
|
||||||
|
item,
|
||||||
|
device,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
item: ShowcaseItem;
|
||||||
|
/** `null` no servidor e até montar: o item mostra só o texto e os links. */
|
||||||
|
device: Device | null;
|
||||||
|
onExpand?: () => void;
|
||||||
|
}) {
|
||||||
|
const { config, project } = item;
|
||||||
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
|
const near = useNearViewport(ref);
|
||||||
|
const assets = device ? getShowcaseAssets(config.id, device) : null;
|
||||||
|
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela
|
||||||
|
const poster =
|
||||||
|
assets && near ? (
|
||||||
|
<Image
|
||||||
|
src={assets.poster}
|
||||||
|
alt={`Captura de tela de ${project.title}`}
|
||||||
|
width={assets.width}
|
||||||
|
height={assets.height}
|
||||||
|
sizes={device === "mobile" ? "(max-width: 767px) 70vw" : "50vw"}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className="ds-reel-item"
|
||||||
|
data-device={device ?? undefined}
|
||||||
|
style={{ "--accent": config.accent } as CSSProperties}
|
||||||
|
>
|
||||||
|
{device && (
|
||||||
|
<div className="ds-reel-item-media">
|
||||||
|
{device === "mobile" ? (
|
||||||
|
<PhoneFrame className="ds-reel-item-phone">{poster}</PhoneFrame>
|
||||||
|
) : (
|
||||||
|
<BrowserFrame url={displayUrl(project)}>
|
||||||
|
<div className="ds-reel-item-screen">{poster}</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ds-reel-caption">
|
||||||
|
<p className="ds-kicker">
|
||||||
|
{project.category} · {project.status}
|
||||||
|
</p>
|
||||||
|
<h3 className="ds-reel-title">{project.title}</h3>
|
||||||
|
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
||||||
|
<ProjectActions item={item} onExpand={onExpand} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projetos em lista simples: sem rolagem presa, sem vídeo que acompanha a rolagem. No celular (abaixo de 768 px)
|
||||||
|
* é um carrossel por toque (rolagem horizontal com encaixe, o próximo projeto aparece pela borda); em telas
|
||||||
|
* maiores, uma grade. Serve também a quem pede menos movimento e a janelas baixas demais para a vitrine fixa.
|
||||||
|
*
|
||||||
|
* É o que o servidor entrega (texto e links, sem mídia): sem JavaScript os projetos continuam todos ali, e
|
||||||
|
* `pending` esconde a lista por CSS só onde a vitrine fixa vai assumir o lugar (largura e altura de desktop).
|
||||||
|
* Pôster e "Ampliar vídeo" entram depois que o navegador monta; vídeo, só dentro da janela ampliada.
|
||||||
|
*/
|
||||||
|
export function ProjectList({
|
||||||
|
items,
|
||||||
|
device,
|
||||||
|
pending = false,
|
||||||
|
}: {
|
||||||
|
items: ShowcaseItem[];
|
||||||
|
device: Device | null;
|
||||||
|
pending?: boolean;
|
||||||
|
}) {
|
||||||
|
const open = useShowcaseOpen();
|
||||||
|
const headingId = useId();
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="ds-reel-list"
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
data-pending={pending ? "" : undefined}
|
||||||
|
>
|
||||||
|
<h2 id={headingId} className="sr-only">
|
||||||
|
Projetos de clientes
|
||||||
|
</h2>
|
||||||
|
<ul className="ds-reel-items">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ListItem
|
||||||
|
key={item.config.id}
|
||||||
|
item={item}
|
||||||
|
device={device}
|
||||||
|
onExpand={device ? () => open(item.config.id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { MOBILE_QUERY } from "@/lib/device";
|
||||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
import { ProjectMedia } from "./ProjectMedia";
|
import { ProjectMedia } from "./ProjectMedia";
|
||||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
@@ -18,6 +20,16 @@ if (!nutriscan) throw new Error("projeto ausente");
|
|||||||
type Callback = IntersectionObserverCallback;
|
type Callback = IntersectionObserverCallback;
|
||||||
const callbacks: Callback[] = [];
|
const callbacks: Callback[] = [];
|
||||||
const Original = global.IntersectionObserver;
|
const Original = global.IntersectionObserver;
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
function mockPhone() {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query === MOBILE_QUERY,
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
@@ -43,6 +55,11 @@ afterEach(() => {
|
|||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
global.IntersectionObserver = Original;
|
global.IntersectionObserver = Original;
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderCard() {
|
function renderCard() {
|
||||||
@@ -156,6 +173,69 @@ describe("ProjectMedia", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends no media at all in the server HTML: the device is only known in the browser", () => {
|
||||||
|
const html = renderToString(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("ds-frame-screen");
|
||||||
|
expect(html).not.toContain("<video");
|
||||||
|
expect(html).not.toContain("/videos/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the poster only once the cover is within about a screen of the window", () => {
|
||||||
|
const { frame, container } = renderCard();
|
||||||
|
const screenElement = container.querySelector(
|
||||||
|
".ds-frame-screen",
|
||||||
|
) as HTMLElement;
|
||||||
|
const video = container.querySelector("video") as HTMLVideoElement;
|
||||||
|
expect(screenElement.style.backgroundImage).toBe("");
|
||||||
|
expect(video).not.toHaveAttribute("poster");
|
||||||
|
expect(video).toHaveAttribute("preload", "metadata");
|
||||||
|
|
||||||
|
show(frame);
|
||||||
|
expect(screenElement.style.backgroundImage).toContain(
|
||||||
|
"/videos/nutriscan/desktop.webp",
|
||||||
|
);
|
||||||
|
expect(video).toHaveAttribute("poster", "/videos/nutriscan/desktop.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the phone version of the poster and the video below 768 px", () => {
|
||||||
|
mockPhone();
|
||||||
|
const { frame, container } = renderCard();
|
||||||
|
show(frame);
|
||||||
|
|
||||||
|
const screenElement = container.querySelector(
|
||||||
|
".ds-frame-screen",
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(screenElement.style.backgroundImage).toContain(
|
||||||
|
"/videos/nutriscan/mobile.webp",
|
||||||
|
);
|
||||||
|
const sources = [...container.querySelectorAll("video source")].map(
|
||||||
|
(source) => source.getAttribute("src"),
|
||||||
|
);
|
||||||
|
expect(sources).toEqual([
|
||||||
|
"/videos/nutriscan/mobile.webm",
|
||||||
|
"/videos/nutriscan/mobile.mp4",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the poster and never attaches a video when the data saver is on", () => {
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: { saveData: true },
|
||||||
|
});
|
||||||
|
const { frame, container } = renderCard();
|
||||||
|
show(frame);
|
||||||
|
|
||||||
|
expect(container.querySelector("video")).toBeNull();
|
||||||
|
expect(container.querySelector("source")).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Captura de tela de NutriScan" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders nothing for a project without a recorded video", () => {
|
it("renders nothing for a project without a recorded video", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ShowcaseProvider projects={projects}>
|
<ShowcaseProvider projects={projects}>
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { getProjectVideo, getShowcaseConfig } from "@/lib/project-showcase";
|
|||||||
import { ProjectVideo } from "../ProjectVideo";
|
import { ProjectVideo } from "../ProjectVideo";
|
||||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
import { useShowcaseCard } from "./ShowcaseProvider";
|
import { useShowcaseCard } from "./ShowcaseProvider";
|
||||||
|
import { useNearViewport, useViewportDevice } from "./useShowcaseMedia";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capa do card com vídeo: o vídeo dentro de uma moldura de navegador, com brilho da cor do site quando é o
|
* Capa do card com vídeo: o vídeo dentro de uma moldura de navegador, com brilho da cor do site quando é o
|
||||||
* card que toca, e o botão que amplia (a tela inteira é o botão).
|
* card que toca, e o botão que amplia (a tela inteira é o botão). A versão do aparelho (celular abaixo de
|
||||||
|
* 768 px) só é escolhida no navegador; no servidor e antes de a capa chegar a ~1 tela da janela, nem o pôster
|
||||||
|
* é pedido (a moldura já reserva o espaço).
|
||||||
*/
|
*/
|
||||||
export function ProjectMedia({
|
export function ProjectMedia({
|
||||||
project,
|
project,
|
||||||
@@ -18,15 +21,16 @@ export function ProjectMedia({
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
demoUrl?: string | null;
|
demoUrl?: string | null;
|
||||||
githubUrl?: string | null;
|
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { frameRef, active, open, userPaused, toggleAutoplay } =
|
const { frameRef, active, open, userPaused, toggleAutoplay } =
|
||||||
useShowcaseCard(project.id);
|
useShowcaseCard(project.id);
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
const video = getProjectVideo(project.id);
|
const device = useViewportDevice();
|
||||||
|
const near = useNearViewport(frameRef);
|
||||||
|
const video = device ? getProjectVideo(project.id, device) : undefined;
|
||||||
const config = getShowcaseConfig(project.id);
|
const config = getShowcaseConfig(project.id);
|
||||||
if (!video || !config) return null;
|
if (!getProjectVideo(project.id) || !config) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -38,9 +42,20 @@ export function ProjectMedia({
|
|||||||
<BrowserFrame url={displayUrl(project)}>
|
<BrowserFrame url={displayUrl(project)}>
|
||||||
<div
|
<div
|
||||||
className="ds-frame-screen"
|
className="ds-frame-screen"
|
||||||
style={{ backgroundImage: `url(${video.poster})` }}
|
style={
|
||||||
|
video && near
|
||||||
|
? { backgroundImage: `url(${video.poster})` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProjectVideo video={video} title={project.title} active={active} />
|
{video && (
|
||||||
|
<ProjectVideo
|
||||||
|
video={video}
|
||||||
|
title={project.title}
|
||||||
|
active={active}
|
||||||
|
showPoster={near}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ds-frame-expand"
|
className="ds-frame-expand"
|
||||||
|
|||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { MOBILE_QUERY } from "@/lib/device";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import { getShowcaseConfig } from "@/lib/project-showcase";
|
||||||
|
import { ProjectReel } from "./ProjectReel";
|
||||||
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
|
import { reelOffset } from "./showcase-math";
|
||||||
|
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de ProjectReel (complemento de ProjectReel.test.tsx):
|
||||||
|
* - qual versão da vitrine entra (fixa só com largura e altura de desktop e sem movimento reduzido; lista em
|
||||||
|
* janela baixa; carrossel no celular; nada sem projetos) e a troca ao vivo quando a janela muda;
|
||||||
|
* - measure(): vitrine escondida por CSS, trilho sem rolagem;
|
||||||
|
* - a faixa de projetos que acompanha o ativo, a descarga de vídeos, o seek dos vídeos, o "prime" e a rolagem suave.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let reducedMotion = false;
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => reducedMotion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HEADER = 60;
|
||||||
|
let containerHeight = 7000;
|
||||||
|
let pinHeight = 800;
|
||||||
|
let reelTop = HEADER;
|
||||||
|
let phoneWidth = 90;
|
||||||
|
/** Rolagem horizontal da faixa de projetos (tablet e celular). */
|
||||||
|
let stripScrollWidth = 0;
|
||||||
|
let stripClientWidth = 0;
|
||||||
|
let buttonOffsetLeft = 0;
|
||||||
|
let mediaNetworkState = 0;
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
const mediaState: Record<string, boolean> = {};
|
||||||
|
const mediaListeners = new Map<string, Set<Listener>>();
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
function mockViewport({
|
||||||
|
phone = false,
|
||||||
|
pinned = !phone,
|
||||||
|
}: {
|
||||||
|
phone?: boolean;
|
||||||
|
pinned?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
mediaState[MOBILE_QUERY] = phone;
|
||||||
|
mediaState[PINNED_QUERY] = pinned;
|
||||||
|
mediaListeners.clear();
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
get matches() {
|
||||||
|
return Boolean(mediaState[query]);
|
||||||
|
},
|
||||||
|
media: query,
|
||||||
|
addEventListener: (_type: string, listener: Listener) => {
|
||||||
|
if (!mediaListeners.has(query)) mediaListeners.set(query, new Set());
|
||||||
|
mediaListeners.get(query)?.add(listener);
|
||||||
|
},
|
||||||
|
removeEventListener: (_type: string, listener: Listener) => {
|
||||||
|
mediaListeners.get(query)?.delete(listener);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMedia(query: string, matches: boolean) {
|
||||||
|
mediaState[query] = matches;
|
||||||
|
act(() => {
|
||||||
|
for (const listener of mediaListeners.get(query) ?? []) listener();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
const WEIGHTS = [
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
"nutriscan",
|
||||||
|
"sc-plus",
|
||||||
|
"as-servicos",
|
||||||
|
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
|
||||||
|
|
||||||
|
const originalRect = Element.prototype.getBoundingClientRect;
|
||||||
|
const descriptors = {
|
||||||
|
offsetHeight: Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetHeight",
|
||||||
|
),
|
||||||
|
offsetWidth: Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetWidth",
|
||||||
|
),
|
||||||
|
offsetLeft: Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetLeft",
|
||||||
|
),
|
||||||
|
scrollWidth: Object.getOwnPropertyDescriptor(
|
||||||
|
Element.prototype,
|
||||||
|
"scrollWidth",
|
||||||
|
),
|
||||||
|
clientWidth: Object.getOwnPropertyDescriptor(
|
||||||
|
Element.prototype,
|
||||||
|
"clientWidth",
|
||||||
|
),
|
||||||
|
networkState: Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLMediaElement.prototype,
|
||||||
|
"networkState",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
if (this.classList.contains("ds-reel")) return containerHeight;
|
||||||
|
if (this.classList.contains("ds-reel-pin")) return pinHeight;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetLeft", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return this.tagName === "BUTTON" ? buttonOffsetLeft : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(Element.prototype, "scrollWidth", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: Element) {
|
||||||
|
return this.matches(".ds-reel-index ol") ? stripScrollWidth : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(Element.prototype, "clientWidth", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: Element) {
|
||||||
|
return this.matches(".ds-reel-index ol") ? stripClientWidth : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, "networkState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => mediaNetworkState,
|
||||||
|
});
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal(
|
||||||
|
this: HTMLDialogElement,
|
||||||
|
) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||||
|
if (this.classList.contains("ds-reel"))
|
||||||
|
return {
|
||||||
|
top: reelTop,
|
||||||
|
bottom: reelTop + containerHeight,
|
||||||
|
height: containerHeight,
|
||||||
|
left: 0,
|
||||||
|
right: 1000,
|
||||||
|
width: 1000,
|
||||||
|
x: 0,
|
||||||
|
y: reelTop,
|
||||||
|
} as DOMRect;
|
||||||
|
return originalRect.call(this);
|
||||||
|
};
|
||||||
|
window.scrollTo = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Element.prototype.getBoundingClientRect = originalRect;
|
||||||
|
for (const [key, target] of [
|
||||||
|
["offsetHeight", HTMLElement.prototype],
|
||||||
|
["offsetWidth", HTMLElement.prototype],
|
||||||
|
["offsetLeft", HTMLElement.prototype],
|
||||||
|
["scrollWidth", Element.prototype],
|
||||||
|
["clientWidth", Element.prototype],
|
||||||
|
["networkState", HTMLMediaElement.prototype],
|
||||||
|
] as const) {
|
||||||
|
const original = descriptors[key];
|
||||||
|
if (original) Object.defineProperty(target, key, original);
|
||||||
|
else Reflect.deleteProperty(target, key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
reducedMotion = false;
|
||||||
|
containerHeight = 7000;
|
||||||
|
pinHeight = 800;
|
||||||
|
reelTop = HEADER;
|
||||||
|
phoneWidth = 90;
|
||||||
|
stripScrollWidth = 0;
|
||||||
|
stripClientWidth = 0;
|
||||||
|
buttonOffsetLeft = 0;
|
||||||
|
mediaNetworkState = 0;
|
||||||
|
mockViewport();
|
||||||
|
(window.scrollTo as jest.Mock).mockClear();
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReel(list = projects) {
|
||||||
|
const view = render(
|
||||||
|
<ShowcaseProvider projects={list}>
|
||||||
|
<ProjectReel />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollTo(progress: number) {
|
||||||
|
const scrollable = containerHeight - pinHeight;
|
||||||
|
reelTop = HEADER - progress * scrollable;
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
jest.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reelEl = () => document.querySelector(".ds-reel");
|
||||||
|
const listEl = () =>
|
||||||
|
screen.queryByRole("region", { name: "Projetos de clientes" });
|
||||||
|
const activeLayer = () =>
|
||||||
|
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||||
|
|
||||||
|
describe("ProjectReel: qual versão entra", () => {
|
||||||
|
it("path: no projects in the showcase -> renders nothing at all", () => {
|
||||||
|
const { container } = renderReel([]);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: wide and tall window, motion allowed -> the pinned reel, not the list", () => {
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(reelEl()).not.toBeNull();
|
||||||
|
expect(listEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: wide but short window (below 561 px of height) -> desktop list, nothing pinned", () => {
|
||||||
|
mockViewport({ phone: false, pinned: false });
|
||||||
|
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(reelEl()).toBeNull();
|
||||||
|
const list = listEl() as HTMLElement;
|
||||||
|
expect(list).not.toBeNull();
|
||||||
|
expect(list.querySelectorAll("li[data-device='desktop']")).toHaveLength(5);
|
||||||
|
expect(list.querySelector(".ds-phone")).toBeNull();
|
||||||
|
expect(list).not.toHaveAttribute("data-pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: phone -> carousel list with the phone version of every item", () => {
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(reelEl()).toBeNull();
|
||||||
|
expect(
|
||||||
|
(listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"),
|
||||||
|
).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: phone width and reduced motion at once -> still the list", () => {
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
reducedMotion = true;
|
||||||
|
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(reelEl()).toBeNull();
|
||||||
|
expect(listEl()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: server render -> the list marked as pending plus the placeholder, with no media", () => {
|
||||||
|
const html = renderToString(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectReel />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("data-pending");
|
||||||
|
expect(html).toContain("ds-reel-placeholder");
|
||||||
|
expect(html).not.toContain("<video");
|
||||||
|
expect(html).not.toContain("<img");
|
||||||
|
expect(html).not.toContain('data-device="');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the window shrinks below the pinned size -> the reel gives way to the list, and back", () => {
|
||||||
|
renderReel();
|
||||||
|
expect(reelEl()).not.toBeNull();
|
||||||
|
|
||||||
|
changeMedia(PINNED_QUERY, false);
|
||||||
|
expect(reelEl()).toBeNull();
|
||||||
|
expect(listEl()).not.toBeNull();
|
||||||
|
|
||||||
|
changeMedia(PINNED_QUERY, true);
|
||||||
|
expect(reelEl()).not.toBeNull();
|
||||||
|
expect(listEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the window crosses the phone breakpoint while the list shows -> the list switches versions", () => {
|
||||||
|
mockViewport({ phone: false, pinned: false });
|
||||||
|
renderReel();
|
||||||
|
expect(
|
||||||
|
(listEl() as HTMLElement).querySelectorAll("li[data-device='desktop']"),
|
||||||
|
).toHaveLength(5);
|
||||||
|
|
||||||
|
changeMedia(MOBILE_QUERY, true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"),
|
||||||
|
).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectReel: measure()", () => {
|
||||||
|
it("path: the reel is hidden by CSS (height 0) -> nothing is measured or downloaded (and it is when visible)", () => {
|
||||||
|
const attachedSources = () =>
|
||||||
|
[...document.querySelectorAll(".ds-reel-layer video")].filter((video) =>
|
||||||
|
video.getAttribute("src"),
|
||||||
|
);
|
||||||
|
containerHeight = 0;
|
||||||
|
const hidden = renderReel();
|
||||||
|
scrollTo(0.5);
|
||||||
|
expect(attachedSources()).toHaveLength(0);
|
||||||
|
hidden.unmount();
|
||||||
|
|
||||||
|
containerHeight = 7000;
|
||||||
|
renderReel();
|
||||||
|
scrollTo(0.5);
|
||||||
|
expect(attachedSources().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the pinned scene is as tall as the track (nothing to scroll) -> stays on the first project", () => {
|
||||||
|
containerHeight = pinHeight;
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
scrollTo(0.9);
|
||||||
|
|
||||||
|
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scrolled to a later project -> that project becomes the active layer", () => {
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
scrollTo(reelOffset(WEIGHTS, 3, 0.5));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('.ds-reel-index [aria-current="true"]'),
|
||||||
|
).toHaveAttribute("aria-label", "SC Plus Aquicultura");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectReel: faixa de projetos que acompanha o ativo", () => {
|
||||||
|
it("path: strip wider than its box -> scrolls the active button into view (minus 16 px)", () => {
|
||||||
|
stripScrollWidth = 900;
|
||||||
|
stripClientWidth = 300;
|
||||||
|
buttonOffsetLeft = 140;
|
||||||
|
const scrollToStrip = jest.fn();
|
||||||
|
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
||||||
|
try {
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(scrollToStrip).toHaveBeenCalledWith({
|
||||||
|
left: 124,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the active button sits near the start -> never scrolls to a negative position", () => {
|
||||||
|
stripScrollWidth = 900;
|
||||||
|
stripClientWidth = 300;
|
||||||
|
buttonOffsetLeft = 4;
|
||||||
|
const scrollToStrip = jest.fn();
|
||||||
|
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
||||||
|
try {
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(scrollToStrip).toHaveBeenCalledWith({
|
||||||
|
left: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: strip fits in its box -> no scrolling", () => {
|
||||||
|
stripScrollWidth = 300;
|
||||||
|
stripClientWidth = 300;
|
||||||
|
const scrollToStrip = jest.fn();
|
||||||
|
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
||||||
|
try {
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(scrollToStrip).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the browser has no Element.scrollTo -> no error and no scrolling", () => {
|
||||||
|
stripScrollWidth = 900;
|
||||||
|
stripClientWidth = 300;
|
||||||
|
|
||||||
|
expect(() => renderReel()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectReel: vídeos", () => {
|
||||||
|
const videos = () => [
|
||||||
|
...document.querySelectorAll<HTMLVideoElement>(".ds-reel-layer video"),
|
||||||
|
];
|
||||||
|
|
||||||
|
function ready(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
{ readyState = 4, duration = 30, seeking = false } = {},
|
||||||
|
) {
|
||||||
|
Object.defineProperty(video, "readyState", {
|
||||||
|
configurable: true,
|
||||||
|
value: readyState,
|
||||||
|
});
|
||||||
|
Object.defineProperty(video, "duration", {
|
||||||
|
configurable: true,
|
||||||
|
value: duration,
|
||||||
|
});
|
||||||
|
Object.defineProperty(video, "seeking", {
|
||||||
|
configurable: true,
|
||||||
|
value: seeking,
|
||||||
|
});
|
||||||
|
let time = 0;
|
||||||
|
Object.defineProperty(video, "currentTime", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => time,
|
||||||
|
set: (value: number) => {
|
||||||
|
time = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("path: videos that hold a file reference but are not attached -> released with load()", () => {
|
||||||
|
mediaNetworkState = 1;
|
||||||
|
reelTop = 6000; // longe da vitrine: ninguém fica anexado
|
||||||
|
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: videos that never loaded anything (network empty) -> nothing to release", () => {
|
||||||
|
mediaNetworkState = 0;
|
||||||
|
reelTop = 6000;
|
||||||
|
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the attached project keeps its file while the others are released", () => {
|
||||||
|
mediaNetworkState = 1;
|
||||||
|
renderReel();
|
||||||
|
scrollTo(0);
|
||||||
|
jest.mocked(HTMLMediaElement.prototype.load).mockClear();
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
const attached = videos().filter((video) => video.getAttribute("src"));
|
||||||
|
expect(attached.length).toBeGreaterThan(0);
|
||||||
|
// quem soltou foi pedido a recarregar; quem ficou com src não foi tocado
|
||||||
|
const loaded = jest.mocked(HTMLMediaElement.prototype.load).mock
|
||||||
|
.instances as unknown as HTMLMediaElement[];
|
||||||
|
for (const video of attached) expect(loaded).not.toContain(video);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a video that is still seeking -> its time is left alone", () => {
|
||||||
|
renderReel();
|
||||||
|
for (const video of videos()) ready(video, { seeking: true });
|
||||||
|
|
||||||
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||||
|
|
||||||
|
expect(videos()[0].currentTime).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a video whose metadata has not loaded (readyState 0) or has no duration -> never sought", () => {
|
||||||
|
renderReel();
|
||||||
|
const [first, second] = videos();
|
||||||
|
ready(first, { readyState: 0 });
|
||||||
|
ready(second, { duration: 0 });
|
||||||
|
|
||||||
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||||
|
|
||||||
|
expect(first.currentTime).toBe(0);
|
||||||
|
expect(second.currentTime).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a video that is ready and idle -> sought to the scroll position", () => {
|
||||||
|
renderReel();
|
||||||
|
for (const video of videos()) ready(video);
|
||||||
|
|
||||||
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||||
|
|
||||||
|
expect(videos()[0].currentTime).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: first frame decoded -> primed once (play then pause) even if loadeddata fires twice", async () => {
|
||||||
|
renderReel();
|
||||||
|
const video = videos()[0];
|
||||||
|
|
||||||
|
fireEvent(video, new Event("loadeddata"));
|
||||||
|
fireEvent(video, new Event("loadeddata"));
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(video.dataset.primed).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: priming is refused by the browser -> swallowed, no pause", async () => {
|
||||||
|
jest
|
||||||
|
.mocked(HTMLMediaElement.prototype.play)
|
||||||
|
.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
fireEvent(videos()[0], new Event("loadeddata"));
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: metadata loads before any scroll work (reel hidden by CSS, nothing measured) -> sought to the start, no error", () => {
|
||||||
|
containerHeight = 0;
|
||||||
|
renderReel();
|
||||||
|
const video = videos()[0];
|
||||||
|
ready(video);
|
||||||
|
|
||||||
|
expect(() => fireEvent(video, new Event("loadedmetadata"))).not.toThrow();
|
||||||
|
|
||||||
|
expect(video.currentTime).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: metadata loads when the scroll target is known but no frame ran yet (reel far away) -> uses the target", () => {
|
||||||
|
reelTop = 6000;
|
||||||
|
renderReel();
|
||||||
|
const video = videos()[0];
|
||||||
|
ready(video);
|
||||||
|
|
||||||
|
fireEvent(video, new Event("loadedmetadata"));
|
||||||
|
|
||||||
|
expect(video.currentTime).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: metadata loads after the scroll already moved -> the video jumps to where the scroll is", () => {
|
||||||
|
renderReel();
|
||||||
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||||
|
const video = videos()[0];
|
||||||
|
ready(video);
|
||||||
|
|
||||||
|
fireEvent(video, new Event("loadedmetadata"));
|
||||||
|
|
||||||
|
expect(video.currentTime).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectReel: rolagem suave até um projeto", () => {
|
||||||
|
function pressProject(name: string) {
|
||||||
|
fireEvent.click(
|
||||||
|
within(
|
||||||
|
screen.getByRole("navigation", { name: "Projetos da vitrine" }),
|
||||||
|
).getByRole("button", { name }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("path: pressing a project -> scrolls in animation frames and ends exactly on the target", () => {
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
pressProject("SC Plus Aquicultura");
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||||
|
expect(calls.length).toBeGreaterThan(2);
|
||||||
|
for (const [options] of calls) expect(options.behavior).toBe("instant");
|
||||||
|
const tops = calls.map(([options]) => options.top as number);
|
||||||
|
expect(tops).toEqual([...tops].sort((a, b) => a - b));
|
||||||
|
expect(tops[tops.length - 1]).toBeGreaterThan(tops[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the wheel during the scroll -> the animation stops where it is", () => {
|
||||||
|
renderReel();
|
||||||
|
pressProject("SC Plus Aquicultura");
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length;
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: touching the screen during the scroll -> the animation stops too", () => {
|
||||||
|
renderReel();
|
||||||
|
pressProject("SC Plus Aquicultura");
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("touchstart"));
|
||||||
|
});
|
||||||
|
const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length;
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a second press while the first scroll runs -> the first is cancelled, only the new target is reached", () => {
|
||||||
|
renderReel();
|
||||||
|
pressProject("AS Serviços");
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
(window.scrollTo as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
pressProject("Lavação 047");
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tops = (window.scrollTo as jest.Mock).mock.calls.map(
|
||||||
|
([options]) => options.top as number,
|
||||||
|
);
|
||||||
|
const finalTop = tops[tops.length - 1];
|
||||||
|
// o destino da Lavação (2º projeto) fica bem antes do da AS Serviços (último)
|
||||||
|
expect(finalTop).toBeLessThan(reelTopFor(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
function reelTopFor(index: number) {
|
||||||
|
const scrollable = containerHeight - pinHeight;
|
||||||
|
return reelOffset(WEIGHTS, index, 0) * scrollable;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { renderToString } from "react-dom/server";
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { MOBILE_QUERY } from "@/lib/device";
|
||||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
||||||
import { ProjectReel } from "./ProjectReel";
|
import { ProjectReel } from "./ProjectReel";
|
||||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
import { reelOffset, videoProgress } from "./showcase-math";
|
import { reelOffset, videoProgress } from "./showcase-math";
|
||||||
|
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||||
|
|
||||||
let reducedMotion = false;
|
let reducedMotion = false;
|
||||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
@@ -16,6 +18,21 @@ const CONTAINER = 7000;
|
|||||||
const SCROLLABLE = CONTAINER - PIN;
|
const SCROLLABLE = CONTAINER - PIN;
|
||||||
const HEADER = 60;
|
const HEADER = 60;
|
||||||
let reelTop = HEADER;
|
let reelTop = HEADER;
|
||||||
|
/** largura do celular da moldura: 0 simula uma moldura escondida por CSS */
|
||||||
|
let phoneWidth = 90;
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
/** Tela de desktop por padrão (cabe a vitrine fixa); `phone` simula um celular. */
|
||||||
|
function mockViewport({ phone = false }: { phone?: boolean } = {}) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches:
|
||||||
|
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const projects = getFeaturedProjects(null);
|
const projects = getFeaturedProjects(null);
|
||||||
const weights = [
|
const weights = [
|
||||||
@@ -31,6 +48,10 @@ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
|||||||
HTMLElement.prototype,
|
HTMLElement.prototype,
|
||||||
"offsetHeight",
|
"offsetHeight",
|
||||||
);
|
);
|
||||||
|
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetWidth",
|
||||||
|
);
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||||
@@ -41,6 +62,22 @@ beforeAll(() => {
|
|||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// jsdom ainda não implementa o <dialog> modal
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal(
|
||||||
|
this: HTMLDialogElement,
|
||||||
|
) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||||
if (this.classList.contains("ds-reel"))
|
if (this.classList.contains("ds-reel"))
|
||||||
return {
|
return {
|
||||||
@@ -66,12 +103,20 @@ afterAll(() => {
|
|||||||
"offsetHeight",
|
"offsetHeight",
|
||||||
originalOffsetHeight,
|
originalOffsetHeight,
|
||||||
);
|
);
|
||||||
|
if (originalOffsetWidth)
|
||||||
|
Object.defineProperty(
|
||||||
|
HTMLElement.prototype,
|
||||||
|
"offsetWidth",
|
||||||
|
originalOffsetWidth,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
reducedMotion = false;
|
reducedMotion = false;
|
||||||
reelTop = HEADER;
|
reelTop = HEADER;
|
||||||
|
phoneWidth = 90;
|
||||||
|
mockViewport();
|
||||||
(window.scrollTo as jest.Mock).mockClear();
|
(window.scrollTo as jest.Mock).mockClear();
|
||||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||||
@@ -80,6 +125,11 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderReel() {
|
function renderReel() {
|
||||||
@@ -130,12 +180,51 @@ const activeLayer = () =>
|
|||||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||||
|
|
||||||
describe("ProjectReel", () => {
|
describe("ProjectReel", () => {
|
||||||
it("renders nothing for users who prefer reduced motion", () => {
|
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
|
||||||
reducedMotion = true;
|
reducedMotion = true;
|
||||||
renderReel();
|
renderReel();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
expect(document.querySelector(".ds-reel")).toBeNull();
|
||||||
|
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||||
|
expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(5);
|
||||||
|
expect(document.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a touch carousel of posters, with nothing pinned and no video, on a phone", () => {
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
renderReel();
|
||||||
|
|
||||||
|
expect(document.querySelector(".ds-reel")).toBeNull();
|
||||||
|
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||||
|
expect(list).not.toHaveAttribute("data-pending");
|
||||||
|
expect(document.querySelector("video")).toBeNull();
|
||||||
|
// o item chega a ~1 tela da janela (o IntersectionObserver do jest.setup avisa): só então vem o pôster
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
const posters = [...list.querySelectorAll("img")].map((image) =>
|
||||||
|
decodeURIComponent(image.getAttribute("src") ?? ""),
|
||||||
|
);
|
||||||
|
expect(posters.length).toBeGreaterThan(0);
|
||||||
|
for (const poster of posters) expect(poster).toContain("mobile.webp");
|
||||||
|
expect(list.querySelectorAll(".ds-phone")).toHaveLength(5);
|
||||||
|
expect(list.querySelector(".ds-bframe")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still opens the enlarged video from the list, without any card in the page", () => {
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
renderReel();
|
||||||
|
const item = screen
|
||||||
|
.getByRole("heading", { level: 3, name: "SEV Moda Íntima" })
|
||||||
|
.closest("li") as HTMLElement;
|
||||||
|
fireEvent.click(
|
||||||
|
within(item).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists every project by name, starting on the first with its chapters", () => {
|
it("lists every project by name, starting on the first with its chapters", () => {
|
||||||
@@ -201,7 +290,7 @@ describe("ProjectReel", () => {
|
|||||||
const lavacao = getShowcaseConfig("lavacao-047");
|
const lavacao = getShowcaseConfig("lavacao-047");
|
||||||
expect(lavacao).toBeDefined();
|
expect(lavacao).toBeDefined();
|
||||||
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
||||||
/^0\d\/06$/,
|
/^0\d\/03$/,
|
||||||
);
|
);
|
||||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||||
expect(labels).toContain(
|
expect(labels).toContain(
|
||||||
@@ -237,7 +326,7 @@ describe("ProjectReel", () => {
|
|||||||
expect(sources().every((src) => src === null)).toBe(true);
|
expect(sources().every((src) => src === null)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
|
it("downloads video only for the project on screen (neighbours stay on the poster), and lets go of the rest", () => {
|
||||||
renderReel();
|
renderReel();
|
||||||
scrollTo(0);
|
scrollTo(0);
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -248,10 +337,14 @@ describe("ProjectReel", () => {
|
|||||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
|
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
// o vizinho (Lavação) e os demais ficam sem nenhum vídeo
|
||||||
|
expect(loaded.some((src) => src?.includes("lavacao-047"))).toBe(false);
|
||||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
||||||
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
||||||
|
for (const video of document.querySelectorAll(".ds-reel-layer video"))
|
||||||
|
expect(video).toHaveAttribute("preload", "metadata");
|
||||||
|
|
||||||
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
||||||
scrollTo(1);
|
scrollTo(1);
|
||||||
@@ -281,34 +374,38 @@ describe("ProjectReel", () => {
|
|||||||
});
|
});
|
||||||
const loaded = sources().filter(Boolean);
|
const loaded = sources().filter(Boolean);
|
||||||
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
|
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
|
||||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(true); // vizinho do projeto onde parou
|
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false); // vizinho: só o pôster
|
||||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not download the hidden laptop video on a phone-sized screen", () => {
|
it("does not download the phone video while the phone frame is not on screen", () => {
|
||||||
const original = window.matchMedia;
|
phoneWidth = 0;
|
||||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
renderReel();
|
||||||
matches: query.includes("max-width: 639px"),
|
scrollTo(0);
|
||||||
media: query,
|
act(() => {
|
||||||
addEventListener() {},
|
jest.advanceTimersByTime(400);
|
||||||
removeEventListener() {},
|
});
|
||||||
}));
|
const loaded = sources().filter(Boolean);
|
||||||
try {
|
expect(
|
||||||
renderReel();
|
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||||
scrollTo(0);
|
).toBe(true);
|
||||||
act(() => {
|
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(false);
|
||||||
jest.advanceTimersByTime(400);
|
});
|
||||||
});
|
|
||||||
const loaded = sources().filter(Boolean);
|
it("asks for posters only near the reel, and only for the project on screen and its neighbours", () => {
|
||||||
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(
|
reelTop = 6000;
|
||||||
true,
|
renderReel();
|
||||||
);
|
const background = (index: number) =>
|
||||||
expect(loaded.some((src) => src?.includes("scrub-desktop.mp4"))).toBe(
|
(document.querySelectorAll(".ds-reel-screen")[index] as HTMLElement).style
|
||||||
false,
|
.backgroundImage;
|
||||||
);
|
expect(background(0)).toBe("");
|
||||||
} finally {
|
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
||||||
window.matchMedia = original;
|
|
||||||
}
|
scrollTo(0); // chegou a menos de uma tela
|
||||||
|
expect(background(0)).toContain("sev-modaintima/desktop.webp");
|
||||||
|
expect(background(1)).toContain("lavacao-047/desktop.webp");
|
||||||
|
expect(background(3)).toBe("");
|
||||||
|
expect(background(4)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the data saver: no scroll video is downloaded", () => {
|
it("respects the data saver: no scroll video is downloaded", () => {
|
||||||
@@ -316,31 +413,113 @@ describe("ProjectReel", () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: { saveData: true },
|
value: { saveData: true },
|
||||||
});
|
});
|
||||||
try {
|
renderReel();
|
||||||
renderReel();
|
scrollTo(0);
|
||||||
scrollTo(0);
|
act(() => {
|
||||||
act(() => {
|
jest.advanceTimersByTime(400);
|
||||||
jest.advanceTimersByTime(400);
|
});
|
||||||
});
|
expect(sources().every((src) => src === null)).toBe(true);
|
||||||
expect(sources().every((src) => src === null)).toBe(true);
|
// o pôster continua lá
|
||||||
} finally {
|
expect(
|
||||||
Object.defineProperty(navigator, "connection", {
|
(document.querySelectorAll(".ds-reel-screen")[0] as HTMLElement).style
|
||||||
configurable: true,
|
.backgroundImage,
|
||||||
value: undefined,
|
).toContain("desktop.webp");
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
|
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
|
||||||
const html = renderToString(
|
const html = renderToString(
|
||||||
<ShowcaseProvider projects={projects}>
|
<ShowcaseProvider projects={projects}>
|
||||||
<ProjectReel />
|
<ProjectReel />
|
||||||
</ShowcaseProvider>,
|
</ShowcaseProvider>,
|
||||||
);
|
);
|
||||||
|
// lugar reservado para a vitrine fixa: a página não muda de altura quando ela aparece (salto para #contact)
|
||||||
expect(html).toContain("ds-reel-placeholder");
|
expect(html).toContain("ds-reel-placeholder");
|
||||||
expect(html).toContain("--reel-sum");
|
expect(html).toContain("--reel-sum");
|
||||||
expect(html).toContain('aria-hidden="true"');
|
expect(html).toContain('aria-hidden="true"');
|
||||||
expect(html).not.toContain("<video");
|
expect(html).not.toContain("<video");
|
||||||
|
expect(html).not.toContain("/videos/");
|
||||||
|
// sem JavaScript os projetos continuam ali, com o link do case
|
||||||
|
expect(html).toContain("ds-reel-list");
|
||||||
|
expect(html).toContain("data-pending");
|
||||||
|
expect(html).toContain("SEV Moda Íntima");
|
||||||
|
expect(html).toContain('href="/cases/lavacao-047"');
|
||||||
|
expect(html).not.toContain("Ampliar vídeo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has an h2 before the project h3, in the pinned scene and in the list", () => {
|
||||||
|
const { unmount } = renderReel();
|
||||||
|
const region = screen.getByRole("region", { name: /Vitrine animada/ });
|
||||||
|
const order = [...region.querySelectorAll("h2, h3")].map(
|
||||||
|
(heading) => heading.tagName,
|
||||||
|
);
|
||||||
|
expect(order[0]).toBe("H2");
|
||||||
|
expect(order).toContain("H3");
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
renderReel();
|
||||||
|
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||||
|
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits each project to three chapters, in the index and in the caption", () => {
|
||||||
|
renderReel();
|
||||||
|
for (const layer of document.querySelectorAll(".ds-reel-layer"))
|
||||||
|
expect(
|
||||||
|
layer.querySelectorAll(".ds-reel-ticks li").length,
|
||||||
|
).toBeLessThanOrEqual(3);
|
||||||
|
const chapters = screen.getByRole("list", {
|
||||||
|
name: "Trechos de SEV Moda Íntima",
|
||||||
|
});
|
||||||
|
expect(within(chapters).getAllByRole("button")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
|
||||||
|
renderReel();
|
||||||
|
const bars = [...document.querySelectorAll(".ds-bframe-url")].map(
|
||||||
|
(bar) => bar.textContent,
|
||||||
|
);
|
||||||
|
expect(bars).toContain("sevmodaintima.com.br");
|
||||||
|
expect(bars).toContain("scplus.evandro.dev.br");
|
||||||
|
expect(bars).toContain("AS Serviços");
|
||||||
|
expect(document.body.innerHTML).not.toContain("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers Ver case (outline), Abrir site (text link, only with a live site) and Ampliar vídeo, none of them lime", () => {
|
||||||
|
renderReel();
|
||||||
|
const layer = activeLayer();
|
||||||
|
const actions = layer.querySelector(".ds-reel-actions") as HTMLElement;
|
||||||
|
expect(
|
||||||
|
within(actions).getByRole("link", { name: /Ver case/ }),
|
||||||
|
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
|
const site = within(actions).getByRole("link", { name: /Abrir site/ });
|
||||||
|
expect(site).toHaveAttribute("href", "https://sevmodaintima.com.br");
|
||||||
|
expect(site).toHaveAttribute("target", "_blank");
|
||||||
|
expect(site).toHaveClass("ds-link-text");
|
||||||
|
expect(
|
||||||
|
within(actions).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||||
|
).toHaveClass("ds-link-muted");
|
||||||
|
expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull();
|
||||||
|
|
||||||
|
// sem demoUrl não há "Abrir site"
|
||||||
|
const asServicos = [...document.querySelectorAll(".ds-reel-layer")].at(-1);
|
||||||
|
expect(asServicos).toBeDefined();
|
||||||
|
expect(
|
||||||
|
within(asServicos as HTMLElement).queryByRole("link", {
|
||||||
|
name: /Abrir site/,
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the enlarged video from the pinned scene", () => {
|
||||||
|
renderReel();
|
||||||
|
fireEvent.click(
|
||||||
|
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scrolls to the start of a project when its button is pressed", () => {
|
it("scrolls to the start of a project when its button is pressed", () => {
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import {
|
|||||||
} from "@/lib/project-showcase";
|
} from "@/lib/project-showcase";
|
||||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
import { PhoneFrame } from "./PhoneFrame";
|
import { PhoneFrame } from "./PhoneFrame";
|
||||||
import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider";
|
import { ProjectActions } from "./ProjectActions";
|
||||||
|
import { ProjectList } from "./ProjectList";
|
||||||
|
import {
|
||||||
|
type ShowcaseItem,
|
||||||
|
useShowcaseItems,
|
||||||
|
useShowcaseOpen,
|
||||||
|
} from "./ShowcaseProvider";
|
||||||
import {
|
import {
|
||||||
easeToward,
|
easeToward,
|
||||||
locateInReel,
|
locateInReel,
|
||||||
@@ -26,6 +32,11 @@ import {
|
|||||||
reelOffset,
|
reelOffset,
|
||||||
videoProgress,
|
videoProgress,
|
||||||
} from "./showcase-math";
|
} from "./showcase-math";
|
||||||
|
import {
|
||||||
|
usePinnedViewport,
|
||||||
|
useSaveData,
|
||||||
|
useViewportDevice,
|
||||||
|
} from "./useShowcaseMedia";
|
||||||
|
|
||||||
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
||||||
let headerCache = { width: -1, height: 60 };
|
let headerCache = { width: -1, height: 60 };
|
||||||
@@ -43,25 +54,6 @@ const headerHeight = () => {
|
|||||||
return headerCache.height;
|
return headerCache.height;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */
|
|
||||||
function usePhoneOnly() {
|
|
||||||
const [phoneOnly, setPhoneOnly] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window.matchMedia !== "function") return;
|
|
||||||
const query = window.matchMedia("(max-width: 639px)");
|
|
||||||
const update = () => setPhoneOnly(query.matches);
|
|
||||||
update();
|
|
||||||
query.addEventListener("change", update);
|
|
||||||
return () => query.removeEventListener("change", update);
|
|
||||||
}, []);
|
|
||||||
return phoneOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */
|
|
||||||
const saveDataOn = () =>
|
|
||||||
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection
|
|
||||||
?.saveData === true;
|
|
||||||
|
|
||||||
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
|
/** 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) {
|
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||||
if (!video || video.readyState < 1 || !video.duration) return;
|
if (!video || video.readyState < 1 || !video.duration) return;
|
||||||
@@ -111,25 +103,68 @@ function scrollToSmoothly(top: number) {
|
|||||||
id = requestAnimationFrame(step);
|
id = requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projetos da home. Em tablet e desktop (768 px de largura e 561 px de altura, no mínimo), a vitrine animada:
|
||||||
|
* o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a página rola, com a
|
||||||
|
* cena presa (position: sticky) durante a rolagem de todos os projetos. No celular, com prefers-reduced-motion
|
||||||
|
* e em janelas baixas, nada fica preso: é a lista de projetos (carrossel por toque no celular), só com pôsteres.
|
||||||
|
* O servidor entrega a lista (texto e links); o navegador troca pela vitrine fixa quando ela cabe, e até lá
|
||||||
|
* um bloco da mesma altura segura o lugar dela. Funciona sozinha dentro de `ShowcaseProvider`: não depende
|
||||||
|
* dos cards da grade.
|
||||||
|
*/
|
||||||
|
export function ProjectReel() {
|
||||||
|
const items = useShowcaseItems();
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const wide = usePinnedViewport();
|
||||||
|
const device = useViewportDevice();
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const mounted = wide !== null && device !== null;
|
||||||
|
if (mounted && wide && !reducedMotion) return <PinnedReel items={items} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProjectList items={items} device={device} pending={!mounted} />
|
||||||
|
{!mounted && (
|
||||||
|
// antes de montar, um bloco da mesma altura segura o lugar da vitrine fixa: a página não muda de
|
||||||
|
// tamanho quando ela aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS só o
|
||||||
|
// mostra onde a vitrine fixa vai existir (e a lista, nesses casos, fica escondida)
|
||||||
|
<div
|
||||||
|
className="ds-reel ds-reel-placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--reel-sum": items.reduce(
|
||||||
|
(sum, item) => sum + item.config.weight,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
|
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
|
||||||
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
|
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
|
||||||
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
|
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
|
||||||
* mostram o mesmo trecho do site ao mesmo tempo.
|
* mostram o mesmo trecho do site ao mesmo tempo.
|
||||||
*
|
*
|
||||||
* Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel.
|
* Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela
|
||||||
|
* da janela; com "economizar dados" ninguém baixa vídeo. O vídeo de celular só é pedido se a moldura do
|
||||||
|
* celular está à vista.
|
||||||
*/
|
*/
|
||||||
export function ProjectReel() {
|
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||||
const items = useShowcaseItems();
|
|
||||||
const openProject = useShowcaseOpen();
|
const openProject = useShowcaseOpen();
|
||||||
const reducedMotion = useReducedMotion();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [chapter, setChapter] = useState(0);
|
const [chapter, setChapter] = useState(0);
|
||||||
const [near, setNear] = useState(false);
|
const [near, setNear] = useState(false);
|
||||||
const [settled, setSettled] = useState(0);
|
const [settled, setSettled] = useState(0);
|
||||||
const [saveData, setSaveData] = useState(false);
|
const [phoneVisible, setPhoneVisible] = useState(true);
|
||||||
const phoneOnly = usePhoneOnly();
|
const saveData = useSaveData();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const pinRef = useRef<HTMLDivElement>(null);
|
const pinRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -145,6 +180,7 @@ export function ProjectReel() {
|
|||||||
raf: 0,
|
raf: 0,
|
||||||
last: 0,
|
last: 0,
|
||||||
near: false,
|
near: false,
|
||||||
|
phone: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const weights = useMemo(
|
const weights = useMemo(
|
||||||
@@ -153,13 +189,8 @@ export function ProjectReel() {
|
|||||||
);
|
);
|
||||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
|
||||||
useEffect(() => {
|
// só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); e só depois que a rolagem parou ali (passar
|
||||||
setSaveData(saveDataOn());
|
// voando por um projeto no clique do índice não pode baixar todos os do caminho)
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por
|
|
||||||
// um projeto no clique do índice não pode baixar todos os do caminho)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setSettled(active), 300);
|
const timer = setTimeout(() => setSettled(active), 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -183,9 +214,11 @@ export function ProjectReel() {
|
|||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
const isAttached = useCallback(
|
const isAttached = useCallback(
|
||||||
(index: number) => near && !saveData && Math.abs(index - settled) <= 1,
|
(index: number) => near && !saveData && index === settled,
|
||||||
[near, saveData, settled],
|
[near, saveData, settled],
|
||||||
);
|
);
|
||||||
|
// o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto
|
||||||
|
const showsPoster = (index: number) => near && Math.abs(index - active) <= 1;
|
||||||
|
|
||||||
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -266,12 +299,20 @@ export function ProjectReel() {
|
|||||||
state.target = items.map((_, i) =>
|
state.target = items.map((_, i) =>
|
||||||
i < index ? 1 : i === index ? videoProgress(local) : 0,
|
i < index ? 1 : i === index ? videoProgress(local) : 0,
|
||||||
);
|
);
|
||||||
|
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
|
||||||
const near =
|
const near =
|
||||||
rect.top < window.innerHeight * 2 && rect.bottom > -window.innerHeight;
|
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
||||||
if (near !== state.near) {
|
if (near !== state.near) {
|
||||||
state.near = near;
|
state.near = near;
|
||||||
setNear(near);
|
setNear(near);
|
||||||
}
|
}
|
||||||
|
// o celular da moldura só baixa vídeo se está à vista (tem tamanho na tela)
|
||||||
|
const phone = container.querySelector<HTMLElement>(".ds-reel-phone");
|
||||||
|
const phoneShown = Boolean(phone && phone.offsetWidth > 0);
|
||||||
|
if (phoneShown !== state.phone) {
|
||||||
|
state.phone = phoneShown;
|
||||||
|
setPhoneVisible(phoneShown);
|
||||||
|
}
|
||||||
|
|
||||||
if (index !== state.active) {
|
if (index !== state.active) {
|
||||||
state.active = index;
|
state.active = index;
|
||||||
@@ -289,7 +330,6 @@ export function ProjectReel() {
|
|||||||
}, [frame, items, weights]);
|
}, [frame, items, weights]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || reducedMotion || items.length === 0) return;
|
|
||||||
let pending = 0;
|
let pending = 0;
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
if (!pending)
|
if (!pending)
|
||||||
@@ -309,7 +349,7 @@ export function ProjectReel() {
|
|||||||
cancelAnimationFrame(state.raf);
|
cancelAnimationFrame(state.raf);
|
||||||
state.raf = 0;
|
state.raf = 0;
|
||||||
};
|
};
|
||||||
}, [mounted, reducedMotion, items.length, measure]);
|
}, [measure]);
|
||||||
|
|
||||||
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||||
const scrollToReel = (index: number, local: number) => {
|
const scrollToReel = (index: number, local: number) => {
|
||||||
@@ -334,20 +374,6 @@ export function ProjectReel() {
|
|||||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (items.length === 0 || (mounted && reducedMotion)) return null;
|
|
||||||
|
|
||||||
// antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine
|
|
||||||
// aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com
|
|
||||||
// prefers-reduced-motion e em telas baixas
|
|
||||||
if (!mounted)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="ds-reel ds-reel-placeholder"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const current = items[active];
|
const current = items[active];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -357,6 +383,7 @@ export function ProjectReel() {
|
|||||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
|
<h2 className="sr-only">Projetos de clientes</h2>
|
||||||
<div className="ds-reel-pin" ref={pinRef}>
|
<div className="ds-reel-pin" ref={pinRef}>
|
||||||
<div className="ds-reel-grid">
|
<div className="ds-reel-grid">
|
||||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||||
@@ -374,9 +401,12 @@ export function ProjectReel() {
|
|||||||
aria-label={item.project.title}
|
aria-label={item.project.title}
|
||||||
onClick={() => goTo(index)}
|
onClick={() => goTo(index)}
|
||||||
>
|
>
|
||||||
<span className="ds-reel-num">
|
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
|
||||||
{String(index + 1).padStart(2, "0")}
|
<span
|
||||||
</span>
|
className="ds-reel-num"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-n={String(index + 1).padStart(2, "0")}
|
||||||
|
/>
|
||||||
<span className="ds-reel-name">{item.project.title}</span>
|
<span className="ds-reel-name">{item.project.title}</span>
|
||||||
<i
|
<i
|
||||||
className="ds-reel-bar"
|
className="ds-reel-bar"
|
||||||
@@ -417,6 +447,7 @@ export function ProjectReel() {
|
|||||||
const { config, project } = item;
|
const { config, project } = item;
|
||||||
const isActive = index === active;
|
const isActive = index === active;
|
||||||
const attach = isAttached(index);
|
const attach = isAttached(index);
|
||||||
|
const posters = showsPoster(index);
|
||||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||||
const phone = getShowcaseAssets(config.id, "mobile");
|
const phone = getShowcaseAssets(config.id, "mobile");
|
||||||
return (
|
return (
|
||||||
@@ -438,22 +469,25 @@ export function ProjectReel() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="ds-reel-screen"
|
className="ds-reel-screen"
|
||||||
style={{ backgroundImage: `url(${desktop.poster})` }}
|
style={
|
||||||
|
posters
|
||||||
|
? { backgroundImage: `url(${desktop.poster})` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
|
||||||
<video
|
<video
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
desktopVideos.current[index] = element;
|
desktopVideos.current[index] = element;
|
||||||
}}
|
}}
|
||||||
src={
|
src={
|
||||||
attach && !phoneOnly
|
attach
|
||||||
? getScrubSource(config.id, "desktop")
|
? getScrubSource(config.id, "desktop")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
poster={desktop.poster}
|
poster={posters ? desktop.poster : undefined}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload={attach && !phoneOnly ? "auto" : "none"}
|
preload="metadata"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
@@ -465,22 +499,21 @@ export function ProjectReel() {
|
|||||||
</BrowserFrame>
|
</BrowserFrame>
|
||||||
<PhoneFrame
|
<PhoneFrame
|
||||||
className="ds-reel-phone"
|
className="ds-reel-phone"
|
||||||
poster={phone.poster}
|
poster={posters ? phone.poster : undefined}
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
|
||||||
<video
|
<video
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
phoneVideos.current[index] = element;
|
phoneVideos.current[index] = element;
|
||||||
}}
|
}}
|
||||||
src={
|
src={
|
||||||
attach
|
attach && phoneVisible
|
||||||
? getScrubSource(config.id, "mobile")
|
? getScrubSource(config.id, "mobile")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
poster={phone.poster}
|
poster={posters ? phone.poster : undefined}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload={attach ? "auto" : "none"}
|
preload="metadata"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
@@ -525,31 +558,10 @@ export function ProjectReel() {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-reel-actions">
|
<ProjectActions
|
||||||
<button
|
item={item}
|
||||||
type="button"
|
onExpand={() => openProject(config.id)}
|
||||||
className="ds-link ds-link-accent"
|
/>
|
||||||
onClick={() => openProject(config.id)}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
>
|
|
||||||
Ampliar vídeo
|
|
||||||
</button>
|
|
||||||
{project.demoUrl &&
|
|
||||||
project.demoAvailability !== "unavailable" && (
|
|
||||||
<a
|
|
||||||
className="ds-link ds-link-muted"
|
|
||||||
href={project.demoUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Abrir projeto
|
|
||||||
<span className="sr-only">
|
|
||||||
{" "}
|
|
||||||
(abre em nova aba)
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import {
|
||||||
|
buildShowcaseItems,
|
||||||
|
ShowcaseProvider,
|
||||||
|
useShowcaseCard,
|
||||||
|
useShowcaseItems,
|
||||||
|
useShowcaseOpen,
|
||||||
|
} from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx): register() sem
|
||||||
|
* IntersectionObserver, limiar de visibilidade, quem devolve o mouse, desmontagem do card em destaque, foco
|
||||||
|
* que sai para dentro ou para fora do card, aba oculta, pausa do usuário e a janela ampliada.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let reducedMotion = false;
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => reducedMotion,
|
||||||
|
}));
|
||||||
|
jest.mock("./ProjectLightbox", () => ({
|
||||||
|
ProjectLightbox: ({
|
||||||
|
index,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
items: unknown[];
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate: (index: number) => void;
|
||||||
|
}) => (
|
||||||
|
<div role="dialog" aria-label="ampliado">
|
||||||
|
aberto:{index} de {items.length}
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
fechar
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onNavigate(0)}>
|
||||||
|
ir para o primeiro
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onNavigate(99)}>
|
||||||
|
ir para um que não existe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type FakeObserver = {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
targets: Set<Element>;
|
||||||
|
};
|
||||||
|
let observers: FakeObserver[] = [];
|
||||||
|
const OriginalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
private readonly state: FakeObserver;
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.state = { callback, targets: new Set() };
|
||||||
|
observers.push(this.state);
|
||||||
|
}
|
||||||
|
observe(element: Element) {
|
||||||
|
this.state.targets.add(element);
|
||||||
|
}
|
||||||
|
unobserve(element: Element) {
|
||||||
|
this.state.targets.delete(element);
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this.state.targets.clear();
|
||||||
|
}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(target: Element, ratio: number) {
|
||||||
|
act(() => {
|
||||||
|
for (const observer of observers) {
|
||||||
|
observer.callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
target,
|
||||||
|
intersectionRatio: ratio,
|
||||||
|
isIntersecting: ratio > 0,
|
||||||
|
} as IntersectionObserverEntry,
|
||||||
|
],
|
||||||
|
observer as unknown as IntersectionObserver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(element: Element, top: number) {
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top,
|
||||||
|
height: 300,
|
||||||
|
bottom: top + 300,
|
||||||
|
left: 0,
|
||||||
|
right: 400,
|
||||||
|
width: 400,
|
||||||
|
x: 0,
|
||||||
|
y: top,
|
||||||
|
}) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ id }: { id: string }) {
|
||||||
|
const { frameRef, active, open, toggleAutoplay, userPaused } =
|
||||||
|
useShowcaseCard(id);
|
||||||
|
return (
|
||||||
|
<article className="ds-card">
|
||||||
|
<div ref={frameRef} data-testid={id} data-active={active} />
|
||||||
|
<button type="button" onClick={open}>
|
||||||
|
ampliar {id}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={toggleAutoplay}>
|
||||||
|
pausa {id} {String(userPaused)}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
|
||||||
|
function Cards({ ids }: { ids: string[] }) {
|
||||||
|
return (
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
{ids.map((id) => (
|
||||||
|
<Card key={id} id={id} />
|
||||||
|
))}
|
||||||
|
</ShowcaseProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(ids = ["sev-modaintima", "lavacao-047"]) {
|
||||||
|
const view = render(<Cards ids={ids} />);
|
||||||
|
const frame = (id: string) => screen.getByTestId(id);
|
||||||
|
// tela de 768px de altura no jsdom: o meio fica em 384
|
||||||
|
if (ids.includes("sev-modaintima")) place(frame("sev-modaintima"), 100); // centro 250
|
||||||
|
if (ids.includes("lavacao-047")) place(frame("lavacao-047"), 230); // centro 380
|
||||||
|
return { view, frame };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = (element: Element) =>
|
||||||
|
element.getAttribute("data-active") === "true";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
reducedMotion = false;
|
||||||
|
observers = [];
|
||||||
|
global.IntersectionObserver =
|
||||||
|
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
global.IntersectionObserver = OriginalObserver;
|
||||||
|
Reflect.deleteProperty(document, "hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ShowcaseProvider: IntersectionObserver e visibilidade", () => {
|
||||||
|
it("path: browser without IntersectionObserver -> cards register fine but none ever plays", () => {
|
||||||
|
// @ts-expect-error simula um navegador sem IntersectionObserver
|
||||||
|
global.IntersectionObserver = undefined;
|
||||||
|
|
||||||
|
const { view, frame } = setup();
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||||
|
expect(() => view.unmount()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0.32, false],
|
||||||
|
[0.33, true],
|
||||||
|
[1, true],
|
||||||
|
])("path: %f of the card on screen -> visible: %s", (ratio, expected) => {
|
||||||
|
const { frame } = setup(["sev-modaintima"]);
|
||||||
|
|
||||||
|
report(frame("sev-modaintima"), ratio);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a visibility report about an element that is not a card -> ignored", () => {
|
||||||
|
const { frame } = setup(["sev-modaintima"]);
|
||||||
|
const stranger = document.createElement("div");
|
||||||
|
report(frame("sev-modaintima"), 0.8);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
report(stranger, 1);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the card leaves the screen -> it stops being the active one", () => {
|
||||||
|
const { frame } = setup(["sev-modaintima"]);
|
||||||
|
report(frame("sev-modaintima"), 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
|
||||||
|
report(frame("sev-modaintima"), 0);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: hidden tab -> nobody plays; back to visible -> the closest card plays again", () => {
|
||||||
|
const { frame } = setup();
|
||||||
|
report(frame("sev-modaintima"), 0.8);
|
||||||
|
report(frame("lavacao-047"), 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "hidden", {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "hidden", {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ShowcaseProvider: mouse e foco", () => {
|
||||||
|
function hoverSetup() {
|
||||||
|
const result = setup();
|
||||||
|
report(result.frame("sev-modaintima"), 0.8);
|
||||||
|
report(result.frame("lavacao-047"), 0.8);
|
||||||
|
flush();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("path: a pen counts as a pointer that hovers", () => {
|
||||||
|
const { frame } = hoverSetup();
|
||||||
|
|
||||||
|
fireEvent.pointerEnter(
|
||||||
|
frame("sev-modaintima").closest(".ds-card") as HTMLElement,
|
||||||
|
{ pointerType: "pen" },
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: another card's pointer leaves -> the hovered card keeps the video", () => {
|
||||||
|
const { frame } = hoverSetup();
|
||||||
|
const sev = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||||
|
const lavacao = frame("lavacao-047").closest(".ds-card") as HTMLElement;
|
||||||
|
fireEvent.pointerEnter(sev, { pointerType: "mouse" });
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(lavacao, { pointerType: "mouse" });
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the hovered card is not on screen -> the closest visible card plays instead", () => {
|
||||||
|
const { frame } = hoverSetup();
|
||||||
|
report(frame("sev-modaintima"), 0);
|
||||||
|
fireEvent.pointerEnter(
|
||||||
|
frame("sev-modaintima").closest(".ds-card") as HTMLElement,
|
||||||
|
{ pointerType: "mouse" },
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: focus moves to another element inside the same card -> hover is kept", () => {
|
||||||
|
const { frame } = hoverSetup();
|
||||||
|
const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||||
|
const inside = screen.getByRole("button", {
|
||||||
|
name: /ampliar sev-modaintima/,
|
||||||
|
});
|
||||||
|
fireEvent.focusIn(inside);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.focusOut(card, { relatedTarget: inside });
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"to another card",
|
||||||
|
() => screen.getByRole("button", { name: /ampliar lavacao-047/ }),
|
||||||
|
],
|
||||||
|
["to nothing", () => null],
|
||||||
|
])(
|
||||||
|
"path: focus leaves the card %s -> hover is released",
|
||||||
|
(_label, target) => {
|
||||||
|
const { frame } = hoverSetup();
|
||||||
|
const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||||
|
fireEvent.focusIn(
|
||||||
|
screen.getByRole("button", { name: /ampliar sev-modaintima/ }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.focusOut(card, { relatedTarget: target() });
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: the hovered card is removed from the page -> its hover is dropped and another card plays", () => {
|
||||||
|
function Swap() {
|
||||||
|
const [showFirst, setShowFirst] = useState(true);
|
||||||
|
return (
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
{showFirst && <Card id="sev-modaintima" />}
|
||||||
|
<Card id="lavacao-047" />
|
||||||
|
<button type="button" onClick={() => setShowFirst(false)}>
|
||||||
|
remover o primeiro
|
||||||
|
</button>
|
||||||
|
</ShowcaseProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Swap />);
|
||||||
|
const sev = screen.getByTestId("sev-modaintima");
|
||||||
|
const lavacao = screen.getByTestId("lavacao-047");
|
||||||
|
place(sev, 100);
|
||||||
|
place(lavacao, 230);
|
||||||
|
report(sev, 0.8);
|
||||||
|
report(lavacao, 0.8);
|
||||||
|
fireEvent.pointerEnter(sev.closest(".ds-card") as HTMLElement, {
|
||||||
|
pointerType: "mouse",
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
expect(isActive(sev)).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "remover o primeiro" }));
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("sev-modaintima")).toBeNull();
|
||||||
|
expect(isActive(lavacao)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ShowcaseProvider: pausa e janela ampliada", () => {
|
||||||
|
it("path: the visitor pauses and resumes -> no card plays while paused", () => {
|
||||||
|
const { frame } = setup(["lavacao-047"]);
|
||||||
|
report(frame("lavacao-047"), 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /pausa lavacao-047 false/ }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /pausa lavacao-047 true/ }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: opening a project that is not part of the showcase -> no window", () => {
|
||||||
|
function OpenOutsider() {
|
||||||
|
const { open } = useShowcaseCard("reddit-explorer");
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={open}>
|
||||||
|
ampliar reddit-explorer
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<OpenOutsider />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /ampliar reddit-explorer/ }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: navigating in the window to a valid index -> changes project; to an invalid one -> closes", () => {
|
||||||
|
setup();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /ampliar lavacao-047/ }),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "ir para o primeiro" }));
|
||||||
|
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0");
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "ir para um que não existe" }),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: reduced motion -> pausing and resuming never makes a card play", () => {
|
||||||
|
reducedMotion = true;
|
||||||
|
const { frame } = setup(["lavacao-047"]);
|
||||||
|
report(frame("lavacao-047"), 1);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /pausa lavacao-047 false/ }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /pausa lavacao-047 true/ }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ShowcaseProvider: trabalho por quadro", () => {
|
||||||
|
it("path: no card registered -> scroll and resize schedule no frame", () => {
|
||||||
|
const raf = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<p>sem cards</p>
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
raf.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(raf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: several scrolls inside one frame -> evaluated once", () => {
|
||||||
|
const { frame } = setup(["lavacao-047"]);
|
||||||
|
report(frame("lavacao-047"), 0.8);
|
||||||
|
flush();
|
||||||
|
const raf = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
raf.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(raf).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ShowcaseProvider: ganchos fora do provedor e cartão sem .ds-card", () => {
|
||||||
|
it("path: hooks used outside the provider -> empty list, and open/toggle do nothing", () => {
|
||||||
|
function Outsider() {
|
||||||
|
const items = useShowcaseItems();
|
||||||
|
const open = useShowcaseOpen();
|
||||||
|
const { toggleAutoplay, open: openCard, active } = useShowcaseCard("x");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
open("sev-modaintima");
|
||||||
|
openCard();
|
||||||
|
toggleAutoplay();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
itens:{items.length} ativo:{String(active)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Outsider />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "itens:0 ativo:false" });
|
||||||
|
expect(() => fireEvent.click(button)).not.toThrow();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a card whose frame has no .ds-card ancestor -> the frame itself listens for the pointer", () => {
|
||||||
|
function Bare() {
|
||||||
|
const { frameRef, active } = useShowcaseCard("lavacao-047");
|
||||||
|
return <div ref={frameRef} data-testid="bare" data-active={active} />;
|
||||||
|
}
|
||||||
|
function Other() {
|
||||||
|
const { frameRef, active } = useShowcaseCard("sev-modaintima");
|
||||||
|
return <div ref={frameRef} data-testid="other" data-active={active} />;
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<Other />
|
||||||
|
<Bare />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
const bare = screen.getByTestId("bare");
|
||||||
|
const other = screen.getByTestId("other");
|
||||||
|
place(other, 230); // mais perto do meio da tela
|
||||||
|
place(bare, 100);
|
||||||
|
report(other, 0.8);
|
||||||
|
report(bare, 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(other)).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.pointerEnter(bare, { pointerType: "mouse" });
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(isActive(bare)).toBe(true);
|
||||||
|
expect(isActive(other)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: projects missing from the list -> only showcase projects that exist are built, in showcase order", () => {
|
||||||
|
const only = projects.filter((project) =>
|
||||||
|
["as-servicos", "sev-modaintima"].includes(project.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = buildShowcaseItems(only);
|
||||||
|
|
||||||
|
expect(items.map((item) => item.config.id)).toEqual([
|
||||||
|
"sev-modaintima",
|
||||||
|
"as-servicos",
|
||||||
|
]);
|
||||||
|
expect(buildShowcaseItems([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildShowcaseItems,
|
buildShowcaseItems,
|
||||||
ShowcaseProvider,
|
ShowcaseProvider,
|
||||||
useShowcaseCard,
|
useShowcaseCard,
|
||||||
|
useShowcaseOpen,
|
||||||
} from "./ShowcaseProvider";
|
} from "./ShowcaseProvider";
|
||||||
|
|
||||||
let reducedMotion = false;
|
let reducedMotion = false;
|
||||||
@@ -157,6 +158,59 @@ describe("ShowcaseProvider", () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves the reel with only the client projects the home passes in, in showcase order", () => {
|
||||||
|
const clients = projects.filter((project) =>
|
||||||
|
["as-servicos", "sc-plus", "lavacao-047", "sev-modaintima"].includes(
|
||||||
|
project.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(buildShowcaseItems(clients).map((item) => item.config.id)).toEqual([
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
"sc-plus",
|
||||||
|
"as-servicos",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with no cards at all: no per-scroll work, and the enlarged window still opens and navigates", () => {
|
||||||
|
const clients = projects.filter((project) =>
|
||||||
|
["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"].includes(
|
||||||
|
project.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
function Opener() {
|
||||||
|
const open = useShowcaseOpen();
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => open("sc-plus")}>
|
||||||
|
abrir sc-plus
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={clients}>
|
||||||
|
<Opener />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
frames.mockClear();
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
expect(frames).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "abrir sc-plus" }));
|
||||||
|
// posição 3 de 4 na lista que a home passou (o NutriScan não está nela)
|
||||||
|
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||||
|
"aberto:2",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
frames.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
|
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
|
||||||
const { frames } = setup();
|
const { frames } = setup();
|
||||||
flush();
|
flush();
|
||||||
|
|||||||
@@ -111,26 +111,32 @@ export function ShowcaseProvider({
|
|||||||
setActiveId(pickActiveCard(candidates, hovered.current));
|
setActiveId(pickActiveCard(candidates, hovered.current));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const schedule = useCallback(() => {
|
// sem cards registrados (a home mostra só a vitrine animada) não há o que decidir a cada quadro de rolagem;
|
||||||
if (queued.current) return;
|
// `force` vale para o que acabou de mudar a lista (um card saiu), que ainda precisa ser reavaliado
|
||||||
queued.current = true;
|
const schedule = useCallback(
|
||||||
frame.current = requestAnimationFrame(evaluate);
|
(force = false) => {
|
||||||
}, [evaluate]);
|
if (queued.current || (!force && cards.current.size === 0)) return;
|
||||||
|
queued.current = true;
|
||||||
|
frame.current = requestAnimationFrame(evaluate);
|
||||||
|
},
|
||||||
|
[evaluate],
|
||||||
|
);
|
||||||
|
|
||||||
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
|
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
paused.current = reducedMotion || openId !== null || userPaused;
|
paused.current = reducedMotion || openId !== null || userPaused;
|
||||||
schedule();
|
schedule(true);
|
||||||
}, [schedule, openId, reducedMotion, userPaused]);
|
}, [schedule, openId, reducedMotion, userPaused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("scroll", schedule, { passive: true });
|
const onChange = () => schedule();
|
||||||
window.addEventListener("resize", schedule);
|
window.addEventListener("scroll", onChange, { passive: true });
|
||||||
document.addEventListener("visibilitychange", schedule);
|
window.addEventListener("resize", onChange);
|
||||||
|
document.addEventListener("visibilitychange", onChange);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", schedule);
|
window.removeEventListener("scroll", onChange);
|
||||||
window.removeEventListener("resize", schedule);
|
window.removeEventListener("resize", onChange);
|
||||||
document.removeEventListener("visibilitychange", schedule);
|
document.removeEventListener("visibilitychange", onChange);
|
||||||
cancelAnimationFrame(frame.current);
|
cancelAnimationFrame(frame.current);
|
||||||
queued.current = false;
|
queued.current = false;
|
||||||
observer.current?.disconnect();
|
observer.current?.disconnect();
|
||||||
@@ -163,7 +169,7 @@ export function ShowcaseProvider({
|
|||||||
observer.current?.unobserve(element);
|
observer.current?.unobserve(element);
|
||||||
cards.current.delete(id);
|
cards.current.delete(id);
|
||||||
if (hovered.current === id) hovered.current = null;
|
if (hovered.current === id) hovered.current = null;
|
||||||
schedule();
|
schedule(true);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setHover(id, isHovered) {
|
setHover(id, isHovered) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
* Vitrine dos projetos: moldura de navegador, capa dos cards, celular, vitrine animada (.ds-reel) e vídeo
|
* Vitrine dos projetos: moldura de navegador, capa dos cards, celular, vitrine animada (.ds-reel), lista de
|
||||||
* ampliado (.ds-lb). Tudo pensado para a seção escura de Projetos: cantos retos, rótulos em mono, lima como
|
* projetos (.ds-reel-list) e vídeo ampliado (.ds-lb). Tudo pensado para a seção escura de Projetos: cantos
|
||||||
* única cor de ação e a cor do site de cada projeto (--accent) só no brilho ao redor do vídeo.
|
* retos, rótulos curtos em mono (12 px no mínimo), texto de botão em sans 15 px, lima só em detalhes (nenhum
|
||||||
|
* botão preenchido de lima aqui: isso é da ação de contato) e a cor do site de cada projeto (--accent) só no
|
||||||
|
* brilho ao redor do vídeo. Nenhum efeito (grão, partículas, escurecimento) fica por cima das telas.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ─────────────────────────── Moldura de navegador ─────────────────────────── */
|
/* ─────────────────────────── Moldura de navegador ─────────────────────────── */
|
||||||
@@ -50,9 +52,10 @@
|
|||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.04em;
|
line-height: 1;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
letter-spacing: 0.02em;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
@@ -81,8 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-bframe-url {
|
.ds-bframe-url {
|
||||||
height: 18px;
|
height: 20px;
|
||||||
font-size: 9px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,14 +177,14 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-height: 28px;
|
min-height: 36px;
|
||||||
padding: 0 10px;
|
padding: 0 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-display);
|
||||||
font-size: 10px;
|
font-size: 15px;
|
||||||
letter-spacing: 0.1em;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
color: #fff;
|
||||||
color: var(--ink);
|
background: rgba(10, 10, 10, 0.82);
|
||||||
background: var(--slash);
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(6px);
|
transform: translateY(6px);
|
||||||
transition:
|
transition:
|
||||||
@@ -281,7 +283,8 @@
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: #0a0a0a center top / cover no-repeat;
|
background: #0a0a0a center top / cover no-repeat;
|
||||||
|
|
||||||
& > video {
|
& > video,
|
||||||
|
& > img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -350,11 +353,11 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-display);
|
||||||
font-size: 11px;
|
font-size: 15px;
|
||||||
letter-spacing: 0.06em;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: rgba(255, 255, 255, 0.66);
|
color: rgba(255, 255, 255, 0.72);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -378,8 +381,8 @@
|
|||||||
|
|
||||||
&[aria-current="true"] {
|
&[aria-current="true"] {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(204, 255, 0, 0.55);
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
background: rgba(204, 255, 0, 0.07);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,8 +394,8 @@
|
|||||||
gap: 6px 10px;
|
gap: 6px 10px;
|
||||||
margin-bottom: var(--sp-5);
|
margin-bottom: var(--sp-5);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
@@ -409,7 +412,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-reel-num {
|
.ds-reel-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-n);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-reel-name {
|
.ds-reel-name {
|
||||||
@@ -432,13 +441,15 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 10px;
|
font-family: var(--font-display);
|
||||||
letter-spacing: 0.08em;
|
font-size: 15px;
|
||||||
text-transform: uppercase;
|
line-height: 1.25;
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: rgba(255, 255, 255, 0.66);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
& span {
|
& span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,13 +537,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* só opacidade: quem não está em cena já é inert (sem foco, sem clique, fora da busca) e aria-hidden */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
pointer-events: none;
|
||||||
transform: translateY(14px);
|
transform: translateY(14px);
|
||||||
transition:
|
transition:
|
||||||
opacity 0.5s ease,
|
opacity 0.5s ease,
|
||||||
transform 0.6s var(--ease-out),
|
transform 0.6s var(--ease-out);
|
||||||
visibility 0s linear 0.5s;
|
|
||||||
|
|
||||||
&[data-state="before"] {
|
&[data-state="before"] {
|
||||||
transform: translateY(-14px);
|
transform: translateY(-14px);
|
||||||
@@ -540,9 +551,8 @@
|
|||||||
|
|
||||||
&[data-state="active"] {
|
&[data-state="active"] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
pointer-events: auto;
|
||||||
transform: none;
|
transform: none;
|
||||||
transition-delay: 0s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,46 +620,6 @@
|
|||||||
min-width: 88px;
|
min-width: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* celular: o celular é o aparelho principal (o notebook some) e a legenda ocupa a largura toda */
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.ds-reel-grid {
|
|
||||||
/* respiro para o botão fixo de preferências de analytics, que ocupa o canto de baixo */
|
|
||||||
padding-bottom: 68px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-reel-layer {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-reel-set {
|
|
||||||
--caption-h: 12.5rem;
|
|
||||||
width: min(100cqw, 460px);
|
|
||||||
height: 100%;
|
|
||||||
margin-inline: auto;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-reel-devices {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-reel-laptop {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-reel-phone {
|
|
||||||
position: relative;
|
|
||||||
inset: auto;
|
|
||||||
width: auto;
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-reel-caption {
|
.ds-reel-caption {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -692,8 +662,8 @@
|
|||||||
|
|
||||||
.ds-reel-chapter-count {
|
.ds-reel-chapter-count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,25 +688,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. */
|
||||||
.ds-reel-actions {
|
.ds-reel-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--sp-3);
|
align-items: center;
|
||||||
|
gap: var(--sp-2) var(--sp-3);
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
|
|
||||||
|
& .ds-link.ds-link-muted {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
& .ds-link.ds-link-muted:hover {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ds-link.ds-link-text {
|
||||||
|
color: rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-reel-actions .ds-link-muted {
|
.ds-reel-expand {
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
cursor: pointer;
|
||||||
color: #fff;
|
|
||||||
|
& svg {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tela baixa (celular deitado, janela de desktop curta): a vitrine não cabe, e os cards já mostram os vídeos */
|
/* tela baixa (celular deitado, janela de desktop curta): a vitrine fixa não cabe, e a lista de projetos entra no lugar */
|
||||||
@media (max-height: 560px) {
|
@media (max-height: 560px) {
|
||||||
.ds-reel {
|
.ds-reel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* antes de o navegador montar a página, só onde a vitrine fixa vai existir: o bloco reservado aparece e a lista
|
||||||
|
(que o servidor entregou, com o texto e os links) fica escondida; sem JavaScript, a lista é quem aparece */
|
||||||
|
.ds-reel-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (min-height: 561px) and (
|
||||||
|
prefers-reduced-motion: no-preference
|
||||||
|
) and (scripting: enabled) {
|
||||||
|
.ds-reel-placeholder {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-list[data-pending] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* desktop com pouca altura: a lista de projetos e os trechos têm de caber na altura do pin */
|
/* desktop com pouca altura: a lista de projetos e os trechos têm de caber na altura do pin */
|
||||||
@media (min-width: 1024px) and (max-height: 700px) {
|
@media (min-width: 1024px) and (max-height: 700px) {
|
||||||
.ds-reel-hint {
|
.ds-reel-hint {
|
||||||
@@ -765,32 +781,125 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* celular: o subtítulo sai para o aparelho ter mais tela, e o contador "02/05" já faz o papel dos tiques */
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.ds-reel-sub,
|
|
||||||
.ds-reel-ticks {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* celular baixo demais: o aparelho ficaria uma cápsula ilegível */
|
|
||||||
@media (max-width: 639px) and (max-height: 600px) {
|
|
||||||
.ds-reel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ds-reel {
|
.ds-reel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* sem JavaScript a vitrine nunca monta: o espaço reservado por ela some */
|
/* ─────────────────────────── Lista de projetos ─────────────────────────── */
|
||||||
@media (scripting: none) {
|
|
||||||
.ds-reel-placeholder {
|
/*
|
||||||
|
* Sem rolagem presa: celular, movimento reduzido e janelas baixas. No celular é um carrossel por toque
|
||||||
|
* (rolagem horizontal com encaixe; o card seguinte aparece pela borda); acima de 768 px, uma grade.
|
||||||
|
*/
|
||||||
|
.ds-reel-list {
|
||||||
|
margin-top: var(--sp-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-items {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
|
||||||
|
gap: var(--sp-8) var(--sp-6);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item {
|
||||||
|
--accent: var(--slash);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item-media {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -4% 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(
|
||||||
|
55% 60% at 50% 50%,
|
||||||
|
color-mix(in srgb, var(--accent) 36%, transparent),
|
||||||
|
transparent 72%
|
||||||
|
);
|
||||||
|
filter: blur(36px);
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item-screen {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0a0a;
|
||||||
|
|
||||||
|
& > img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item-phone {
|
||||||
|
width: min(62%, 240px);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.ds-reel-list {
|
||||||
|
margin-top: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-items {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-padding-inline: var(--sp-5);
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* o card seguinte aparece pela borda: é o convite para deslizar */
|
||||||
|
margin-inline: calc(var(--sp-5) * -1);
|
||||||
|
padding: 2px var(--sp-5) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-items::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ds-reel-item {
|
||||||
|
flex: 0 0 min(82%, 340px);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
padding: var(--sp-5) var(--sp-4);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* um cartão mais baixo: sem subtítulo a legenda cabe e o celular da moldura ganha espaço */
|
||||||
|
.ds-reel-item .ds-reel-sub {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sem JavaScript o carrossel vira lista vertical: nada fica escondido atrás de uma rolagem lateral */
|
||||||
|
@media (scripting: none) {
|
||||||
|
.ds-reel-items {
|
||||||
|
display: grid;
|
||||||
|
overflow: visible;
|
||||||
|
margin-inline: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────────── Vídeo ampliado ─────────────────────────── */
|
/* ─────────────────────────── Vídeo ampliado ─────────────────────────── */
|
||||||
@@ -870,8 +979,8 @@ html.ds-lb-open {
|
|||||||
|
|
||||||
.ds-lb-count {
|
.ds-lb-count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.08em;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,11 +1002,10 @@ html.ds-lb-open {
|
|||||||
& button {
|
& button {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-display);
|
||||||
font-size: 11px;
|
font-size: 15px;
|
||||||
letter-spacing: 0.08em;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
color: rgba(255, 255, 255, 0.72);
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -905,9 +1013,11 @@ html.ds-lb-open {
|
|||||||
background 0.2s ease,
|
background 0.2s ease,
|
||||||
color 0.2s ease;
|
color 0.2s ease;
|
||||||
|
|
||||||
|
/* a versão escolhida: fundo claro e sublinhado em lima (a lima só marca, não preenche o botão) */
|
||||||
&[aria-pressed="true"] {
|
&[aria-pressed="true"] {
|
||||||
color: var(--ink);
|
color: #fff;
|
||||||
background: var(--slash);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
@@ -1007,13 +1117,19 @@ html.ds-lb-open {
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--ink);
|
color: #fff;
|
||||||
background: var(--slash);
|
background: transparent;
|
||||||
border: 0;
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid #fff;
|
outline: 2px solid var(--slash);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,7 +1144,7 @@ html.ds-lb-open {
|
|||||||
flex: none;
|
flex: none;
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
@@ -1085,14 +1201,12 @@ html.ds-lb-open {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
& button {
|
& button {
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
padding: 0 12px;
|
padding: 0 14px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-display);
|
||||||
font-size: 10px;
|
font-size: 15px;
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.76);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1101,6 +1215,8 @@ html.ds-lb-open {
|
|||||||
border-color 0.2s ease;
|
border-color 0.2s ease;
|
||||||
|
|
||||||
& span {
|
& span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,7 +1229,7 @@ html.ds-lb-open {
|
|||||||
|
|
||||||
&[aria-current="true"] {
|
&[aria-current="true"] {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--slash);
|
border-color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
@@ -1132,11 +1248,18 @@ html.ds-lb-open {
|
|||||||
.ds-lb-links {
|
.ds-lb-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--sp-3);
|
align-items: center;
|
||||||
|
gap: var(--sp-2) var(--sp-3);
|
||||||
|
|
||||||
& .ds-link-muted {
|
& .ds-link.ds-link-muted {
|
||||||
|
padding: 8px 16px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ds-link.ds-link-text {
|
||||||
|
color: rgba(255, 255, 255, 0.84);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { act, render, renderHook } from "@testing-library/react";
|
||||||
|
import { type RefObject, useRef } from "react";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { MOBILE_QUERY } from "@/lib/device";
|
||||||
|
import {
|
||||||
|
NEAR_MARGIN,
|
||||||
|
PINNED_QUERY,
|
||||||
|
useNearViewport,
|
||||||
|
usePinnedViewport,
|
||||||
|
useSaveData,
|
||||||
|
useViewportDevice,
|
||||||
|
} from "./useShowcaseMedia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base dos hooks de mídia da vitrine: aparelho (celular x desktop x sem matchMedia),
|
||||||
|
* vitrine fixa cabe ou não, "economizar dados" e a proximidade com a janela (com e sem IntersectionObserver).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
const OriginalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
type MediaListener = () => void;
|
||||||
|
type FakeQuery = {
|
||||||
|
query: string;
|
||||||
|
matches: boolean;
|
||||||
|
listeners: Set<MediaListener>;
|
||||||
|
};
|
||||||
|
let queries: FakeQuery[] = [];
|
||||||
|
|
||||||
|
/** matchMedia falso: `matching` diz quais consultas valem; cada chamada devolve um objeto que guarda os ouvintes. */
|
||||||
|
function mockMedia(matching: string[]) {
|
||||||
|
queries = [];
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => {
|
||||||
|
const fake: FakeQuery = {
|
||||||
|
query,
|
||||||
|
matches: matching.includes(query),
|
||||||
|
listeners: new Set(),
|
||||||
|
};
|
||||||
|
queries.push(fake);
|
||||||
|
return {
|
||||||
|
get matches() {
|
||||||
|
return fake.matches;
|
||||||
|
},
|
||||||
|
media: query,
|
||||||
|
addEventListener: (_type: string, listener: MediaListener) =>
|
||||||
|
fake.listeners.add(listener),
|
||||||
|
removeEventListener: (_type: string, listener: MediaListener) =>
|
||||||
|
fake.listeners.delete(listener),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockNavigator(values: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
global.IntersectionObserver = OriginalObserver;
|
||||||
|
mockNavigator({ connection: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useViewportDevice", () => {
|
||||||
|
it("path: server render (no effect yet) -> null, so no media file is chosen", () => {
|
||||||
|
function Probe() {
|
||||||
|
return <span>{String(useViewportDevice())}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderToString(<Probe />)).toContain("null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: browser without matchMedia -> desktop", () => {
|
||||||
|
// @ts-expect-error simula um navegador sem matchMedia
|
||||||
|
window.matchMedia = undefined;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useViewportDevice());
|
||||||
|
|
||||||
|
expect(result.current).toBe("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: phone width matches -> mobile", () => {
|
||||||
|
mockMedia([MOBILE_QUERY]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useViewportDevice());
|
||||||
|
|
||||||
|
expect(result.current).toBe("mobile");
|
||||||
|
expect(queries.map((q) => q.query)).toEqual([MOBILE_QUERY]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: phone width does not match -> desktop", () => {
|
||||||
|
mockMedia([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useViewportDevice());
|
||||||
|
|
||||||
|
expect(result.current).toBe("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the window is resized across the breakpoint -> follows both ways", () => {
|
||||||
|
mockMedia([]);
|
||||||
|
const { result } = renderHook(() => useViewportDevice());
|
||||||
|
expect(result.current).toBe("desktop");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queries[0].matches = true;
|
||||||
|
for (const listener of queries[0].listeners) listener();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe("mobile");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queries[0].matches = false;
|
||||||
|
for (const listener of queries[0].listeners) listener();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the media listener is removed", () => {
|
||||||
|
mockMedia([]);
|
||||||
|
const { unmount } = renderHook(() => useViewportDevice());
|
||||||
|
expect(queries[0].listeners.size).toBe(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(queries[0].listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("usePinnedViewport", () => {
|
||||||
|
it("path: server render -> null until the browser mounts", () => {
|
||||||
|
function Probe() {
|
||||||
|
return <span>{String(usePinnedViewport())}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderToString(<Probe />)).toContain("null");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: browser without matchMedia -> the pinned reel is allowed", () => {
|
||||||
|
// @ts-expect-error simula um navegador sem matchMedia
|
||||||
|
window.matchMedia = undefined;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePinnedViewport());
|
||||||
|
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the screen is wide and tall enough -> true, using the shared query", () => {
|
||||||
|
mockMedia([PINNED_QUERY]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePinnedViewport());
|
||||||
|
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
expect(queries.map((q) => q.query)).toEqual([PINNED_QUERY]);
|
||||||
|
expect(PINNED_QUERY).toBe("(min-width: 768px) and (min-height: 561px)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the screen is too small (phone or short window) -> false", () => {
|
||||||
|
mockMedia([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePinnedViewport());
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the window changes size -> follows, and unmount removes the listener", () => {
|
||||||
|
mockMedia([]);
|
||||||
|
const { result, unmount } = renderHook(() => usePinnedViewport());
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queries[0].matches = true;
|
||||||
|
for (const listener of queries[0].listeners) listener();
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(queries[0].listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useSaveData", () => {
|
||||||
|
it.each([
|
||||||
|
["saveData on", { saveData: true }, true],
|
||||||
|
["saveData off", { saveData: false }, false],
|
||||||
|
["no connection info", undefined, false],
|
||||||
|
])("path: %s -> %s", (_label, connection, expected) => {
|
||||||
|
mockNavigator({ connection });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSaveData());
|
||||||
|
|
||||||
|
expect(result.current).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: server render -> false (the lightest assumption is not 'save data')", () => {
|
||||||
|
mockNavigator({ connection: { saveData: true } });
|
||||||
|
function Probe() {
|
||||||
|
return <span>{String(useSaveData())}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderToString(<Probe />)).toContain("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useNearViewport", () => {
|
||||||
|
type Entry = { isIntersecting: boolean };
|
||||||
|
let callbacks: Array<(entries: Entry[]) => void> = [];
|
||||||
|
let observers: Array<{
|
||||||
|
observe: jest.Mock;
|
||||||
|
disconnect: jest.Mock;
|
||||||
|
options?: IntersectionObserverInit;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
callbacks = [];
|
||||||
|
observers = [];
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
observe = jest.fn();
|
||||||
|
disconnect = jest.fn();
|
||||||
|
constructor(
|
||||||
|
callback: (entries: Entry[]) => void,
|
||||||
|
options?: IntersectionObserverInit,
|
||||||
|
) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
observers.push({
|
||||||
|
observe: this.observe,
|
||||||
|
disconnect: this.disconnect,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
function Probe({ attach = true }: { attach?: boolean }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const near = useNearViewport(ref as RefObject<Element | null>);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{attach && <div ref={ref} data-testid="target" />}
|
||||||
|
<output>{String(near)}</output>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("path: element far from the window -> false, observing it with a one-screen margin", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
|
||||||
|
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||||
|
expect(observers).toHaveLength(1);
|
||||||
|
expect(observers[0].options).toEqual({ rootMargin: NEAR_MARGIN });
|
||||||
|
expect(observers[0].observe).toHaveBeenCalledWith(
|
||||||
|
view.getByTestId("target"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the observer reports nothing intersecting -> still false", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
|
||||||
|
act(() => callbacks[0]([{ isIntersecting: false }]));
|
||||||
|
|
||||||
|
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the element gets within a screen -> true, and it stays true when it leaves again", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
callbacks[0]([{ isIntersecting: false }, { isIntersecting: true }]),
|
||||||
|
);
|
||||||
|
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||||
|
|
||||||
|
act(() => callbacks[0]([{ isIntersecting: false }]));
|
||||||
|
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no ref target -> no observer is created and the answer stays false", () => {
|
||||||
|
const view = render(<Probe attach={false} />);
|
||||||
|
|
||||||
|
expect(observers).toHaveLength(0);
|
||||||
|
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: browser without IntersectionObserver -> assumes near so the poster still loads", () => {
|
||||||
|
// @ts-expect-error simula um navegador sem IntersectionObserver
|
||||||
|
global.IntersectionObserver = undefined;
|
||||||
|
|
||||||
|
const view = render(<Probe />);
|
||||||
|
|
||||||
|
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: unmount -> the observer is disconnected", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
|
import { isSaveData, MOBILE_QUERY } from "@/lib/device";
|
||||||
|
import type { Device } from "@/lib/project-showcase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versão de vídeo e pôster que vale para o aparelho: "mobile" abaixo de 768 px, "desktop" acima. É `null`
|
||||||
|
* até a página montar no navegador (no servidor nenhum arquivo de mídia é escolhido: só o espaço da capa).
|
||||||
|
*/
|
||||||
|
export function useViewportDevice(): Device | null {
|
||||||
|
const [device, setDevice] = useState<Device | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") {
|
||||||
|
setDevice("desktop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const update = () => setDevice(query.matches ? "mobile" : "desktop");
|
||||||
|
update();
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Telas em que a vitrine fixa cabe: tablet ou desktop, com altura para o notebook e a legenda (showcase.css usa a mesma conta). */
|
||||||
|
export const PINNED_QUERY = "(min-width: 768px) and (min-height: 561px)";
|
||||||
|
|
||||||
|
/** A vitrine fixa cabe nesta tela? `null` até montar no navegador. */
|
||||||
|
export function usePinnedViewport(): boolean | null {
|
||||||
|
const [pinned, setPinned] = useState<boolean | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") {
|
||||||
|
setPinned(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = window.matchMedia(PINNED_QUERY);
|
||||||
|
const update = () => setPinned(query.matches);
|
||||||
|
update();
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
return pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Economizar dados" ligado (lido no navegador, depois de montar): só pôsteres, nenhum vídeo baixado. */
|
||||||
|
export function useSaveData(): boolean {
|
||||||
|
const [saveData, setSaveData] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setSaveData(isSaveData());
|
||||||
|
}, []);
|
||||||
|
return saveData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Margem em volta da janela dentro da qual uma seção conta como "perto": uma tela acima e abaixo. */
|
||||||
|
export const NEAR_MARGIN = "100% 0px";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verdadeiro quando o elemento chegou a até ~1 tela da janela: só então o pôster dele é pedido. Depois de
|
||||||
|
* perto, continua verdadeiro (o pôster já baixado não some e volta).
|
||||||
|
*/
|
||||||
|
export function useNearViewport(ref: RefObject<Element | null>): boolean {
|
||||||
|
const [near, setNear] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
|
setNear(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) setNear(true);
|
||||||
|
},
|
||||||
|
{ rootMargin: NEAR_MARGIN },
|
||||||
|
);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [ref]);
|
||||||
|
return near;
|
||||||
|
}
|
||||||
+91
-551
@@ -137,10 +137,9 @@ html {
|
|||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 11px;
|
font-family: var(--font-display);
|
||||||
font-weight: 700;
|
font-size: 15px;
|
||||||
letter-spacing: 0.08em;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: transform 0.2s var(--ease-out);
|
transition: transform 0.2s var(--ease-out);
|
||||||
}
|
}
|
||||||
@@ -174,6 +173,12 @@ html {
|
|||||||
);
|
);
|
||||||
mix-blend-mode: overlay;
|
mix-blend-mode: overlay;
|
||||||
opacity: 0.08;
|
opacity: 0.08;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* partículas e efeitos nunca ficam por cima das telas dos projetos (FilmGrain marca quando uma está à vista) */
|
||||||
|
.film-grain[data-over-projects] {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
||||||
@@ -186,7 +191,8 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: var(--font-mono);
|
/* sans por padrão: a mono é só dos rótulos (.ds-eyebrow, .ds-kicker, .ds-tag...), que a pedem à parte */
|
||||||
|
font-family: var(--font-body);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.68;
|
line-height: 1.68;
|
||||||
}
|
}
|
||||||
@@ -325,10 +331,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* rótulos curtos em mono: 12 px no mínimo e espaçamento moderado */
|
||||||
.ds-eyebrow,
|
.ds-eyebrow,
|
||||||
.ds-kicker {
|
.ds-kicker {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--meta);
|
color: var(--meta);
|
||||||
}
|
}
|
||||||
@@ -422,8 +429,8 @@ textarea {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid rgba(10, 10, 10, 0.12);
|
border: 1px solid rgba(10, 10, 10, 0.12);
|
||||||
background: rgba(10, 10, 10, 0.03);
|
background: rgba(10, 10, 10, 0.03);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
/* pílula inteira numa linha: a quebra acontece entre tags (.ds-tags wrap), nunca dentro dela */
|
/* pílula inteira numa linha: a quebra acontece entre tags (.ds-tags wrap), nunca dentro dela */
|
||||||
@@ -440,8 +447,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-section-dark .ds-tag:hover {
|
.ds-section-dark .ds-tag:hover {
|
||||||
border-color: rgba(204, 255, 0, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
box-shadow: 0 2px 16px rgba(204, 255, 0, 0.1);
|
box-shadow: 0 2px 16px rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-metric {
|
.ds-metric {
|
||||||
@@ -449,11 +456,14 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
color: rgba(10, 10, 10, 0.65);
|
color: rgba(10, 10, 10, 0.65);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Botões e links de ação: texto em sans 15 px, sem caixa-alta mono. O verde-limão preenchido
|
||||||
|
(.ds-link-accent) é só da ação de contato; as demais ações usam contorno (.ds-link-muted) ou
|
||||||
|
link de texto (.ds-link-text). */
|
||||||
.ds-link {
|
.ds-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -461,9 +471,12 @@ textarea {
|
|||||||
min-height: 44px; /* alvo de toque minimo e mesma altura em todos os botoes */
|
min-height: 44px; /* alvo de toque minimo e mesma altura em todos os botoes */
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 11px;
|
font-family: var(--font-display);
|
||||||
letter-spacing: 0.08em;
|
font-size: 15px;
|
||||||
text-transform: uppercase;
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
transition:
|
transition:
|
||||||
transform 0.2s ease,
|
transform 0.2s ease,
|
||||||
background 0.2s ease,
|
background 0.2s ease,
|
||||||
@@ -478,6 +491,7 @@ textarea {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--slash);
|
background: var(--slash);
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
|
font-weight: 600;
|
||||||
transition:
|
transition:
|
||||||
transform 0.25s ease,
|
transform 0.25s ease,
|
||||||
box-shadow 0.25s ease,
|
box-shadow 0.25s ease,
|
||||||
@@ -497,9 +511,10 @@ textarea {
|
|||||||
0 0 60px rgba(204, 255, 0, 0.22);
|
0 0 60px rgba(204, 255, 0, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* contorno: ação secundária (sem lima) */
|
||||||
.ds-link-muted {
|
.ds-link-muted {
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(10, 10, 10, 0.28);
|
||||||
transition:
|
transition:
|
||||||
transform 0.25s ease,
|
transform 0.25s ease,
|
||||||
box-shadow 0.25s ease,
|
box-shadow 0.25s ease,
|
||||||
@@ -512,8 +527,21 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-section-dark .ds-link-muted:hover {
|
.ds-section-dark .ds-link-muted:hover {
|
||||||
border-color: rgba(204, 255, 0, 0.35);
|
border-color: rgba(255, 255, 255, 0.7);
|
||||||
box-shadow: 0 4px 20px rgba(204, 255, 0, 0.08);
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* link de texto: ação terciária, sublinhada, sem caixa */
|
||||||
|
.ds-link-text {
|
||||||
|
padding: 0 2px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-link-text:hover {
|
||||||
|
transform: none;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-list {
|
.ds-list {
|
||||||
@@ -531,8 +559,8 @@ textarea {
|
|||||||
.ds-loading,
|
.ds-loading,
|
||||||
.ds-error {
|
.ds-error {
|
||||||
padding: var(--sp-10) clamp(24px, 6vw, 64px);
|
padding: var(--sp-10) clamp(24px, 6vw, 64px);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +591,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-section-dark .ds-link-muted {
|
.ds-section-dark .ds-link-muted {
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-draw-line {
|
.ds-draw-line {
|
||||||
@@ -673,15 +701,13 @@ textarea {
|
|||||||
right: var(--sp-3);
|
right: var(--sp-3);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 1px solid var(--slash);
|
border: 1px solid rgba(244, 242, 237, 0.5);
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
padding-inline: var(--sp-3);
|
padding-inline: var(--sp-3);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-display);
|
||||||
font-size: 11px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,17 +841,6 @@ textarea {
|
|||||||
margin-bottom: var(--sp-8);
|
margin-bottom: var(--sp-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* rotulos em 11-12px (antes 9-11px): legiveis no celular sem perder o estilo tecnico */
|
|
||||||
.ds-eyebrow,
|
|
||||||
.ds-kicker {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-tag,
|
|
||||||
.ds-link {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-section-title {
|
.ds-section-title {
|
||||||
font-size: clamp(24px, 8vw, 34px);
|
font-size: clamp(24px, 8vw, 34px);
|
||||||
}
|
}
|
||||||
@@ -847,7 +862,6 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-tag {
|
.ds-tag {
|
||||||
font-size: 10px;
|
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -994,9 +1008,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hero: palco de 100svh; em telas grandes ele fica fixo enquanto a rolagem conduz o mergulho da cena 3D.
|
/* Hero: palco de 100svh; em telas grandes ele fica fixo enquanto a rolagem conduz o mergulho da cena 3D.
|
||||||
--hero-p (0 a 1) vem de cinematic/HeroField.tsx. */
|
--hero-p (0 a 1) e --hero-open (0 a 1: a máscara do fundo se abrindo) vêm de cinematic/HeroField.tsx. */
|
||||||
.hero {
|
.hero {
|
||||||
--hero-p: 0;
|
--hero-p: 0;
|
||||||
|
--hero-open: 0;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1023,21 @@ textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sem WebGL (ou antes de a cena carregar): malha de pontos estática no lugar das camadas */
|
/* Máscara dos fundos ambientes do hero (cena 3D e malha estática): transparente no miolo, onde ficam o título
|
||||||
|
e as molduras da direita, e opaca nas bordas e cantos. Com o mergulho (--hero-open 0 → 1) o miolo some e o
|
||||||
|
fundo aparece inteiro, quando o texto e a coluna da direita já saíram de cena. */
|
||||||
|
.hero-glow::after,
|
||||||
|
.hero-field {
|
||||||
|
--clear: calc((1 - var(--hero-open)) * 52% - 1%);
|
||||||
|
--feather: calc((1 - var(--hero-open)) * 30%);
|
||||||
|
mask-image: radial-gradient(
|
||||||
|
ellipse at 46% 50%,
|
||||||
|
transparent var(--clear),
|
||||||
|
#000 calc(var(--clear) + var(--feather) + 1%)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sem WebGL, com aparelho que não aguenta a cena, ou antes de ela carregar: malha de pontos estática, leve */
|
||||||
.hero-glow::after {
|
.hero-glow::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1016,7 +1045,7 @@ textarea {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0
|
radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0
|
||||||
0 / 22px 22px;
|
0 / 22px 22px;
|
||||||
mask: radial-gradient(38% 46% at 74% 50%, #000, transparent 75%);
|
opacity: 0.6;
|
||||||
transition: opacity 1.2s var(--ease-out);
|
transition: opacity 1.2s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,14 +1053,23 @@ textarea {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fundo ambiente: a cena 3D fica com pouca opacidade (sobe para a opacidade cheia só no mergulho). O
|
||||||
|
desenho entra por animação (e não por transition) para a opacidade poder seguir a rolagem sem atraso. */
|
||||||
.hero-field {
|
.hero-field {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 1.6s var(--ease-out);
|
pointer-events: none;
|
||||||
|
|
||||||
&[data-ready] {
|
&[data-ready] {
|
||||||
opacity: 1;
|
opacity: calc(0.5 + 0.5 * var(--hero-open));
|
||||||
|
animation: hero-field-in 1.6s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-field-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,46 +1148,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Etiquetas presas às camadas da cena: a posição vem da cena a cada quadro (transform), o recuo para a
|
|
||||||
esquerda do ponto de ancoragem fica no translate */
|
|
||||||
.hero-tag {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
display: none;
|
|
||||||
max-width: 230px;
|
|
||||||
translate: calc(-100% - 14px) -50%;
|
|
||||||
border: 1px solid rgba(244, 242, 237, 0.14);
|
|
||||||
background: rgba(10, 10, 10, 0.62);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
padding: 7px 11px;
|
|
||||||
color: rgba(244, 242, 237, 0.78);
|
|
||||||
font: 13px / 1.35 var(--font-body);
|
|
||||||
text-align: right;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: -14px;
|
|
||||||
width: 14px;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-tag-title,
|
|
||||||
.hero-caption-title {
|
.hero-caption-title {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
font: 600 11px / 1.4 var(--font-mono);
|
font: 600 12px / 1.4 var(--font-mono);
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,6 +1247,12 @@ textarea {
|
|||||||
opacity: calc(1 - var(--hero-p) * 5);
|
opacity: calc(1 - var(--hero-p) * 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a coluna da direita (molduras) sai junto com o texto: o fundo 3D se abre sem passar atrás dela */
|
||||||
|
.hero-aside {
|
||||||
|
transform: translate3d(0, calc(var(--hero-p) * -10vh), 0);
|
||||||
|
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-scrim {
|
.hero-scrim {
|
||||||
opacity: calc(1 - var(--hero-p) * 1.4);
|
opacity: calc(1 - var(--hero-p) * 1.4);
|
||||||
}
|
}
|
||||||
@@ -1355,475 +1364,6 @@ html.lenis {
|
|||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interactive quality laboratory — scoped to the Lighthouse section. */
|
|
||||||
.quality-lab {
|
|
||||||
--quality-muted: color-mix(in srgb, var(--paper) 72%, transparent);
|
|
||||||
--quality-line: color-mix(in srgb, var(--paper) 18%, transparent);
|
|
||||||
color: var(--paper);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-intro {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.3fr);
|
|
||||||
gap: var(--sp-12);
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--sp-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-copy .ds-eyebrow {
|
|
||||||
color: var(--slash);
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-copy .ds-section-title {
|
|
||||||
max-width: 16ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-copy h2 span {
|
|
||||||
display: block;
|
|
||||||
color: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-copy > p:not(.ds-eyebrow) {
|
|
||||||
max-width: 44ch;
|
|
||||||
margin: var(--sp-6) 0;
|
|
||||||
color: var(--quality-muted);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-copy .ds-link {
|
|
||||||
white-space: normal;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual {
|
|
||||||
min-width: 0;
|
|
||||||
border: 1px solid var(--quality-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual-label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--quality-line);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
color: var(--quality-muted);
|
|
||||||
font: 10px / 1.5 var(--font-mono);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual-label span:first-child {
|
|
||||||
color: var(--paper);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-blueprint {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dimensions {
|
|
||||||
stroke: var(--quality-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-dimensions text {
|
|
||||||
fill: var(--quality-muted);
|
|
||||||
stroke: none;
|
|
||||||
font: 9px var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-browser-frame {
|
|
||||||
fill: var(--ink);
|
|
||||||
stroke: color-mix(in srgb, var(--paper) 38%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-wire {
|
|
||||||
stroke: color-mix(in srgb, var(--paper) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-browser-dots {
|
|
||||||
fill: var(--quality-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-address {
|
|
||||||
fill: var(--quality-muted);
|
|
||||||
font: 10px var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-browser-heading {
|
|
||||||
fill: var(--paper);
|
|
||||||
font: 700 24px var(--font-display);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-browser-button {
|
|
||||||
fill: var(--slash-dim);
|
|
||||||
stroke: color-mix(in srgb, var(--slash) 50%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-browser-button-label {
|
|
||||||
fill: var(--paper);
|
|
||||||
font: 9px var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-layer {
|
|
||||||
stroke: var(--slash);
|
|
||||||
stroke-width: 1.2px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 180ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-layer text {
|
|
||||||
fill: var(--slash);
|
|
||||||
stroke: none;
|
|
||||||
font: 10px var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-layer circle {
|
|
||||||
fill: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-highlight {
|
|
||||||
fill: color-mix(in srgb, var(--slash) 5%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-focus-frame {
|
|
||||||
stroke-width: 2px;
|
|
||||||
stroke-dasharray: 5 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-lab[data-inspection="performance"] [data-layer="performance"],
|
|
||||||
.quality-lab[data-inspection="accessibility"] [data-layer="accessibility"],
|
|
||||||
.quality-lab[data-inspection="practices"] [data-layer="practices"],
|
|
||||||
.quality-lab[data-inspection="seo"] [data-layer="seo"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-annotation {
|
|
||||||
fill: var(--ink);
|
|
||||||
stroke: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.quality-lab[data-inspection="performance"]
|
|
||||||
[data-layer="performance"]
|
|
||||||
.quality-trace,
|
|
||||||
.quality-lab[data-inspection="accessibility"]
|
|
||||||
[data-layer="accessibility"]
|
|
||||||
.quality-trace,
|
|
||||||
.quality-lab[data-inspection="practices"]
|
|
||||||
[data-layer="practices"]
|
|
||||||
.quality-trace,
|
|
||||||
.quality-lab[data-inspection="seo"] [data-layer="seo"] .quality-trace {
|
|
||||||
stroke-dasharray: 1;
|
|
||||||
animation: quality-trace 650ms ease-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Entrada do diagrama (classe is-drawing posta por useCinematicScroll): linhas se desenham e rótulos
|
|
||||||
aparecem uma única vez, sem acompanhar cada quadro da rolagem */
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
/* só com as transições ativas (html.lenis): sem o script, o diagrama aparece normalmente */
|
|
||||||
html.lenis .quality-lab:not(.is-drawing)
|
|
||||||
:is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-lab.is-drawing
|
|
||||||
.quality-blueprint
|
|
||||||
:is(
|
|
||||||
.quality-dimensions path,
|
|
||||||
.quality-browser-frame,
|
|
||||||
.quality-wire,
|
|
||||||
.quality-browser-button
|
|
||||||
) {
|
|
||||||
stroke-dasharray: 1800; /* maior que o maior contorno (moldura: ~1600) */
|
|
||||||
animation: quality-draw 1.1s var(--ease-out) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-lab.is-drawing
|
|
||||||
:is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
|
|
||||||
animation: quality-fade 0.6s var(--ease-out) both;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes quality-draw {
|
|
||||||
from {
|
|
||||||
stroke-dashoffset: 1800;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes quality-fade {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes quality-trace {
|
|
||||||
from {
|
|
||||||
stroke-dashoffset: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual figcaption {
|
|
||||||
display: grid;
|
|
||||||
min-height: 64px;
|
|
||||||
border-top: 1px solid var(--quality-line);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
color: var(--quality-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overlapping hidden panels reserve the tallest content without fixed heights. */
|
|
||||||
.quality-visual figcaption span,
|
|
||||||
.quality-report {
|
|
||||||
grid-area: 1 / 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual figcaption span[aria-hidden="true"],
|
|
||||||
.quality-report[aria-hidden="true"] {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report-stack {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
min-width: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control input {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--sp-5);
|
|
||||||
border: 1px solid var(--quality-line);
|
|
||||||
border-top-width: 2px;
|
|
||||||
transition:
|
|
||||||
background-color 180ms ease-out,
|
|
||||||
border-color 180ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control + .quality-control .quality-control-content {
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control:hover .quality-control-content {
|
|
||||||
background: color-mix(in srgb, var(--paper) 5%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control input:checked + .quality-control-content {
|
|
||||||
border-top-color: var(--slash);
|
|
||||||
background: var(--slash-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control input:focus-visible + .quality-control-content {
|
|
||||||
outline: 2px solid var(--slash);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-name {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-name::before {
|
|
||||||
content: "";
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control input:checked + span .quality-control-name::before {
|
|
||||||
background: var(--slash);
|
|
||||||
border-color: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-target {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
color: var(--quality-muted);
|
|
||||||
font: 11px / 1.2 var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-target strong {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--paper);
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 500;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control input:checked + span strong {
|
|
||||||
color: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-hint,
|
|
||||||
.quality-disclaimer {
|
|
||||||
color: var(--quality-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-hint {
|
|
||||||
margin-top: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
gap: var(--sp-12);
|
|
||||||
min-height: 240px;
|
|
||||||
padding: var(--sp-8) 0;
|
|
||||||
border-bottom: 1px solid var(--quality-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-code {
|
|
||||||
color: var(--slash);
|
|
||||||
font: 10px / 1.8 var(--font-mono);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report h3 {
|
|
||||||
margin: var(--sp-2) 0 var(--sp-3);
|
|
||||||
font: 600 clamp(20px, 2.4vw, 28px) / 1.2 var(--font-display);
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report-copy > p:last-child {
|
|
||||||
max-width: 60ch;
|
|
||||||
color: var(--quality-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report ul {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report li {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3) 0;
|
|
||||||
border-bottom: 1px solid var(--quality-line);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report li span {
|
|
||||||
color: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-disclaimer {
|
|
||||||
margin-top: var(--sp-5);
|
|
||||||
max-width: 90ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.quality-intro {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--sp-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-copy .ds-section-title,
|
|
||||||
.quality-copy > p:not(.ds-eyebrow) {
|
|
||||||
max-width: 650px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual {
|
|
||||||
width: min(100%, 720px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.quality-controls {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control + .quality-control .quality-control-content {
|
|
||||||
border-left: 1px solid var(--quality-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-content {
|
|
||||||
padding: var(--sp-4) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-name {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-target strong {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-visual-label {
|
|
||||||
padding: var(--sp-3);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-report {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
min-height: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
|
||||||
.quality-control-content {
|
|
||||||
padding-inline: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-control-name {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.quality-layer,
|
|
||||||
.quality-control-content {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
|
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
|
||||||
recua e desfoca, a nova chega de baixo. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
recua e desfoca, a nova chega de baixo. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
||||||
.site-header {
|
.site-header {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import ts from "typescript";
|
||||||
|
import {
|
||||||
|
isCoarsePointer,
|
||||||
|
isLowPowerDevice,
|
||||||
|
isMobileViewport,
|
||||||
|
isSaveData,
|
||||||
|
prefersReducedMotion,
|
||||||
|
} from "./device";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de lib/device.ts (complemento de device.test.ts): matches() sem matchMedia,
|
||||||
|
* cada condição de isLowPowerDevice isolada (inclusive os limites), isSaveData e o ambiente sem window/navigator
|
||||||
|
* (servidor). O servidor é simulado transpilando o fonte e executando-o com `window` e `navigator` indefinidos,
|
||||||
|
* porque o jsdom não deixa esconder esses globais.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type DeviceModule = typeof import("./device");
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
function mockMedia(active: string[]) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: active.includes(query),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockNavigator(values: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOnServer(globals: { window?: unknown; navigator?: unknown }) {
|
||||||
|
const source = readFileSync(join(process.cwd(), "lib/device.ts"), "utf8");
|
||||||
|
const { outputText } = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||||
|
});
|
||||||
|
const loaded = { exports: {} as DeviceModule };
|
||||||
|
new Function("module", "exports", "window", "navigator", outputText)(
|
||||||
|
loaded,
|
||||||
|
loaded.exports,
|
||||||
|
globals.window,
|
||||||
|
globals.navigator,
|
||||||
|
);
|
||||||
|
return loaded.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMedia([]);
|
||||||
|
mockNavigator({
|
||||||
|
hardwareConcurrency: 8,
|
||||||
|
deviceMemory: undefined,
|
||||||
|
connection: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matches() (through the media helpers)", () => {
|
||||||
|
it("path: matchMedia is not available -> every media hint is false", () => {
|
||||||
|
// @ts-expect-error simula um navegador sem matchMedia
|
||||||
|
window.matchMedia = undefined;
|
||||||
|
|
||||||
|
expect(isCoarsePointer()).toBe(false);
|
||||||
|
expect(prefersReducedMotion()).toBe(false);
|
||||||
|
expect(isMobileViewport()).toBe(false);
|
||||||
|
expect(isLowPowerDevice()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: matchMedia answers false for the query -> false", () => {
|
||||||
|
expect(isCoarsePointer()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: matchMedia answers true only for the asked query -> only that hint is true", () => {
|
||||||
|
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||||
|
|
||||||
|
expect(prefersReducedMotion()).toBe(true);
|
||||||
|
expect(isCoarsePointer()).toBe(false);
|
||||||
|
expect(isMobileViewport()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSaveData()", () => {
|
||||||
|
it.each([
|
||||||
|
["connection is missing", undefined],
|
||||||
|
["connection has no saveData", {}],
|
||||||
|
["saveData is false", { saveData: false }],
|
||||||
|
["saveData is a truthy non-boolean", { saveData: "true" }],
|
||||||
|
])("path: %s -> false", (_label, connection) => {
|
||||||
|
mockNavigator({ connection });
|
||||||
|
|
||||||
|
expect(isSaveData()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: saveData is exactly true -> true", () => {
|
||||||
|
mockNavigator({ connection: { saveData: true } });
|
||||||
|
|
||||||
|
expect(isSaveData()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLowPowerDevice(): limites de cada condição, uma de cada vez", () => {
|
||||||
|
it.each([
|
||||||
|
["5 cores", { hardwareConcurrency: 5 }],
|
||||||
|
["8 GB of memory", { hardwareConcurrency: 8, deviceMemory: 8 }],
|
||||||
|
["5 GB of memory", { hardwareConcurrency: 8, deviceMemory: 5 }],
|
||||||
|
["cores reported as text", { hardwareConcurrency: "2" }],
|
||||||
|
["memory reported as text", { deviceMemory: "2" }],
|
||||||
|
["cores reported as null", { hardwareConcurrency: null }],
|
||||||
|
])(
|
||||||
|
"path: %s -> full power (just above the limit or not a number)",
|
||||||
|
(_label, hints) => {
|
||||||
|
mockNavigator(hints);
|
||||||
|
|
||||||
|
expect(isLowPowerDevice()).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["exactly 4 cores", { hardwareConcurrency: 4 }],
|
||||||
|
["exactly 4 GB", { deviceMemory: 4 }],
|
||||||
|
["0.5 GB", { deviceMemory: 0.5 }],
|
||||||
|
["1 core", { hardwareConcurrency: 1 }],
|
||||||
|
])("path: %s -> low power", (_label, hints) => {
|
||||||
|
mockNavigator(hints);
|
||||||
|
|
||||||
|
expect(isLowPowerDevice()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: only the first condition is true (touch) -> low power without reading the rest", () => {
|
||||||
|
mockMedia(["(pointer: coarse)"]);
|
||||||
|
mockNavigator({ hardwareConcurrency: 16, deviceMemory: 16 });
|
||||||
|
|
||||||
|
expect(isLowPowerDevice()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: several conditions true at once -> still just true", () => {
|
||||||
|
mockMedia(["(pointer: coarse)", "(prefers-reduced-motion: reduce)"]);
|
||||||
|
mockNavigator({
|
||||||
|
hardwareConcurrency: 2,
|
||||||
|
deviceMemory: 2,
|
||||||
|
connection: { saveData: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isLowPowerDevice()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without window and navigator (server)", () => {
|
||||||
|
it("path: no window -> media hints are false", () => {
|
||||||
|
const device = loadOnServer({ window: undefined, navigator: undefined });
|
||||||
|
|
||||||
|
expect(device.isCoarsePointer()).toBe(false);
|
||||||
|
expect(device.prefersReducedMotion()).toBe(false);
|
||||||
|
expect(device.isMobileViewport()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no navigator -> save data is off and the device counts as low power", () => {
|
||||||
|
const device = loadOnServer({ window: undefined, navigator: undefined });
|
||||||
|
|
||||||
|
expect(device.isSaveData()).toBe(false);
|
||||||
|
expect(device.isLowPowerDevice()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a navigator without window (runtime that exposes only navigator) -> hints still read", () => {
|
||||||
|
const device = loadOnServer({
|
||||||
|
window: undefined,
|
||||||
|
navigator: { hardwareConcurrency: 16, connection: { saveData: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(device.isSaveData()).toBe(true);
|
||||||
|
expect(device.isLowPowerDevice()).toBe(true);
|
||||||
|
const capable = loadOnServer({
|
||||||
|
window: undefined,
|
||||||
|
navigator: { hardwareConcurrency: 16 },
|
||||||
|
});
|
||||||
|
expect(capable.isLowPowerDevice()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
isCoarsePointer,
|
||||||
|
isLowPowerDevice,
|
||||||
|
isMobileViewport,
|
||||||
|
isSaveData,
|
||||||
|
MOBILE_QUERY,
|
||||||
|
} from "./device";
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
function mockMedia(active: string[]) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: active.includes(query),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockNavigator(values: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMedia([]);
|
||||||
|
mockNavigator({
|
||||||
|
hardwareConcurrency: 8,
|
||||||
|
deviceMemory: undefined,
|
||||||
|
connection: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("device hints", () => {
|
||||||
|
it("treats a capable desktop as full power", () => {
|
||||||
|
expect(isLowPowerDevice()).toBe(false);
|
||||||
|
expect(isCoarsePointer()).toBe(false);
|
||||||
|
expect(isSaveData()).toBe(false);
|
||||||
|
expect(isMobileViewport()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the phone breakpoint from the shared query", () => {
|
||||||
|
mockMedia([MOBILE_QUERY]);
|
||||||
|
expect(isMobileViewport()).toBe(true);
|
||||||
|
expect(MOBILE_QUERY).toBe("(max-width: 767px)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
|
||||||
|
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||||
|
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||||
|
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||||
|
["two cores", () => mockNavigator({ hardwareConcurrency: 2 })],
|
||||||
|
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
|
||||||
|
["2 GB of memory", () => mockNavigator({ deviceMemory: 2 })],
|
||||||
|
])("is low power with %s", (_label, arrange) => {
|
||||||
|
arrange();
|
||||||
|
expect(isLowPowerDevice()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not penalize a browser that hides the hardware hints", () => {
|
||||||
|
mockNavigator({ hardwareConcurrency: undefined, deviceMemory: undefined });
|
||||||
|
expect(isLowPowerDevice()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 8 GB and 6 cores on the full path", () => {
|
||||||
|
mockNavigator({ hardwareConcurrency: 6, deviceMemory: 8 });
|
||||||
|
expect(isLowPowerDevice()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Leituras do aparelho que decidem o que a página carrega e anima: ponteiro de toque, pouca CPU ou memória,
|
||||||
|
* "economizar dados" e largura de celular. Tudo roda só no navegador (no servidor, devolve o valor mais leve).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Abaixo disto (px) a página usa as versões de celular dos vídeos e capas. */
|
||||||
|
export const MOBILE_MAX_WIDTH = 767;
|
||||||
|
export const MOBILE_QUERY = `(max-width: ${MOBILE_MAX_WIDTH}px)`;
|
||||||
|
|
||||||
|
type NavigatorHints = Navigator & {
|
||||||
|
deviceMemory?: number;
|
||||||
|
connection?: { saveData?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
function matches(query: string) {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia(query).matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ponteiro principal impreciso (dedo): sem rolagem suavizada nem cena 3D. */
|
||||||
|
export const isCoarsePointer = () => matches("(pointer: coarse)");
|
||||||
|
|
||||||
|
export const prefersReducedMotion = () =>
|
||||||
|
matches("(prefers-reduced-motion: reduce)");
|
||||||
|
|
||||||
|
/** Tela de celular: abaixo de 768 px. */
|
||||||
|
export const isMobileViewport = () => matches(MOBILE_QUERY);
|
||||||
|
|
||||||
|
/** "Economizar dados" ligado: só imagens, nenhum vídeo é baixado sozinho. */
|
||||||
|
export function isSaveData() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return (navigator as NavigatorHints).connection?.saveData === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aparelho que não deve montar WebGL: toque, 4 núcleos ou menos, 4 GB de memória ou menos, economia de dados
|
||||||
|
* ou movimento reduzido. O que não é informado pelo navegador (Safari, Firefox) não conta contra o aparelho.
|
||||||
|
*/
|
||||||
|
export function isLowPowerDevice() {
|
||||||
|
if (typeof navigator === "undefined") return true;
|
||||||
|
const hints = navigator as NavigatorHints;
|
||||||
|
return (
|
||||||
|
isCoarsePointer() ||
|
||||||
|
prefersReducedMotion() ||
|
||||||
|
isSaveData() ||
|
||||||
|
(typeof hints.hardwareConcurrency === "number" &&
|
||||||
|
hints.hardwareConcurrency <= 4) ||
|
||||||
|
(typeof hints.deviceMemory === "number" && hints.deviceMemory <= 4)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
chapterAnchors,
|
||||||
|
chapterFraction,
|
||||||
|
chapterIndexAt,
|
||||||
|
desktopFractionAt,
|
||||||
|
getProjectVideo,
|
||||||
|
getShowcaseAssets,
|
||||||
|
mobileFractionAt,
|
||||||
|
SHOWCASE,
|
||||||
|
type ShowcaseConfig,
|
||||||
|
} from "./project-showcase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de lib/project-showcase.ts (complemento de project-showcase.test.ts):
|
||||||
|
* getProjectVideo (id da vitrine x id fora dela x chaves herdadas de Object, nos dois aparelhos) e os trechos
|
||||||
|
* de chapterAnchors/interpolate/chapterFraction que o arquivo principal não exercita.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function config(
|
||||||
|
chapters: ShowcaseConfig["chapters"],
|
||||||
|
durations = { desktop: 10, mobile: 20 },
|
||||||
|
): ShowcaseConfig {
|
||||||
|
return { id: "fixture", accent: "#fff", durations, weight: 1, chapters };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getProjectVideo", () => {
|
||||||
|
it.each(SHOWCASE.map(({ id }) => id))(
|
||||||
|
"path: showcase project %s -> assets for the requested device",
|
||||||
|
(id) => {
|
||||||
|
expect(getProjectVideo(id, "desktop")).toEqual(
|
||||||
|
getShowcaseAssets(id, "desktop"),
|
||||||
|
);
|
||||||
|
expect(getProjectVideo(id, "mobile")).toEqual(
|
||||||
|
getShowcaseAssets(id, "mobile"),
|
||||||
|
);
|
||||||
|
expect(getProjectVideo(id)).toEqual(getShowcaseAssets(id, "desktop"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"",
|
||||||
|
"nao-existe",
|
||||||
|
"constructor",
|
||||||
|
"__proto__",
|
||||||
|
"toString",
|
||||||
|
"SEV-MODAINTIMA",
|
||||||
|
])(
|
||||||
|
"path: id %j is not a showcase project -> undefined for both devices",
|
||||||
|
(id) => {
|
||||||
|
expect(getProjectVideo(id)).toBeUndefined();
|
||||||
|
expect(getProjectVideo(id, "mobile")).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: the two devices differ in size and in poster, not in the project folder", () => {
|
||||||
|
const desktop = getProjectVideo("nutriscan", "desktop");
|
||||||
|
const mobile = getProjectVideo("nutriscan", "mobile");
|
||||||
|
|
||||||
|
expect(desktop).toMatchObject({ width: 1440, height: 900 });
|
||||||
|
expect(mobile).toMatchObject({ width: 390, height: 844 });
|
||||||
|
expect(desktop?.poster).toBe("/videos/nutriscan/desktop.webp");
|
||||||
|
expect(mobile?.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterAnchors", () => {
|
||||||
|
it("path: chapters that advance on both videos -> one anchor each between [0,0] and [1,1]", () => {
|
||||||
|
const anchors = chapterAnchors(
|
||||||
|
config([
|
||||||
|
{ label: "A", desktop: 2, mobile: 4 },
|
||||||
|
{ label: "B", desktop: 5, mobile: 10 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(anchors).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[0.2, 0.2],
|
||||||
|
[0.5, 0.5],
|
||||||
|
[1, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a chapter at second 0 on both videos -> no duplicate of the origin", () => {
|
||||||
|
const anchors = chapterAnchors(
|
||||||
|
config([
|
||||||
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "B", desktop: 5, mobile: 10 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(anchors).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[0.5, 0.5],
|
||||||
|
[1, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a chapter that does not advance on the desktop video -> skipped", () => {
|
||||||
|
const anchors = chapterAnchors(
|
||||||
|
config([
|
||||||
|
{ label: "A", desktop: 4, mobile: 4 },
|
||||||
|
{ label: "B", desktop: 4, mobile: 12 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(anchors).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[0.4, 0.2],
|
||||||
|
[1, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a chapter that does not advance on the mobile video -> skipped", () => {
|
||||||
|
const anchors = chapterAnchors(
|
||||||
|
config([
|
||||||
|
{ label: "A", desktop: 4, mobile: 10 },
|
||||||
|
{ label: "B", desktop: 8, mobile: 10 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(anchors).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[0.4, 0.5],
|
||||||
|
[1, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mobileFractionAt / desktopFractionAt (interpolation)", () => {
|
||||||
|
const sample = config([
|
||||||
|
{ label: "A", desktop: 2, mobile: 10 },
|
||||||
|
{ label: "B", desktop: 8, mobile: 15 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[-1, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0.2, 0.5],
|
||||||
|
[0.5, 0.5 + (0.3 / 0.6) * 0.25],
|
||||||
|
[0.8, 0.75],
|
||||||
|
[1, 1],
|
||||||
|
[2, 1],
|
||||||
|
])(
|
||||||
|
"path: desktop fraction %f -> mobile fraction %f",
|
||||||
|
(fraction, expected) => {
|
||||||
|
expect(mobileFractionAt(sample, fraction)).toBeCloseTo(expected, 9);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: desktopFractionAt is the inverse on the anchors", () => {
|
||||||
|
expect(desktopFractionAt(sample, 0.5)).toBeCloseTo(0.2, 9);
|
||||||
|
expect(desktopFractionAt(sample, 0.75)).toBeCloseTo(0.8, 9);
|
||||||
|
expect(desktopFractionAt(sample, 0)).toBe(0);
|
||||||
|
expect(desktopFractionAt(sample, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a chapter right at the end makes a zero-length span -> no division by zero", () => {
|
||||||
|
const flat = config([{ label: "Fim", desktop: 10, mobile: 20 }]);
|
||||||
|
|
||||||
|
expect(mobileFractionAt(flat, 1)).toBe(1);
|
||||||
|
expect(Number.isFinite(mobileFractionAt(flat, 0.999))).toBe(true);
|
||||||
|
expect(Number.isFinite(desktopFractionAt(flat, 1))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterIndexAt / chapterFraction", () => {
|
||||||
|
const sample = config([
|
||||||
|
{ label: "A", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "B", desktop: 4, mobile: 8 },
|
||||||
|
{ label: "C", desktop: 8, mobile: 16 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[-5, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0.399, 0],
|
||||||
|
[0.4, 1],
|
||||||
|
[0.7999, 1],
|
||||||
|
[0.8, 2],
|
||||||
|
[1, 2],
|
||||||
|
[9, 2],
|
||||||
|
])("path: fraction %f -> chapter %i", (fraction, expected) => {
|
||||||
|
expect(chapterIndexAt(sample, fraction)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: chapter index in range -> start fraction on each video", () => {
|
||||||
|
expect(chapterFraction(sample, 1, "desktop")).toBeCloseTo(0.4, 9);
|
||||||
|
expect(chapterFraction(sample, 1, "mobile")).toBeCloseTo(0.4, 9);
|
||||||
|
expect(chapterFraction(sample, 2, "desktop")).toBeCloseTo(0.8, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([-1, 3, 99])(
|
||||||
|
"path: chapter index %i does not exist -> 0",
|
||||||
|
(index) => {
|
||||||
|
expect(chapterFraction(sample, index, "desktop")).toBe(0);
|
||||||
|
expect(chapterFraction(sample, index, "mobile")).toBe(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getProjectVideo,
|
getProjectVideo,
|
||||||
getScrubSource,
|
getScrubSource,
|
||||||
getShowcaseAssets,
|
getShowcaseAssets,
|
||||||
|
MAX_CHAPTERS,
|
||||||
mobileFractionAt,
|
mobileFractionAt,
|
||||||
PROJECT_VIDEO_IDS,
|
PROJECT_VIDEO_IDS,
|
||||||
SHOWCASE,
|
SHOWCASE,
|
||||||
@@ -16,6 +17,34 @@ import {
|
|||||||
|
|
||||||
const MAX_BYTES = 3 * 1024 * 1024;
|
const MAX_BYTES = 3 * 1024 * 1024;
|
||||||
const publicFile = (path: string) => join(process.cwd(), "public", path);
|
const publicFile = (path: string) => join(process.cwd(), "public", path);
|
||||||
|
/** Os projetos de cliente que a home entrega à vitrine. */
|
||||||
|
const HOME_IDS = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||||
|
|
||||||
|
describe("showcase length", () => {
|
||||||
|
it.each(SHOWCASE)("$id has at most three chapters", (config) => {
|
||||||
|
expect(MAX_CHAPTERS).toBe(3);
|
||||||
|
expect(config.chapters.length).toBeLessThanOrEqual(MAX_CHAPTERS);
|
||||||
|
expect(config.chapters.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the four home projects for about three and a half screens at most", () => {
|
||||||
|
const total = SHOWCASE.filter((config) => HOME_IDS.includes(config.id))
|
||||||
|
.map((config) => config.weight)
|
||||||
|
.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
expect(HOME_IDS.every((id) => SHOWCASE.some((c) => c.id === id))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(total).toBeLessThanOrEqual(3.5);
|
||||||
|
expect(total).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the NutriScan configuration for /cases", () => {
|
||||||
|
const nutriscan = SHOWCASE.find((config) => config.id === "nutriscan");
|
||||||
|
expect(nutriscan).toBeDefined();
|
||||||
|
expect(PROJECT_VIDEO_IDS).toContain("nutriscan");
|
||||||
|
expect(getOgImagePath("nutriscan")).toBe("/videos/nutriscan/og.jpg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("showcase chapters", () => {
|
describe("showcase chapters", () => {
|
||||||
it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => {
|
it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => {
|
||||||
@@ -60,8 +89,9 @@ describe("showcase chapters", () => {
|
|||||||
expect(lavacao).toBeDefined();
|
expect(lavacao).toBeDefined();
|
||||||
if (!lavacao) return;
|
if (!lavacao) return;
|
||||||
expect(chapterIndexAt(lavacao, 0)).toBe(0);
|
expect(chapterIndexAt(lavacao, 0)).toBe(0);
|
||||||
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(1);
|
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(0);
|
||||||
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(2);
|
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(1);
|
||||||
|
expect(chapterIndexAt(lavacao, 25 / lavacao.durations.desktop)).toBe(2);
|
||||||
expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1);
|
expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1);
|
||||||
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
|
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
|
||||||
expect(chapterIndexAt(lavacao, -1)).toBe(0);
|
expect(chapterIndexAt(lavacao, -1)).toBe(0);
|
||||||
@@ -71,6 +101,22 @@ describe("showcase chapters", () => {
|
|||||||
describe("showcase assets", () => {
|
describe("showcase assets", () => {
|
||||||
it("returns undefined for projects without a recorded video", () => {
|
it("returns undefined for projects without a recorded video", () => {
|
||||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||||
|
expect(getProjectVideo("reddit-explorer", "mobile")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the version for the device, desktop by default", () => {
|
||||||
|
expect(getProjectVideo("nutriscan")?.poster).toBe(
|
||||||
|
"/videos/nutriscan/desktop.webp",
|
||||||
|
);
|
||||||
|
expect(getProjectVideo("nutriscan", "desktop")).toEqual(
|
||||||
|
getShowcaseAssets("nutriscan", "desktop"),
|
||||||
|
);
|
||||||
|
const mobile = getProjectVideo("nutriscan", "mobile");
|
||||||
|
expect(mobile?.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||||
|
expect(mobile?.sources.map((source) => source.src)).toEqual([
|
||||||
|
"/videos/nutriscan/mobile.webm",
|
||||||
|
"/videos/nutriscan/mobile.mp4",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(PROJECT_VIDEO_IDS)(
|
it.each(PROJECT_VIDEO_IDS)(
|
||||||
|
|||||||
+21
-16
@@ -2,6 +2,7 @@
|
|||||||
* Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos.
|
* Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos.
|
||||||
*
|
*
|
||||||
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
||||||
|
* - desktop.webp e mobile.webp: pôsteres; a versão de celular vale abaixo de 768 px (lib/device.ts);
|
||||||
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
||||||
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
|
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
|
||||||
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
|
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
|
||||||
@@ -27,18 +28,24 @@ export interface ShowcaseConfig {
|
|||||||
chapters: readonly ShowcaseChapter[];
|
chapters: readonly ShowcaseChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ordem da vitrine = ordem dos cards na home. Capítulos marcados nos quadros dos próprios vídeos. */
|
/** Máximo de capítulos por projeto na vitrine: três marcas bastam para situar o visitante sem virar índice. */
|
||||||
|
export const MAX_CHAPTERS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordem da vitrine = ordem do SHOWCASE para os projetos que o provedor recebe. Capítulos marcados nos quadros
|
||||||
|
* dos próprios vídeos, no máximo MAX_CHAPTERS por projeto (os mais representativos, com os tempos originais).
|
||||||
|
* O peso soma cerca de 3,5 alturas de tela para os quatro projetos de cliente da home (sev-modaintima,
|
||||||
|
* lavacao-047, sc-plus e as-servicos): a vitrine inteira prende pouco a rolagem.
|
||||||
|
*/
|
||||||
export const SHOWCASE: readonly ShowcaseConfig[] = [
|
export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||||
{
|
{
|
||||||
id: "sev-modaintima",
|
id: "sev-modaintima",
|
||||||
accent: "#c2303a",
|
accent: "#c2303a",
|
||||||
durations: { desktop: 17.9, mobile: 30.1 },
|
durations: { desktop: 17.9, mobile: 30.1 },
|
||||||
weight: 1,
|
weight: 0.95,
|
||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
{ label: "Categorias", desktop: 2.7, mobile: 1.9 },
|
|
||||||
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
|
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
|
||||||
{ label: "Nossa essência", desktop: 10.7, mobile: 8.6 },
|
|
||||||
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
|
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -46,24 +53,21 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
|||||||
id: "lavacao-047",
|
id: "lavacao-047",
|
||||||
accent: "#4aa3ff",
|
accent: "#4aa3ff",
|
||||||
durations: { desktop: 30.07, mobile: 30.1 },
|
durations: { desktop: 30.07, mobile: 30.1 },
|
||||||
weight: 1.4,
|
weight: 1.1,
|
||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
{ label: "Tabela de preços", desktop: 1.5, mobile: 1.9 },
|
|
||||||
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
|
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
|
||||||
{ label: "Como funciona", desktop: 13.5, mobile: 13.2 },
|
|
||||||
{ label: "Galeria e mapa", desktop: 18.5, mobile: 17.5 },
|
|
||||||
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
|
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// fora da home atual, mas usado em /cases (og-card): a configuração fica
|
||||||
id: "nutriscan",
|
id: "nutriscan",
|
||||||
accent: "#2f9e5f",
|
accent: "#2f9e5f",
|
||||||
durations: { desktop: 15.67, mobile: 26.4 },
|
durations: { desktop: 15.67, mobile: 26.4 },
|
||||||
weight: 0.9,
|
weight: 0.8,
|
||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Busca", desktop: 0, mobile: 0 },
|
{ label: "Busca", desktop: 0, mobile: 0 },
|
||||||
{ label: "Por que confiar", desktop: 2.3, mobile: 3 },
|
|
||||||
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
|
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
|
||||||
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
|
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
|
||||||
],
|
],
|
||||||
@@ -72,12 +76,10 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
|||||||
id: "sc-plus",
|
id: "sc-plus",
|
||||||
accent: "#22c55e",
|
accent: "#22c55e",
|
||||||
durations: { desktop: 13, mobile: 19.67 },
|
durations: { desktop: 13, mobile: 19.67 },
|
||||||
weight: 0.85,
|
weight: 0.8,
|
||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
{ label: "Sobre", desktop: 1.5, mobile: 2.2 },
|
|
||||||
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
|
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
|
||||||
{ label: "Apoiadores e participação", desktop: 7.5, mobile: 12.8 },
|
|
||||||
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
|
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -132,9 +134,12 @@ export function getOgImagePath(id: string) {
|
|||||||
return `/videos/${id}/og.jpg`;
|
return `/videos/${id}/og.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vídeo desktop de um projeto da vitrine: é o que as capas dos cards tocam. */
|
/** Arquivos de um projeto da vitrine para o aparelho (desktop por padrão): é o que as capas dos cards usam. */
|
||||||
export function getProjectVideo(id: string): ShowcaseAssets | undefined {
|
export function getProjectVideo(
|
||||||
return byId.has(id) ? getShowcaseAssets(id, "desktop") : undefined;
|
id: string,
|
||||||
|
device: Device = "desktop",
|
||||||
|
): ShowcaseAssets | undefined {
|
||||||
|
return byId.has(id) ? getShowcaseAssets(id, device) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user