perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -1,65 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { getProjectVideo } from "@/lib/project-showcase";
|
||||
import { ProjectMedia } from "./showcase/ProjectMedia";
|
||||
|
||||
type CoverProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
};
|
||||
|
||||
/** Capturas de tela publicadas dos projetos que têm interface pública. */
|
||||
const SCREENSHOTS: Record<string, string | undefined> = {
|
||||
nutriscan: "/assets/notebook/nutriscan.webp",
|
||||
"sc-plus": "/assets/notebook/sc-plus.webp",
|
||||
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
|
||||
"as-servicos": "/assets/notebook/as-servicos.webp",
|
||||
};
|
||||
|
||||
function initials(title: string) {
|
||||
return title
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 2 || /^[A-Z]/.test(word))
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa do card de projeto: a captura de tela real quando existe; sem captura, um painel gerado em código
|
||||
* (só o monograma, sem repetir a stack que já aparece nas tags do card) em vez de uma imagem inventada.
|
||||
*/
|
||||
export function ProjectCover({
|
||||
project,
|
||||
priority = false,
|
||||
}: {
|
||||
project: CoverProject;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const video = getProjectVideo(project.id);
|
||||
const src = SCREENSHOTS[project.id];
|
||||
|
||||
if (video) return <ProjectMedia project={project} />;
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<div className="ds-cover">
|
||||
<Image
|
||||
src={src}
|
||||
alt={`Captura de tela de ${project.title}`}
|
||||
width={1600}
|
||||
height={1000}
|
||||
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
|
||||
priority={priority}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ds-cover ds-cover-fallback" aria-hidden="true">
|
||||
<span className="ds-cover-mark">{initials(project.title)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { ProjectVideo } from "./ProjectVideo";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ProjectVideo (complemento de ProjectVideo.test.tsx): modo só pôster
|
||||
* (movimento reduzido ou "economizar dados") com e sem pôster pedido, inativo x ativo, vídeo sem fontes,
|
||||
* falha do play() e a combinação de ativo com modo só pôster.
|
||||
*/
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const video = getShowcaseAssets("nutriscan", "desktop");
|
||||
|
||||
function setSaveData(value: boolean | undefined) {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: value === undefined ? undefined : { saveData: value },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
setSaveData(undefined);
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
setSaveData(undefined);
|
||||
});
|
||||
|
||||
describe("ProjectVideo: modo só pôster", () => {
|
||||
it.each([
|
||||
[
|
||||
"reduced motion",
|
||||
() => {
|
||||
reducedMotion = true;
|
||||
},
|
||||
],
|
||||
["the data saver", () => setSaveData(true)],
|
||||
])(
|
||||
"path: %s and the poster is not wanted yet -> renders nothing at all",
|
||||
(_l, arrange) => {
|
||||
arrange();
|
||||
|
||||
const { container } = render(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { ProjectVideo } from "./ProjectVideo";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const video = getShowcaseAssets("nutriscan", "desktop");
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectVideo", () => {
|
||||
it("renders a muted looping inline video that downloads nothing until it is active", () => {
|
||||
const { container } = render(
|
||||
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||
);
|
||||
const element = container.querySelector("video");
|
||||
|
||||
expect(element).not.toBeNull();
|
||||
expect(element).toHaveAttribute("autoplay");
|
||||
expect(element).toHaveAttribute("loop");
|
||||
expect(element).toHaveAttribute("playsinline");
|
||||
expect(element).toHaveAttribute("preload", "metadata");
|
||||
expect(element).toHaveAttribute("poster", video.poster);
|
||||
expect(element?.muted).toBe(true);
|
||||
expect(element).toHaveAttribute("data-ready", "false");
|
||||
expect(container.querySelectorAll("source")).toHaveLength(0);
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches the sources and plays when it becomes the active card, then pauses when it stops being", () => {
|
||||
const { container, rerender } = render(
|
||||
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||
);
|
||||
|
||||
rerender(<ProjectVideo video={video} title="Demo" active />);
|
||||
const sources = [...container.querySelectorAll("source")];
|
||||
expect(sources.map((source) => source.getAttribute("src"))).toEqual(
|
||||
video.sources.map((source) => source.src),
|
||||
);
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
||||
|
||||
rerender(<ProjectVideo video={video} title="Demo" active={false} />);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
||||
// sai de cena, mas o arquivo já baixado fica: voltar a tocar não baixa de novo
|
||||
expect(container.querySelectorAll("source")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("fades the video in once it is playing", () => {
|
||||
const { container } = render(
|
||||
<ProjectVideo video={video} title="Demo" active />,
|
||||
);
|
||||
const element = container.querySelector("video") as HTMLVideoElement;
|
||||
|
||||
act(() => {
|
||||
element.dispatchEvent(new Event("playing"));
|
||||
});
|
||||
expect(element).toHaveAttribute("data-ready", "true");
|
||||
});
|
||||
|
||||
it("does not even request the poster while the card is far from the screen", () => {
|
||||
const { container, rerender } = render(
|
||||
<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", () => {
|
||||
reducedMotion = true;
|
||||
const { container } = render(
|
||||
<ProjectVideo video={video} title="Demo" active />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Captura de tela de Demo" }),
|
||||
).toBeInTheDocument();
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { ShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { useSaveData } from "./showcase/useShowcaseMedia";
|
||||
|
||||
/**
|
||||
* Vídeo de rolagem na capa do card. `video` já é a versão do aparelho (quem chama escolhe: celular abaixo de
|
||||
* 768 px; no servidor, nenhuma). Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que está
|
||||
* sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o card
|
||||
* fica ativo (`preload="metadata"`, sem fontes até lá); fora de cena ele pausa. Com prefers-reduced-motion ou
|
||||
* "economizar dados" só o pôster aparece. `showPoster` falso (seção longe da tela) não pede nem o pôster.
|
||||
* O pôster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
|
||||
*/
|
||||
export function ProjectVideo({
|
||||
video,
|
||||
title,
|
||||
active,
|
||||
showPoster = true,
|
||||
}: {
|
||||
video: ShowcaseAssets;
|
||||
title: string;
|
||||
active: boolean;
|
||||
showPoster?: boolean;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const saveData = useSaveData();
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [attached, setAttached] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const posterOnly = reducedMotion || saveData;
|
||||
const playing = active && !posterOnly;
|
||||
const firstSource = video.sources[0]?.src;
|
||||
|
||||
useEffect(() => {
|
||||
if (playing) setAttached(true);
|
||||
}, [playing]);
|
||||
|
||||
useEffect(() => {
|
||||
// as fontes entram depois do primeiro render (ou mudam com o aparelho): o elemento precisa ler a lista nova
|
||||
if (attached && firstSource) ref.current?.load();
|
||||
}, [attached, firstSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element || !attached) return;
|
||||
if (playing) {
|
||||
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
|
||||
element.play().catch(() => {});
|
||||
} else {
|
||||
element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar
|
||||
}
|
||||
}, [attached, playing]);
|
||||
|
||||
if (posterOnly) {
|
||||
if (!showPoster) return null;
|
||||
return (
|
||||
<Image
|
||||
src={video.poster}
|
||||
alt={`Captura de tela de ${title}`}
|
||||
width={video.width}
|
||||
height={video.height}
|
||||
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={showPoster ? video.poster : undefined}
|
||||
width={video.width}
|
||||
height={video.height}
|
||||
data-ready={ready}
|
||||
onPlaying={() => setReady(true)}
|
||||
aria-label={`Demonstração em vídeo de ${title}`}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
>
|
||||
{attached &&
|
||||
video.sources.map((source) => (
|
||||
<source key={source.src} src={source.src} type={source.type} />
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { Section } from "./Section";
|
||||
|
||||
/**
|
||||
* Entradas por rolagem sem biblioteca de animação: o componente só marca data-shown (ou data-loaded) quando o
|
||||
* IntersectionObserver avisa e o CSS (globals.css) faz a transição. Aqui vale o contrato com o CSS: as classes,
|
||||
* as variáveis e o momento em que as marcas aparecem.
|
||||
*/
|
||||
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
observed: Element[];
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
const original = global.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: Observer;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = { callback, observed: [] };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.observed.push(target);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = original;
|
||||
});
|
||||
|
||||
/** Avisa que `target` entrou inteiro na janela. */
|
||||
function reveal(target: Element) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
if (observer.observed.includes(target))
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1,
|
||||
intersectionRect: { height: 400 },
|
||||
target,
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Reveal", () => {
|
||||
it("renders its children and waits for the viewport to mark them as shown", () => {
|
||||
const { container } = render(
|
||||
<Reveal>
|
||||
<p>Conteúdo</p>
|
||||
</Reveal>,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(screen.getByText("Conteúdo")).toBeInTheDocument();
|
||||
expect(wrapper).toHaveClass("ds-reveal");
|
||||
expect(wrapper).not.toHaveAttribute("data-shown");
|
||||
|
||||
reveal(wrapper);
|
||||
expect(wrapper).toHaveAttribute("data-shown");
|
||||
});
|
||||
|
||||
it("hands the CSS its offset, duration and delay as custom properties", () => {
|
||||
const { container } = render(
|
||||
<Reveal y={40} duration={0.9} delay={0.12} className="extra">
|
||||
x
|
||||
</Reveal>,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(wrapper.style.getPropertyValue("--reveal-y")).toBe("40px");
|
||||
expect(wrapper.style.getPropertyValue("--reveal-duration")).toBe("0.9s");
|
||||
expect(wrapper.style.getPropertyValue("--reveal-delay")).toBe("0.12s");
|
||||
expect(wrapper).toHaveClass("ds-reveal", "extra");
|
||||
});
|
||||
|
||||
it("uses no inline opacity or transform: the hidden state lives in the CSS, so no script means visible content", () => {
|
||||
const { container } = render(<Reveal>x</Reveal>);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(wrapper.style.opacity).toBe("");
|
||||
expect(wrapper.style.transform).toBe("");
|
||||
expect(wrapper.style.clipPath).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DrawLine", () => {
|
||||
it("draws once the line is mostly on screen, and takes its delay from the prop", () => {
|
||||
const { container } = render(<DrawLine delay={0.16} className="x" />);
|
||||
const line = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(line).toHaveClass("ds-draw-line", "x");
|
||||
expect(line.style.getPropertyValue("--reveal-delay")).toBe("0.16s");
|
||||
expect(line).not.toHaveAttribute("data-shown");
|
||||
|
||||
reveal(line);
|
||||
expect(line).toHaveAttribute("data-shown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LazyRevealImage", () => {
|
||||
it("shows the picture (data-loaded) only after it has loaded", async () => {
|
||||
const { container } = render(
|
||||
<LazyRevealImage src="/foto.webp" alt="Foto" className="h-40" />,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
const image = screen.getByAltText("Foto");
|
||||
|
||||
expect(wrapper).toHaveClass("ds-lazy-reveal", "h-40");
|
||||
expect(wrapper).not.toHaveAttribute("data-loaded");
|
||||
|
||||
// o next/image só repassa o onLoad depois de decodificar a imagem (uma microtarefa)
|
||||
await act(async () => {
|
||||
image.dispatchEvent(new Event("load"));
|
||||
});
|
||||
expect(wrapper).toHaveAttribute("data-loaded");
|
||||
});
|
||||
|
||||
it("does not wait forever for an image that was already complete before the page hydrated", async () => {
|
||||
const complete = jest
|
||||
.spyOn(HTMLImageElement.prototype, "complete", "get")
|
||||
.mockReturnValue(true);
|
||||
const width = jest
|
||||
.spyOn(HTMLImageElement.prototype, "naturalWidth", "get")
|
||||
.mockReturnValue(640);
|
||||
|
||||
let view: ReturnType<typeof render> | undefined;
|
||||
await act(async () => {
|
||||
view = render(<LazyRevealImage src="/foto.webp" alt="Foto" />);
|
||||
});
|
||||
|
||||
expect(view?.container.firstElementChild).toHaveAttribute("data-loaded");
|
||||
complete.mockRestore();
|
||||
width.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Section", () => {
|
||||
it("renders the heading, the description and the content, and marks only the header as it enters", () => {
|
||||
const { container } = render(
|
||||
<Section
|
||||
id="sobre"
|
||||
eyebrow="Sobre"
|
||||
title="Quem faz"
|
||||
description="Texto de apoio"
|
||||
aside={<span>lateral</span>}
|
||||
>
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
const header = container.querySelector(".ds-section-header") as HTMLElement;
|
||||
|
||||
expect(container.querySelector("section#sobre")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Quem faz" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Texto de apoio")).toHaveClass(
|
||||
"ds-section-description",
|
||||
"ds-reveal",
|
||||
);
|
||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
|
||||
expect(screen.getByText("lateral").closest("aside")).toHaveClass(
|
||||
"ds-reveal",
|
||||
);
|
||||
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
||||
expect(header).not.toHaveAttribute("data-shown");
|
||||
|
||||
reveal(header);
|
||||
expect(header).toHaveAttribute("data-shown");
|
||||
});
|
||||
|
||||
it("staggers the header items with growing delays", () => {
|
||||
render(
|
||||
<Section
|
||||
eyebrow="Sobre"
|
||||
title="Quem faz"
|
||||
description="Texto"
|
||||
aside={<span>lateral</span>}
|
||||
>
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
const delay = (element: HTMLElement) =>
|
||||
Number.parseFloat(element.style.getPropertyValue("--reveal-delay"));
|
||||
|
||||
const eyebrow = delay(screen.getByText("Sobre"));
|
||||
const description = delay(screen.getByText("Texto"));
|
||||
const aside = delay(
|
||||
screen.getByText("lateral").closest("aside") as HTMLElement,
|
||||
);
|
||||
|
||||
expect(eyebrow).toBeLessThan(description);
|
||||
expect(description).toBeLessThan(aside);
|
||||
});
|
||||
|
||||
it("wraps the body in Reveal, unless the section animates its own children", () => {
|
||||
const { container, rerender } = render(
|
||||
<Section title="Titulo">
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Section title="Titulo" disableChildrenAnimation>
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeNull();
|
||||
expect(container.querySelector(".ds-section-shell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the dark skin when asked", () => {
|
||||
const { container } = render(
|
||||
<Section title="Titulo" dark>
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("section")).toHaveClass(
|
||||
"ds-section",
|
||||
"ds-section-dark",
|
||||
);
|
||||
});
|
||||
});
|
||||
+20
-44
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { type CSSProperties, type ReactNode, useRef } from "react";
|
||||
import { useInView } from "@/app/hooks/useInView";
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode;
|
||||
y?: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
ease?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
scrub?: boolean | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conteúdo que sobe e aparece uma vez, quando chega à janela. A animação é CSS (.ds-reveal em globals.css):
|
||||
* só opacidade e deslocamento, que a placa de vídeo compõe sem redesenhar. Sem JavaScript ou com movimento
|
||||
* reduzido, o conteúdo já nasce visível.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
y = 36,
|
||||
@@ -24,46 +23,23 @@ export function Reveal({
|
||||
delay = 0,
|
||||
className = "",
|
||||
}: RevealProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [animating, setAnimating] = useState(!shouldReduceMotion);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shown = useInView(ref);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y, clipPath: "inset(100% 0 0 0)" }
|
||||
<div
|
||||
ref={ref}
|
||||
className={`ds-reveal ${className}`.trim()}
|
||||
data-shown={shown ? "" : undefined}
|
||||
style={
|
||||
{
|
||||
"--reveal-y": `${y}px`,
|
||||
"--reveal-duration": `${duration}s`,
|
||||
"--reveal-delay": `${delay}s`,
|
||||
} as CSSProperties
|
||||
}
|
||||
whileInView={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, clipPath: "inset(0% 0 0 0)" }
|
||||
}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: {
|
||||
duration,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
style={{
|
||||
willChange:
|
||||
animating && !shouldReduceMotion
|
||||
? "transform, opacity, clip-path"
|
||||
: undefined,
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
if (!shouldReduceMotion) {
|
||||
setAnimating(true);
|
||||
}
|
||||
}}
|
||||
onAnimationComplete={() => setAnimating(false)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Section } from "./Section";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de Section (complemento de Reveal.test.tsx): cada parte opcional do cabeçalho
|
||||
* (eyebrow, descrição, aside) aparece só quando é dada, a casca clara é a padrão e o id é opcional.
|
||||
*/
|
||||
|
||||
describe("Section", () => {
|
||||
it("path: only the title and the body -> no eyebrow, no description, no aside", () => {
|
||||
const { container } = render(
|
||||
<Section title="Quem faz">
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Quem faz" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
||||
expect(container.querySelector(".ds-eyebrow")).toBeNull();
|
||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
// a linha que se desenha embaixo do título é sempre desenhada
|
||||
expect(container.querySelector(".ds-draw-line")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: light skin by default -> no dark class, and no id attribute without one", () => {
|
||||
const { container } = render(<Section title="Titulo">x</Section>);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
expect(section).toHaveClass("ds-section");
|
||||
expect(section).not.toHaveClass("ds-section-dark");
|
||||
expect(section).not.toHaveAttribute("id");
|
||||
});
|
||||
|
||||
it("path: only the eyebrow -> the eyebrow shows, the description and the aside stay out", () => {
|
||||
const { container } = render(
|
||||
<Section title="Titulo" eyebrow="Sobre">
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow");
|
||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: only the description -> the description shows, the eyebrow and the aside stay out", () => {
|
||||
const { container } = render(
|
||||
<Section title="Titulo" description="Texto de apoio">
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Texto de apoio")).toHaveClass(
|
||||
"ds-section-description",
|
||||
);
|
||||
expect(container.querySelector(".ds-eyebrow")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: only the aside -> the aside shows next to the title, the eyebrow and the description stay out", () => {
|
||||
const { container } = render(
|
||||
<Section title="Titulo" aside={<span>lateral</span>}>
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("lateral").closest("aside")).toBeInTheDocument();
|
||||
expect(container.querySelector(".ds-eyebrow")).toBeNull();
|
||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: children animation disabled -> the body is a plain block, not inside a reveal", () => {
|
||||
const { container } = render(
|
||||
<Section title="Titulo" disableChildrenAnimation>
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
|
||||
const body = screen.getByText("Corpo").parentElement;
|
||||
expect(body?.parentElement).toHaveClass("ds-section-shell");
|
||||
expect(container.querySelectorAll(".ds-reveal")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+23
-45
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { type CSSProperties, type ReactNode, useRef } from "react";
|
||||
import { useInView } from "@/app/hooks/useInView";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
||||
import { Reveal } from "./Reveal";
|
||||
@@ -13,11 +12,15 @@ interface SectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
dark?: boolean;
|
||||
aside?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
aside?: ReactNode;
|
||||
children: ReactNode;
|
||||
disableChildrenAnimation?: boolean;
|
||||
}
|
||||
|
||||
/** Atraso (s) de cada item do cabeçalho: entram em escada, como a linha que se desenha logo depois. */
|
||||
const stagger = (seconds: number) =>
|
||||
({ "--reveal-delay": `${seconds}s` }) as CSSProperties;
|
||||
|
||||
export const Section = ({
|
||||
id,
|
||||
eyebrow,
|
||||
@@ -28,50 +31,23 @@ export const Section = ({
|
||||
children,
|
||||
disableChildrenAnimation = false,
|
||||
}: SectionProps) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const headerShown = useInView(headerRef);
|
||||
const baseClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.02,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25, ease: [0.16, 1, 0.3, 1] }
|
||||
: {
|
||||
type: "spring",
|
||||
stiffness: 60,
|
||||
damping: 20,
|
||||
mass: 0.85,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section id={id} className={baseClass}>
|
||||
<div className="ds-section-shell">
|
||||
<motion.div
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="ds-section-header"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
data-shown={headerShown ? "" : undefined}
|
||||
>
|
||||
<div className="ds-section-meta">
|
||||
{eyebrow && (
|
||||
<motion.p className="ds-eyebrow" variants={itemVariants}>
|
||||
<p className="ds-eyebrow ds-reveal" style={stagger(0.02)}>
|
||||
{eyebrow}
|
||||
</motion.p>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2 className="ds-section-title">
|
||||
@@ -79,21 +55,23 @@ export const Section = ({
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<motion.p
|
||||
className="ds-section-description"
|
||||
variants={itemVariants}
|
||||
<p
|
||||
className="ds-section-description ds-reveal"
|
||||
style={stagger(0.1)}
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DrawLine delay={0.16} />
|
||||
</div>
|
||||
|
||||
{aside && (
|
||||
<motion.aside variants={itemVariants}>{aside}</motion.aside>
|
||||
<aside className="ds-reveal" style={stagger(0.18)}>
|
||||
{aside}
|
||||
</aside>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{!disableChildrenAnimation ? (
|
||||
<Reveal y={40} duration={0.9} delay={0.12}>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { TopProgressBar } from "./animations/TopProgressBar";
|
||||
|
||||
interface SectionLoadingProps {
|
||||
message: string;
|
||||
dark?: boolean;
|
||||
skeletons?: number;
|
||||
}
|
||||
|
||||
export function SectionLoading({
|
||||
message,
|
||||
dark = false,
|
||||
skeletons = 2,
|
||||
}: Readonly<SectionLoadingProps>) {
|
||||
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||
const skeletonId = useId();
|
||||
const skeletonKeys = Array.from(
|
||||
{ length: skeletons },
|
||||
(_, index) => `${skeletonId}-${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={sectionClass}>
|
||||
<TopProgressBar />
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-loading">{message}</div>
|
||||
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
|
||||
{skeletonKeys.map((skeletonKey) => (
|
||||
<div key={skeletonKey} className="ds-card ds-skeleton-card">
|
||||
<div className="ds-skeleton ds-skeleton-kicker" />
|
||||
<div className="ds-skeleton ds-skeleton-title" />
|
||||
<div className="ds-skeleton ds-skeleton-line" />
|
||||
<div className="ds-skeleton ds-skeleton-line" />
|
||||
<div className="ds-skeleton ds-skeleton-line ds-skeleton-line-short" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface CounterValueProps {
|
||||
value: number;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
once?: boolean;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export function CounterValue({
|
||||
value,
|
||||
className = "",
|
||||
duration = 1200,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
once = true,
|
||||
amount = 0.45,
|
||||
}: Readonly<CounterValueProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const isInView = useInView(ref, { once, amount });
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let rafId = 0;
|
||||
|
||||
const frame = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
setDisplayValue(Math.round(value * eased));
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isInView, value, duration, shouldReduceMotion]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
{prefix}
|
||||
{displayValue}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { type CSSProperties, useRef } from "react";
|
||||
import { useInView } from "@/app/hooks/useInView";
|
||||
|
||||
interface DrawLineProps {
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/** Linha fina que se desenha da esquerda para a direita quando chega à janela (CSS em globals.css, .ds-draw-line). */
|
||||
export function DrawLine({
|
||||
className = "",
|
||||
delay = 0,
|
||||
}: Readonly<DrawLineProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [animating, setAnimating] = useState(!shouldReduceMotion);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const shown = useInView(ref, { amount: 0.7 });
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
<span
|
||||
ref={ref}
|
||||
className={`ds-draw-line ${className}`.trim()}
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scaleX: 0 }}
|
||||
whileInView={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scaleX: 1 }
|
||||
}
|
||||
viewport={{ once: true, amount: 0.7 }}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.25 : 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
style={{
|
||||
transformOrigin: "left",
|
||||
willChange:
|
||||
animating && !shouldReduceMotion ? "transform, opacity" : undefined,
|
||||
}}
|
||||
onAnimationComplete={() => setAnimating(false)}
|
||||
data-shown={shown ? "" : undefined}
|
||||
style={{ "--reveal-delay": `${delay}s` } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { LazyRevealImage } from "./LazyRevealImage";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de LazyRevealImage (complemento de Reveal.test.tsx): a imagem que ainda carrega, o desenho de baixa resolução só para
|
||||
* imagem importada e os valores padrão de `sizes` e da classe da imagem.
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockComplete(complete: boolean, naturalWidth: number) {
|
||||
jest
|
||||
.spyOn(HTMLImageElement.prototype, "complete", "get")
|
||||
.mockReturnValue(complete);
|
||||
jest
|
||||
.spyOn(HTMLImageElement.prototype, "naturalWidth", "get")
|
||||
.mockReturnValue(naturalWidth);
|
||||
}
|
||||
|
||||
async function renderImage(ui: React.ReactElement) {
|
||||
let view: ReturnType<typeof render> | undefined;
|
||||
await act(async () => {
|
||||
view = render(ui);
|
||||
});
|
||||
if (!view) throw new Error("render failed");
|
||||
return view;
|
||||
}
|
||||
|
||||
describe("LazyRevealImage", () => {
|
||||
it("path: not complete yet -> waits for the load event, then marks it", async () => {
|
||||
mockComplete(false, 0);
|
||||
|
||||
const { container } = await renderImage(
|
||||
<LazyRevealImage src="/foto.webp" alt="Foto" />,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
expect(wrapper).not.toHaveAttribute("data-loaded");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByAltText("Foto").dispatchEvent(new Event("load"));
|
||||
});
|
||||
|
||||
expect(wrapper).toHaveAttribute("data-loaded");
|
||||
});
|
||||
|
||||
it("path: defaults -> lazy loading, the half-width size hint and the cover class on the image", async () => {
|
||||
const { container } = await renderImage(
|
||||
<LazyRevealImage src="/foto.webp" alt="Foto" />,
|
||||
);
|
||||
|
||||
const image = screen.getByAltText("Foto");
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
expect(image).toHaveAttribute("sizes", "(max-width: 1024px) 100vw, 50vw");
|
||||
expect(image).toHaveClass("object-cover");
|
||||
expect(container.firstElementChild?.className).toBe(
|
||||
"ds-lazy-reveal relative",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: custom sizes and image class -> used instead of the defaults", async () => {
|
||||
await renderImage(
|
||||
<LazyRevealImage
|
||||
src="/foto.webp"
|
||||
alt="Foto"
|
||||
sizes="320px"
|
||||
imageClassName="object-contain"
|
||||
className="h-40"
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = screen.getByAltText("Foto");
|
||||
expect(image).toHaveAttribute("sizes", "320px");
|
||||
expect(image).toHaveClass("object-contain");
|
||||
expect(image).not.toHaveClass("object-cover");
|
||||
});
|
||||
|
||||
it("path: an imported image (with a blur) -> the low-resolution preview style is on the image until it loads", async () => {
|
||||
mockComplete(false, 0);
|
||||
await renderImage(
|
||||
<LazyRevealImage
|
||||
src={{
|
||||
src: "/importada.png",
|
||||
width: 40,
|
||||
height: 20,
|
||||
blurDataURL: "data:image/png;base64,AAAA",
|
||||
}}
|
||||
alt="Importada"
|
||||
/>,
|
||||
);
|
||||
|
||||
// o jsdom descarta o `background-image` do SVG que o next/image monta, mas o restante do estilo do desfoque fica
|
||||
expect(screen.getByAltText("Importada").getAttribute("style")).toContain(
|
||||
"background-size: cover",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: a path string -> no preview style on the image", async () => {
|
||||
mockComplete(false, 0);
|
||||
await renderImage(<LazyRevealImage src="/foto.webp" alt="Foto" />);
|
||||
|
||||
expect(
|
||||
screen.getByAltText("Foto").getAttribute("style") ?? "",
|
||||
).not.toContain("background-size");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Image, { type StaticImageData } from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface LazyRevealImageProps {
|
||||
src: string | StaticImageData;
|
||||
@@ -13,6 +11,7 @@ interface LazyRevealImageProps {
|
||||
sizes?: string;
|
||||
}
|
||||
|
||||
/** Imagem que entra com um fade curto quando termina de carregar (CSS em globals.css, .ds-lazy-reveal). */
|
||||
export function LazyRevealImage({
|
||||
src,
|
||||
alt,
|
||||
@@ -20,31 +19,20 @@ export function LazyRevealImage({
|
||||
imageClassName = "object-cover",
|
||||
sizes = "(max-width: 1024px) 100vw, 50vw",
|
||||
}: Readonly<LazyRevealImageProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// uma imagem que já estava no cache termina antes de a página hidratar: o onLoad não vem mais
|
||||
useEffect(() => {
|
||||
const image = ref.current?.querySelector("img");
|
||||
if (image?.complete && image.naturalWidth > 0) setLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`relative ${className}`.trim()}
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
|
||||
}
|
||||
animate={
|
||||
loaded
|
||||
? shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, scale: 1 }
|
||||
: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, scale: 1.02 }
|
||||
}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.25 : 0.75,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
style={{
|
||||
willChange: !shouldReduceMotion ? "transform, opacity" : undefined,
|
||||
}}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`ds-lazy-reveal relative ${className}`.trim()}
|
||||
data-loaded={loaded ? "" : undefined}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
@@ -56,6 +44,6 @@ export function LazyRevealImage({
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={imageClassName}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { TextDiffuseReveal } from "./TextDiffuseReveal";
|
||||
|
||||
let mockInView = false;
|
||||
jest.mock("motion/react", () => ({ useInView: () => mockInView }));
|
||||
jest.mock("@/app/hooks/useInView", () => ({ useInView: () => mockInView }));
|
||||
|
||||
describe("TextDiffuseReveal", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useInView } from "motion/react";
|
||||
import { type CSSProperties, useRef } from "react";
|
||||
import { useInView } from "@/app/hooks/useInView";
|
||||
|
||||
interface TextDiffuseRevealProps {
|
||||
text: string;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface TopProgressBarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TopProgressBar({
|
||||
className = "",
|
||||
}: Readonly<TopProgressBarProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none fixed inset-x-0 top-0 z-[120] h-[2px] overflow-hidden bg-white/10 ${className}`.trim()}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full w-1/3 bg-[var(--slash)] shadow-[0_0_12px_rgba(204,255,0,0.85)]"
|
||||
initial={shouldReduceMotion ? { opacity: 1, x: "0%" } : { x: "-120%" }}
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1, x: "0%" }
|
||||
: { x: ["-120%", "15%", "45%", "110%"] }
|
||||
}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.5,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatDelay: 0.1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function PointCloudScene() {
|
||||
return <div data-testid="point-cloud-mock"></div>;
|
||||
}
|
||||
@@ -1,6 +1,68 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
|
||||
/* IntersectionObserver controlado: o teste decide quando cada tela de projeto entra ou sai da janela. */
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
observed: Set<Element>;
|
||||
disconnect: jest.Mock;
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
const original = global.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: Observer;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = { callback, observed: new Set(), disconnect: jest.fn() };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.observed.add(target);
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.record.observed.delete(target);
|
||||
}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = original;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function addScreen(className: string) {
|
||||
const element = document.createElement("div");
|
||||
element.className = className;
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/** O navegador avisa que `target` entrou (ou saiu) da janela; `height` 0 é uma tela escondida por CSS. */
|
||||
function intersect(target: Element, isIntersecting: boolean, height = 600) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
if (observer.observed.has(target))
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
isIntersecting,
|
||||
boundingClientRect: { height },
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("static cinematic effects", () => {
|
||||
it("renders grain without a canvas", () => {
|
||||
const grain = render(<FilmGrain />);
|
||||
@@ -13,62 +75,36 @@ describe("static cinematic effects", () => {
|
||||
});
|
||||
|
||||
describe("film grain over the project screens", () => {
|
||||
let screenTop = 5000;
|
||||
|
||||
function addScreen(className: string) {
|
||||
const element = document.createElement("div");
|
||||
element.className = className;
|
||||
element.getBoundingClientRect = () =>
|
||||
({ top: screenTop, bottom: screenTop + 600, height: 600 }) as DOMRect;
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function scroll(to: number) {
|
||||
screenTop = to;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
screenTop = 5000;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("steps aside while a project screen is in view and comes back when it leaves", () => {
|
||||
addScreen("ds-reel");
|
||||
const reel = addScreen("ds-reel");
|
||||
const { container } = render(<FilmGrain />);
|
||||
const grain = container.firstElementChild as HTMLElement;
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
|
||||
scroll(300); // a vitrine entrou na janela
|
||||
intersect(reel, true); // a vitrine entrou na janela
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
scroll(-900); // passou: a cena acabou 300 px acima do topo
|
||||
intersect(reel, false); // passou
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("also covers the browser and phone frames", () => {
|
||||
addScreen("ds-bframe");
|
||||
const { container } = render(<FilmGrain />);
|
||||
scroll(100);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-over-projects");
|
||||
it("also covers the browser and phone frames and the card screens", () => {
|
||||
for (const className of ["ds-bframe", "ds-phone", "ds-frame"]) {
|
||||
const screen = addScreen(className);
|
||||
const { container, unmount } = render(<FilmGrain />);
|
||||
|
||||
intersect(screen, true);
|
||||
|
||||
expect(container.firstElementChild).toHaveAttribute("data-over-projects");
|
||||
unmount();
|
||||
document.body.innerHTML = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("stays on when there is no project screen on the page", () => {
|
||||
const { container } = render(<FilmGrain />);
|
||||
scroll(100);
|
||||
|
||||
expect(observers[0].observed.size).toBe(0);
|
||||
expect(container.firstElementChild).not.toHaveAttribute(
|
||||
"data-over-projects",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import CinematicOverlays from "./CinematicOverlays";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CinematicOverlays (complemento de CinematicOverlays.test.tsx): a troca de página
|
||||
* com a página montada (de fora da home para a home e de volta) e as duas condições do grão juntas.
|
||||
*/
|
||||
|
||||
let mockPathname = "/";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
const mockLoaded = jest.fn();
|
||||
jest.mock("./FilmGrain", () => {
|
||||
mockLoaded();
|
||||
return {
|
||||
__esModule: true,
|
||||
default: function MockGrain() {
|
||||
return <div data-testid="grain" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** O import dinâmico do grão resolve em microtarefas. */
|
||||
const loaded = () =>
|
||||
act(async () => {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
const original = window.matchMedia;
|
||||
|
||||
function mockMedia(active: string[] = []) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/";
|
||||
mockLoaded.mockClear();
|
||||
mockMedia();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
describe("CinematicOverlays: troca de página", () => {
|
||||
it("path: arriving on the home from another page with a mouse -> the grain and the progress bar appear", async () => {
|
||||
mockPathname = "/cases";
|
||||
const { container, rerender } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
mockPathname = "/";
|
||||
rerender(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: leaving the home after the grain loaded -> everything goes away, and comes back on return", async () => {
|
||||
const { container, rerender } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
|
||||
mockPathname = "/servicos";
|
||||
rerender(<CinematicOverlays />);
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
mockPathname = "/";
|
||||
rerender(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: a sub-route that merely starts like the home path -> not the home", async () => {
|
||||
mockPathname = "/cases/sev-modaintima";
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CinematicOverlays: condições do grão juntas", () => {
|
||||
it("path: touch screen and reduced motion together -> no grain, the progress bar stays", async () => {
|
||||
mockMedia(["(pointer: coarse)", "(prefers-reduced-motion: reduce)"]);
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: no matchMedia in the browser -> counts as mouse with motion allowed: the grain loads", async () => {
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import CinematicOverlays from "./CinematicOverlays";
|
||||
|
||||
let mockPathname = "/";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
// o grão vem por import dinâmico (pacote próprio): aqui ele vira um marcador, e o contador diz se o pacote foi pedido
|
||||
const mockLoaded = jest.fn();
|
||||
jest.mock("./FilmGrain", () => {
|
||||
mockLoaded();
|
||||
return {
|
||||
__esModule: true,
|
||||
default: function MockGrain() {
|
||||
return <div data-testid="grain" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/** O import dinâmico do grão resolve em microtarefas. */
|
||||
const loaded = () =>
|
||||
act(async () => {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
const original = window.matchMedia;
|
||||
|
||||
function mockMedia(active: string[] = []) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/";
|
||||
mockMedia();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
/*
|
||||
* Os três primeiros testes vêm antes de qualquer um que carregue o grão: o módulo, depois de pedido uma vez, fica
|
||||
* no registro do Jest, então "o pacote nunca foi pedido" só vale enquanto ninguém o pediu.
|
||||
*/
|
||||
describe("CinematicOverlays: where the grain package is never requested", () => {
|
||||
it("does not request the grain on a touch screen (nothing invisible left measuring the page); the progress bar stays", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(mockLoaded).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
// a barra de progresso é CSS puro: continua
|
||||
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not request the grain for users who prefer reduced motion", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(mockLoaded).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows nothing, and requests nothing, on the other pages", async () => {
|
||||
mockPathname = "/cases";
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
expect(mockLoaded).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CinematicOverlays: with a mouse", () => {
|
||||
it("shows the scroll progress at once and the film grain as soon as its package arrives", async () => {
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
|
||||
// a barra vem no HTML; o grão só depois do import
|
||||
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
expect(mockLoaded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mount the grain if the page unmounts while its package loads", async () => {
|
||||
const { unmount } = render(<CinematicOverlays />);
|
||||
unmount();
|
||||
|
||||
await loaded();
|
||||
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type ComponentType, useEffect, useState } from "react";
|
||||
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||
import ScrollProgress from "./ScrollProgress";
|
||||
|
||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Efeitos por cima da home: a barra de progresso (CSS puro) e o grão de filme. O grão só existe com mouse e sem
|
||||
* movimento reduzido, e é importado só nesse caso: no toque o pacote dele nem é baixado, e nada fica montado,
|
||||
* invisível, medindo a tela.
|
||||
*/
|
||||
export default function CinematicOverlays() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === "/";
|
||||
const isHome = usePathname() === "/";
|
||||
const [Grain, setGrain] = useState<ComponentType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHome || isCoarsePointer() || prefersReducedMotion()) return;
|
||||
let alive = true;
|
||||
import("./FilmGrain").then((module) => {
|
||||
if (alive) setGrain(() => module.default);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [isHome]);
|
||||
|
||||
if (!isHome) return null;
|
||||
return (
|
||||
<>
|
||||
{isHome ? (
|
||||
<>
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
</>
|
||||
) : null}
|
||||
{Grain && <Grain />}
|
||||
<ScrollProgress />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,88 +2,134 @@ import { act, render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo
|
||||
* de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros
|
||||
* agrupados e a limpeza ao desmontar.
|
||||
* Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): a medição é do
|
||||
* IntersectionObserver (nada ouve a rolagem nem mede a página a cada quadro), uma tela escondida por CSS não
|
||||
* conta, várias telas, telas que a página cria ou remove depois e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
const INNER_HEIGHT = 800;
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
observed: Set<Element>;
|
||||
/** quantas vezes observe() foi chamado (um conjunto esconderia repetições) */
|
||||
calls: number;
|
||||
disconnect: jest.Mock;
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
const original = global.IntersectionObserver;
|
||||
|
||||
function addScreen(
|
||||
className: string,
|
||||
rect: { top: number; bottom: number; height: number },
|
||||
) {
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: Observer;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = {
|
||||
callback,
|
||||
observed: new Set(),
|
||||
calls: 0,
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.observed.add(target);
|
||||
this.record.calls += 1;
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.record.observed.delete(target);
|
||||
}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = original;
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function addScreen(className: string) {
|
||||
const element = document.createElement("div");
|
||||
element.className = className;
|
||||
element.getBoundingClientRect = () => rect as DOMRect;
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: INNER_HEIGHT,
|
||||
function intersect(target: Element, isIntersecting: boolean, height = 600) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
if (observer.observed.has(target))
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
isIntersecting,
|
||||
boundingClientRect: { height },
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
function renderGrain() {
|
||||
const view = render(<FilmGrain />);
|
||||
return { ...view, grain: view.container.firstElementChild as HTMLElement };
|
||||
}
|
||||
|
||||
/** Deixa o MutationObserver (microtarefa) avisar. */
|
||||
const flush = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
describe("FilmGrain: is a project screen in view?", () => {
|
||||
it("path: a visible screen -> the grain steps aside", () => {
|
||||
addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
|
||||
|
||||
const screen = addScreen("ds-phone");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(screen, true);
|
||||
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }],
|
||||
["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }],
|
||||
["ended above the window", { top: -900, bottom: -300, height: 600 }],
|
||||
[
|
||||
"starts exactly at the bottom edge",
|
||||
{ top: INNER_HEIGHT, bottom: 1400, height: 600 },
|
||||
],
|
||||
["starts below the window", { top: 2000, bottom: 2600, height: 600 }],
|
||||
])("path: a screen that %s -> does not count", (_label, rect) => {
|
||||
addScreen("ds-bframe", rect);
|
||||
|
||||
it("path: a screen with no height (hidden by CSS) -> does not count even if the browser says it intersects", () => {
|
||||
const screen = addScreen("ds-bframe");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(screen, true, 0);
|
||||
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: just inside the window by one pixel at either edge -> counts", () => {
|
||||
addScreen("ds-frame", { top: -599, bottom: 1, height: 600 });
|
||||
const top = renderGrain();
|
||||
expect(top.grain).toHaveAttribute("data-over-projects");
|
||||
top.unmount();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 });
|
||||
const bottom = renderGrain();
|
||||
expect(bottom.grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: one screen off the window and another in view -> counts (any is enough)", () => {
|
||||
addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 });
|
||||
addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
|
||||
|
||||
it("path: a screen that is off the window -> does not count", () => {
|
||||
const screen = addScreen("ds-frame");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(screen, false);
|
||||
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: one screen off the window and another in view -> counts (any is enough), and leaves only when the last one leaves", () => {
|
||||
const far = addScreen("ds-reel");
|
||||
const near = addScreen("ds-phone");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(far, false);
|
||||
intersect(near, true);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
intersect(far, true);
|
||||
intersect(near, false);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
intersect(far, false);
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: no project screen on the page -> the grain stays", () => {
|
||||
@@ -93,47 +139,83 @@ describe("FilmGrain: is a project screen in view?", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilmGrain: frames and cleanup", () => {
|
||||
it("path: many scroll events inside one frame -> the page is measured once", () => {
|
||||
const screenEl = addScreen("ds-reel", {
|
||||
top: 5000,
|
||||
bottom: 5600,
|
||||
height: 600,
|
||||
});
|
||||
const measure = jest.spyOn(screenEl, "getBoundingClientRect");
|
||||
renderGrain();
|
||||
measure.mockClear();
|
||||
describe("FilmGrain: screens that appear and disappear", () => {
|
||||
it("path: a screen the page creates after the grain mounted (the pinned reel swapping the list) is observed too", async () => {
|
||||
const { grain } = renderGrain();
|
||||
expect(observers[0].observed.size).toBe(0);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
const reel = addScreen("ds-reel");
|
||||
await flush();
|
||||
expect(observers[0].observed.has(reel)).toBe(true);
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
intersect(reel, true);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: unmount -> listeners removed and no measurement afterwards", () => {
|
||||
const screenEl = addScreen("ds-reel", {
|
||||
top: 5000,
|
||||
bottom: 5600,
|
||||
height: 600,
|
||||
});
|
||||
const measure = jest.spyOn(screenEl, "getBoundingClientRect");
|
||||
const { unmount } = renderGrain();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
it("path: a screen that is removed while in view stops counting", async () => {
|
||||
const reel = addScreen("ds-reel");
|
||||
const { grain } = renderGrain();
|
||||
intersect(reel, true);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
reel.remove();
|
||||
await flush();
|
||||
|
||||
expect(observers[0].observed.has(reel)).toBe(false);
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: the same screen is never observed twice", async () => {
|
||||
addScreen("ds-reel");
|
||||
renderGrain();
|
||||
expect(observers[0].calls).toBe(1);
|
||||
|
||||
addScreen("ds-phone");
|
||||
await flush();
|
||||
|
||||
// só a tela nova foi pedida ao observador; a primeira não se repete
|
||||
expect(observers[0].calls).toBe(2);
|
||||
expect(observers[0].observed.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilmGrain: no work per frame, cleanup", () => {
|
||||
it("path: nothing listens to the scroll or the window size, and the page is never measured", () => {
|
||||
const screen = addScreen("ds-reel");
|
||||
const measure = jest.spyOn(screen, "getBoundingClientRect");
|
||||
const listen = jest.spyOn(window, "addEventListener");
|
||||
renderGrain();
|
||||
|
||||
unmount();
|
||||
measure.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(measure).not.toHaveBeenCalled();
|
||||
const types = listen.mock.calls.map(([type]) => type);
|
||||
expect(types).not.toContain("scroll");
|
||||
expect(types).not.toContain("resize");
|
||||
});
|
||||
|
||||
it("path: unmount -> the observers are disconnected and later screens are ignored", async () => {
|
||||
const { unmount } = renderGrain();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
addScreen("ds-reel");
|
||||
await flush();
|
||||
expect(observers[0].observed.size).toBe(0);
|
||||
});
|
||||
|
||||
it("path: a browser without IntersectionObserver -> the grain renders and simply stays", () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
|
||||
(global as any).IntersectionObserver = undefined;
|
||||
addScreen("ds-reel");
|
||||
|
||||
const { grain } = renderGrain();
|
||||
|
||||
expect(grain).toHaveClass("film-grain");
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,48 @@ import { useEffect, useRef } from "react";
|
||||
/** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */
|
||||
const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone";
|
||||
|
||||
/**
|
||||
* Avisa `onChange(true)` enquanto qualquer tela de projeto está à vista. Usa IntersectionObserver (nada mede a
|
||||
* página a cada quadro de rolagem) e um MutationObserver só para pegar as telas que a página cria ou remove
|
||||
* depois (a vitrine fixa troca a lista ao hidratar). Devolve a função que desfaz tudo.
|
||||
*/
|
||||
function watchProjectScreens(onChange: (over: boolean) => void) {
|
||||
if (typeof IntersectionObserver === "undefined") return () => {};
|
||||
const watched = new Set<Element>();
|
||||
const inView = new Set<Element>();
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && entry.boundingClientRect.height > 0)
|
||||
inView.add(entry.target);
|
||||
else inView.delete(entry.target);
|
||||
}
|
||||
onChange(inView.size > 0);
|
||||
});
|
||||
|
||||
const scan = () => {
|
||||
for (const screen of document.querySelectorAll(PROJECT_SCREENS)) {
|
||||
if (watched.has(screen)) continue;
|
||||
watched.add(screen);
|
||||
observer.observe(screen);
|
||||
}
|
||||
for (const screen of watched) {
|
||||
if (screen.isConnected) continue;
|
||||
observer.unobserve(screen);
|
||||
watched.delete(screen);
|
||||
inView.delete(screen);
|
||||
}
|
||||
onChange(inView.size > 0);
|
||||
};
|
||||
scan();
|
||||
const mutations = new MutationObserver(scan);
|
||||
mutations.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mutations.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */
|
||||
export default function FilmGrain() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -12,31 +54,10 @@ export default function FilmGrain() {
|
||||
useEffect(() => {
|
||||
const grain = ref.current;
|
||||
if (!grain) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const over = Array.from(document.querySelectorAll(PROJECT_SCREENS)).some(
|
||||
(screen) => {
|
||||
const rect = screen.getBoundingClientRect();
|
||||
return (
|
||||
rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight
|
||||
);
|
||||
},
|
||||
);
|
||||
return watchProjectScreens((over) => {
|
||||
if (over) grain.dataset.overProjects = "";
|
||||
else delete grain.dataset.overProjects;
|
||||
};
|
||||
const queue = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", queue, { passive: true });
|
||||
window.addEventListener("resize", queue);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("scroll", queue);
|
||||
window.removeEventListener("resize", queue);
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} aria-hidden="true" className="film-grain" />;
|
||||
|
||||
@@ -17,34 +17,110 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
|
||||
describe("FramePulse", () => {
|
||||
it("asks for frames at about 30 per second while active, not 60", () => {
|
||||
render(<FramePulse active />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
advance(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);
|
||||
});
|
||||
advance(1000);
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<FramePulse active />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
advance(200);
|
||||
const running = mockInvalidate.mock.calls.length;
|
||||
expect(running).toBeGreaterThan(0);
|
||||
|
||||
rerender(<FramePulse active={false} />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
advance(1000);
|
||||
expect(mockInvalidate.mock.calls.length).toBe(running);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FramePulse: stops when nobody is looking", () => {
|
||||
it("stops drawing, and stops asking for animation frames, after 5 s without a cursor or scroll", () => {
|
||||
render(<FramePulse active />);
|
||||
advance(4000);
|
||||
const drawn = mockInvalidate.mock.calls.length;
|
||||
expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo
|
||||
|
||||
advance(1500); // passou dos 5 s
|
||||
const stopped = mockInvalidate.mock.calls.length;
|
||||
// parou perto dos 5 s: no máximo 5 s de batidas a ~30 por segundo
|
||||
expect(stopped).toBeLessThanOrEqual(5 * 32);
|
||||
|
||||
advance(3000);
|
||||
expect(mockInvalidate.mock.calls.length).toBe(stopped);
|
||||
// sem laço nenhum: nenhum quadro de animação pendente
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])(
|
||||
"wakes up again with %s and keeps drawing",
|
||||
(type) => {
|
||||
render(<FramePulse active />);
|
||||
advance(7000);
|
||||
const stopped = mockInvalidate.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(type));
|
||||
});
|
||||
advance(500);
|
||||
|
||||
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(stopped + 8);
|
||||
},
|
||||
);
|
||||
|
||||
it("each gesture buys another 5 s: it does not stop while there is movement", () => {
|
||||
render(<FramePulse active />);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
advance(3000);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
}
|
||||
const before = mockInvalidate.mock.calls.length;
|
||||
|
||||
advance(1000);
|
||||
|
||||
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(before + 20);
|
||||
});
|
||||
|
||||
it("lets the idle time be configured", () => {
|
||||
render(<FramePulse active idleMs={1000} />);
|
||||
advance(1500);
|
||||
const stopped = mockInvalidate.mock.calls.length;
|
||||
|
||||
advance(2000);
|
||||
|
||||
expect(mockInvalidate.mock.calls.length).toBe(stopped);
|
||||
});
|
||||
|
||||
it("removes its listeners when it unmounts or goes inactive", () => {
|
||||
const { rerender, unmount } = render(<FramePulse active />);
|
||||
rerender(<FramePulse active={false} />);
|
||||
mockInvalidate.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
advance(500);
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
advance(500);
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,31 @@
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** Gestos que mostram alguém olhando para a página: qualquer um deles religa a batida. */
|
||||
const ACTIVITY = [
|
||||
"pointermove",
|
||||
"pointerdown",
|
||||
"scroll",
|
||||
"wheel",
|
||||
"touchmove",
|
||||
"keydown",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum quadro, nenhum
|
||||
* requestAnimationFrame) e o último quadro fica na tela; qualquer movimento a religa.
|
||||
* Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame.
|
||||
*/
|
||||
export default function FramePulse({
|
||||
active,
|
||||
fps = 30,
|
||||
idleMs = 5000,
|
||||
}: {
|
||||
active: boolean;
|
||||
fps?: number;
|
||||
idleMs?: number;
|
||||
}) {
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
@@ -22,15 +36,30 @@ export default function FramePulse({
|
||||
const gap = 1000 / fps - 2;
|
||||
let id = 0;
|
||||
let last = 0;
|
||||
let lastActivity = performance.now();
|
||||
const beat = (now: number) => {
|
||||
// parada por inatividade: sem laço até o próximo movimento
|
||||
if (now - lastActivity > idleMs) {
|
||||
id = 0;
|
||||
return;
|
||||
}
|
||||
id = requestAnimationFrame(beat);
|
||||
if (now - last < gap) return;
|
||||
last = now;
|
||||
invalidate();
|
||||
};
|
||||
const wake = () => {
|
||||
lastActivity = performance.now();
|
||||
if (!id) id = requestAnimationFrame(beat);
|
||||
};
|
||||
for (const type of ACTIVITY)
|
||||
window.addEventListener(type, wake, { passive: true });
|
||||
id = requestAnimationFrame(beat);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [active, fps, invalidate]);
|
||||
return () => {
|
||||
for (const type of ACTIVITY) window.removeEventListener(type, wake);
|
||||
cancelAnimationFrame(id);
|
||||
};
|
||||
}, [active, fps, idleMs, invalidate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
|
||||
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
||||
|
||||
/**
|
||||
* Reusable 3D animated background for internal hero sections.
|
||||
* 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() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [capable, setCapable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCapable(!isLowPowerDevice());
|
||||
}, []);
|
||||
|
||||
if (shouldReduceMotion) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 3D particle scene */}
|
||||
{capable && <HeroScene />}
|
||||
|
||||
{/* 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%)]" />
|
||||
|
||||
{/* Gradient fade to bottom */}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||
|
||||
{/* Scanlines */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.012]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import HeroField from "./HeroField";
|
||||
import HeroField, { DIVE_QUERY } 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).
|
||||
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): como a espera pelo WebGL acaba
|
||||
* (primeira interação, requestIdleCallback com folga ou sem folga, temporizador), os cancelamentos, as falhas
|
||||
* (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a
|
||||
* visibilidade (observador, aba oculta, fim do mergulho) e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
type SceneProps = {
|
||||
@@ -14,16 +15,25 @@ type SceneProps = {
|
||||
onError: () => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
const mockPrepare = jest.fn();
|
||||
let mockImportFails = false;
|
||||
|
||||
jest.mock("next/dynamic", () => ({
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", { "data-testid": "scene" });
|
||||
}
|
||||
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
||||
get default() {
|
||||
if (mockImportFails) throw new Error("chunk indisponível");
|
||||
return MockScene;
|
||||
},
|
||||
get prepareField() {
|
||||
if (mockImportFails) throw new Error("chunk indisponível");
|
||||
return () => mockPrepare();
|
||||
},
|
||||
}));
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
@@ -36,7 +46,7 @@ const original = {
|
||||
cancelIdle: win.cancelIdleCallback,
|
||||
};
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
@@ -69,6 +79,8 @@ function Hero({ children }: { children?: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const section = () => document.querySelector(".hero") as HTMLElement;
|
||||
|
||||
function setHidden(value: boolean) {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value });
|
||||
}
|
||||
@@ -83,11 +95,53 @@ function reportIntersecting(value: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* requestIdleCallback de mentira: o pedido da espera pela página ociosa (timeout 4000) fica guardado para o teste
|
||||
* disparar com o prazo que quiser; os dos passos de carregamento (timeout 500) rodam logo.
|
||||
*/
|
||||
function mockIdleCallback() {
|
||||
const waiting = {
|
||||
callback: undefined as ((d: IdleDeadline) => void) | undefined,
|
||||
};
|
||||
let requests = 0;
|
||||
const request = jest.fn(
|
||||
(
|
||||
callback: (d: IdleDeadline) => void,
|
||||
options?: { timeout?: number },
|
||||
): number => {
|
||||
requests += 1;
|
||||
if (options?.timeout === 500)
|
||||
setTimeout(
|
||||
() => callback({ didTimeout: false, timeRemaining: () => 50 }),
|
||||
0,
|
||||
);
|
||||
else waiting.callback = callback;
|
||||
return requests;
|
||||
},
|
||||
);
|
||||
win.requestIdleCallback = request;
|
||||
win.cancelIdleCallback = jest.fn();
|
||||
return { waiting, request };
|
||||
}
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** Depois de a espera acabar, os passos seguintes (pacote, geometria, Canvas). */
|
||||
async function finishLoading() {
|
||||
await advance(60);
|
||||
await advance(60);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockScenes.length = 0;
|
||||
mockPrepare.mockClear();
|
||||
mockImportFails = false;
|
||||
observers = [];
|
||||
mockMedia([]);
|
||||
mockMedia();
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
@@ -127,90 +181,165 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HeroField: quando o WebGL monta", () => {
|
||||
it("path: no requestIdleCallback -> the scene mounts after 1.2 s of timer, not before", () => {
|
||||
it("path: no requestIdleCallback -> the scene starts loading after 1.2 s of timer, not before", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1199);
|
||||
});
|
||||
await advance(1199);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
await advance(1);
|
||||
await finishLoading();
|
||||
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;
|
||||
it.each([
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"keydown",
|
||||
"wheel",
|
||||
"touchstart",
|
||||
"scroll",
|
||||
])(
|
||||
"path: first %s -> the scene starts loading without waiting for the timer",
|
||||
async (type) => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(type));
|
||||
});
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("path: several gestures in a row -> the scene is loaded once", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
});
|
||||
win.requestIdleCallback = request;
|
||||
win.cancelIdleCallback = jest.fn();
|
||||
await finishLoading();
|
||||
await advance(3000);
|
||||
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(mockScenes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: requestIdleCallback with plenty of free time -> the scene starts loading right then, not on the timer", async () => {
|
||||
const { waiting, request } = mockIdleCallback();
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
await advance(1300);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(request).toHaveBeenCalledWith(expect.any(Function), {
|
||||
timeout: 2500,
|
||||
timeout: 4000,
|
||||
});
|
||||
|
||||
act(() => idle?.());
|
||||
act(() =>
|
||||
waiting.callback?.({ didTimeout: false, timeRemaining: () => 45 }),
|
||||
);
|
||||
await finishLoading();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
|
||||
it("path: an idle period with no free time -> waits for the next one; waiting too long -> goes ahead anyway", async () => {
|
||||
const { waiting, request } = mockIdleCallback();
|
||||
|
||||
render(<Hero />);
|
||||
const first = request.mock.calls.length;
|
||||
|
||||
act(() =>
|
||||
waiting.callback?.({ didTimeout: false, timeRemaining: () => 3 }),
|
||||
);
|
||||
await finishLoading();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(request.mock.calls.length).toBeGreaterThan(first); // pediu o próximo intervalo
|
||||
|
||||
act(() => waiting.callback?.({ didTimeout: true, timeRemaining: () => 0 }));
|
||||
await finishLoading();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: unmounted while waiting for the browser to get idle -> the request is cancelled and no scene mounts", async () => {
|
||||
const cancel = jest.fn();
|
||||
win.requestIdleCallback = jest.fn(() => 7);
|
||||
win.cancelIdleCallback = cancel;
|
||||
|
||||
const view = render(<Hero />);
|
||||
view.unmount();
|
||||
await advance(5000);
|
||||
|
||||
expect(cancel).toHaveBeenCalledWith(7);
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
|
||||
it("path: unmounted before the fallback timer fires -> nothing loads afterwards, and the gestures no longer matter", async () => {
|
||||
const view = render(<Hero />);
|
||||
view.unmount();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(5000);
|
||||
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
|
||||
it("path: unmounted in the middle of loading (after the package, before the geometry) -> nothing mounts", async () => {
|
||||
const view = render(<Hero />);
|
||||
await advance(1200);
|
||||
|
||||
view.unmount();
|
||||
await finishLoading();
|
||||
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background, no dive", async () => {
|
||||
setContext(() => {
|
||||
throw new Error("sem GPU");
|
||||
});
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background", () => {
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => {
|
||||
setContext(() => null);
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
|
||||
it("path: the three.js package fails to load (network) -> static background, no dive, no scene", async () => {
|
||||
mockImportFails = true;
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL2 lose_context) before the real scene mounts", async () => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => ({
|
||||
getExtension: (name: string) =>
|
||||
@@ -218,46 +347,48 @@ describe("HeroField: quando o WebGL monta", () => {
|
||||
}));
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", () => {
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: low power -> the WebGL probe and the timer are never started", () => {
|
||||
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
setContext(probe);
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
const types = addListener.mock.calls.map(([type]) => type);
|
||||
expect(types).not.toContain("pointermove");
|
||||
expect(types).not.toContain("scroll");
|
||||
expect(observers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: rolagem e visibilidade", () => {
|
||||
function section() {
|
||||
return document.querySelector(".hero") as HTMLElement;
|
||||
}
|
||||
|
||||
function place(top: number, height = 2000) {
|
||||
function place(top: number, height = 3000) {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top, height, bottom: top + height }) as DOMRect;
|
||||
}
|
||||
@@ -270,6 +401,11 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function loaded() {
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
@@ -277,15 +413,13 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
|
||||
it("path: no .hero ancestor -> no scroll variables and no observer, and nothing else starts either", async () => {
|
||||
render(<HeroField />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
|
||||
@@ -302,13 +436,14 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
|
||||
it("path: resize recomputes the progress like a scroll", () => {
|
||||
render(<Hero />);
|
||||
place(-600);
|
||||
place(-700);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
// trilho de 1400 px (3000 de hero - 2 telas de 800)
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
0.5,
|
||||
);
|
||||
@@ -322,25 +457,43 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: end of the dive then back up -> drawing stops, then resumes", () => {
|
||||
it("path: scroll far past the dive (Projects covering the stage) -> progress clamps at 1", () => {
|
||||
render(<Hero />);
|
||||
|
||||
scroll(-2200);
|
||||
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
});
|
||||
|
||||
it("path: a hero shorter than the minimum rail -> the rail is 80 % of the window, never zero", () => {
|
||||
render(<Hero />);
|
||||
place(-400, 800);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
400 / 640,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: end of the dive then back up -> drawing stops, then resumes", async () => {
|
||||
render(<Hero />);
|
||||
await loaded();
|
||||
reportIntersecting(true);
|
||||
|
||||
scroll(-1200);
|
||||
scroll(-1400);
|
||||
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", () => {
|
||||
it("path: the hero leaves the screen -> not visible; returns -> visible", async () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await loaded();
|
||||
|
||||
reportIntersecting(true);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
@@ -352,11 +505,9 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", () => {
|
||||
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", async () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await loaded();
|
||||
reportIntersecting(true);
|
||||
|
||||
setHidden(true);
|
||||
@@ -372,11 +523,9 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the tab shows but the hero is off screen -> still not visible", () => {
|
||||
it("path: the tab shows but the hero is off screen -> still not visible", async () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await loaded();
|
||||
reportIntersecting(false);
|
||||
|
||||
act(() => {
|
||||
@@ -386,11 +535,14 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled", () => {
|
||||
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled, hero back to plain", () => {
|
||||
const removeListener = jest.spyOn(window, "removeEventListener");
|
||||
const removeDocListener = jest.spyOn(document, "removeEventListener");
|
||||
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
|
||||
const view = render(<Hero />);
|
||||
const hero = section();
|
||||
scroll(-700);
|
||||
expect(hero).toHaveAttribute("data-dive");
|
||||
|
||||
view.unmount();
|
||||
|
||||
@@ -401,5 +553,21 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
);
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(cancelFrame).toHaveBeenCalled();
|
||||
expect(hero).not.toHaveAttribute("data-dive");
|
||||
expect(hero.style.getPropertyValue("--hero-p")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: navegador sem matchMedia", () => {
|
||||
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import HeroField from "./HeroField";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
type SceneProps = {
|
||||
visible: boolean;
|
||||
@@ -9,16 +9,17 @@ type SceneProps = {
|
||||
onError: () => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
const mockPrepare = jest.fn();
|
||||
|
||||
jest.mock("next/dynamic", () => ({
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
default: function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
prepareField: () => mockPrepare(),
|
||||
}));
|
||||
|
||||
const original = {
|
||||
@@ -27,7 +28,8 @@ const original = {
|
||||
getContext: HTMLCanvasElement.prototype.getContext,
|
||||
};
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
/** Consultas de mídia que valem; o mergulho (DIVE_QUERY) vale por padrão: tela alta, sem movimento reduzido. */
|
||||
function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
@@ -49,17 +51,37 @@ function Hero() {
|
||||
);
|
||||
}
|
||||
|
||||
/** O navegador ocioso: o Three.js só entra depois do tempo de espera. */
|
||||
function idle() {
|
||||
const section = () => document.querySelector(".hero") as HTMLElement;
|
||||
|
||||
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
||||
function scrollWritesProgress() {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top: -700, height: 3000, bottom: 2300 }) as DOMRect;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
return section().style.getPropertyValue("--hero-p") !== "";
|
||||
}
|
||||
|
||||
/** Avança o relógio e deixa as promessas (importação do pacote, passos ociosos) resolverem no caminho. */
|
||||
const advance = (ms: number) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** A página fica ociosa (sem requestIdleCallback: 1,2 s) e o Three.js entra, passo a passo. */
|
||||
async function idle() {
|
||||
await advance(1200); // a espera
|
||||
await advance(60); // pacote carregado, geometria no próximo intervalo ocioso
|
||||
await advance(60); // Canvas montado
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockScenes.length = 0;
|
||||
mockMedia([]);
|
||||
mockPrepare.mockClear();
|
||||
mockMedia();
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
@@ -81,18 +103,50 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HeroField (ambient background)", () => {
|
||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", () => {
|
||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
||||
render(<Hero />);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
idle();
|
||||
|
||||
await 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", () => {
|
||||
it("brings the geometry in before the Canvas, in a task of its own", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
await advance(1200);
|
||||
await advance(10); // o pacote carregou, mas o intervalo ocioso da geometria ainda não veio
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
await advance(60);
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
await advance(60);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not wait for the idle time once the visitor interacts", async () => {
|
||||
render(<Hero />);
|
||||
await advance(100);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(200);
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is decorative: hidden from assistive technology and with no labels over the 3D", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
await idle();
|
||||
const field = container.querySelector(".hero-field");
|
||||
expect(field).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelector(".hero-tag")).toBeNull();
|
||||
@@ -102,7 +156,7 @@ describe("HeroField (ambient background)", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
|
||||
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
|
||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||
@@ -115,26 +169,33 @@ describe("HeroField (ambient background)", () => {
|
||||
},
|
||||
],
|
||||
])(
|
||||
"keeps the light static background (no WebGL) with %s",
|
||||
(_label, arrange) => {
|
||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
||||
async (_label, arrange) => {
|
||||
arrange();
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
|
||||
await idle();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
// o hero é o palco de 100svh: sem marca de mergulho, sem variável de rolagem
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(scrollWritesProgress()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("follows the hero scroll with --hero-p and --hero-open, and stops drawing once the dive is over", () => {
|
||||
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
const section = container.querySelector(".hero") as HTMLElement;
|
||||
const hero = container.querySelector(".hero") as HTMLElement;
|
||||
expect(hero).toHaveAttribute("data-dive");
|
||||
await idle();
|
||||
// 3000 px de hero: 800 de palco, 800 da tela de Projetos subindo e 1400 de mergulho
|
||||
let top = 0;
|
||||
section.getBoundingClientRect = () =>
|
||||
({ top, height: 2000, bottom: top + 2000 }) as DOMRect;
|
||||
hero.getBoundingClientRect = () =>
|
||||
({ top, height: 3000, bottom: top + 3000 }) as DOMRect;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
@@ -150,30 +211,46 @@ describe("HeroField (ambient background)", () => {
|
||||
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);
|
||||
scroll(700); // meio do mergulho (1400 px de trilho)
|
||||
expect(Number(hero.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);
|
||||
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
|
||||
|
||||
scroll(1200);
|
||||
expect(Number(section.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
scroll(1400);
|
||||
expect(Number(hero.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", () => {
|
||||
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
|
||||
mockMedia([]);
|
||||
|
||||
render(<Hero />);
|
||||
await idle();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(scrollWritesProgress()).toBe(false);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals the scene (data-ready) once it has drawn its first frames, and goes back to the plain hero if it fails", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
await 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();
|
||||
// de volta ao palco de 100svh: sem marca de mergulho nem variáveis de rolagem
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ComponentType,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||
|
||||
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
||||
ssr: false,
|
||||
});
|
||||
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
||||
export const DIVE_QUERY =
|
||||
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
|
||||
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
|
||||
const IDLE_SLACK_MS = 20;
|
||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||
const IDLE_PATIENCE_MS = 4000;
|
||||
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
|
||||
const FIRST_ACTIVITY = [
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"keydown",
|
||||
"wheel",
|
||||
"touchstart",
|
||||
"scroll",
|
||||
] as const;
|
||||
|
||||
const smooth = (a: number, b: number, x: number) => {
|
||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||
@@ -25,83 +43,178 @@ function supportsWebGL2() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Um intervalo ocioso do navegador (sem requestIdleCallback, um instante depois). */
|
||||
function nextIdle() {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (typeof window.requestIdleCallback === "function")
|
||||
window.requestIdleCallback(() => resolve(), { timeout: 500 });
|
||||
else setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fundo ambiente do hero. Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
||||
* 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.
|
||||
* Chama `run` uma vez: na primeira interação do visitante ou quando o navegador fica ocioso com folga (o que
|
||||
* vier primeiro). Título, botões e hidratação vêm antes; devolve a função que cancela a espera.
|
||||
*/
|
||||
function whenIdle(run: () => void) {
|
||||
let idle: number | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stop = () => {
|
||||
for (const type of FIRST_ACTIVITY) window.removeEventListener(type, fire);
|
||||
if (idle !== undefined) window.cancelIdleCallback(idle);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
function fire() {
|
||||
stop();
|
||||
run();
|
||||
}
|
||||
for (const type of FIRST_ACTIVITY)
|
||||
window.addEventListener(type, fire, { passive: true });
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const wait = (deadline: IdleDeadline) => {
|
||||
if (deadline.didTimeout || deadline.timeRemaining() >= IDLE_SLACK_MS)
|
||||
fire();
|
||||
else
|
||||
idle = window.requestIdleCallback(wait, { timeout: IDLE_PATIENCE_MS });
|
||||
};
|
||||
idle = window.requestIdleCallback(wait, { timeout: IDLE_PATIENCE_MS });
|
||||
} else timer = setTimeout(fire, 1200);
|
||||
return stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
|
||||
* num intervalo ocioso) e só então o Canvas. Devolve `null` se o aparelho não faz WebGL2 ou se a tela foi
|
||||
* desmontada no meio do caminho.
|
||||
*/
|
||||
async function loadScene(isAlive: () => boolean) {
|
||||
if (!supportsWebGL2()) return null;
|
||||
const scene = await import("./HeroFieldScene");
|
||||
await nextIdle();
|
||||
if (!isAlive()) return null;
|
||||
scene.prepareField();
|
||||
await nextIdle();
|
||||
return isAlive() ? scene.default : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho) e --hero-open (0 em
|
||||
* repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e marca o hero com data-dive, que
|
||||
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
|
||||
*/
|
||||
function trackDive(
|
||||
section: HTMLElement,
|
||||
progress: { current: number },
|
||||
onDone: (done: boolean) => void,
|
||||
) {
|
||||
section.dataset.dive = "";
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = section.getBoundingClientRect();
|
||||
// o palco fica preso por (altura - 1 tela); a última tela dessa rolagem é de Projetos subindo sobre ele
|
||||
const span = Math.max(
|
||||
rect.height - window.innerHeight * 2,
|
||||
window.innerHeight * 0.8,
|
||||
);
|
||||
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 === progress.current) return;
|
||||
progress.current = p;
|
||||
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));
|
||||
onDone(p >= 0.999);
|
||||
};
|
||||
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);
|
||||
delete section.dataset.dive;
|
||||
section.style.removeProperty("--hero-p");
|
||||
section.style.removeProperty("--hero-open");
|
||||
};
|
||||
}
|
||||
|
||||
/** Avisa se o hero está à vista (e a aba visível): fora disso a cena não desenha nada. */
|
||||
function trackVisibility(
|
||||
section: HTMLElement,
|
||||
onChange: (visible: boolean) => void,
|
||||
) {
|
||||
let intersecting = false;
|
||||
const sync = () => onChange(intersecting && !document.hidden);
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersecting = entry.isIntersecting;
|
||||
sync();
|
||||
});
|
||||
observer.observe(section);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2, sem toque, com mais de
|
||||
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
|
||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
||||
* (trackDive). Sem legendas sobre o 3D: o espaço da direita é das molduras.
|
||||
*/
|
||||
export default function HeroField() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const [mount, setMount] = useState(false);
|
||||
const giveUp = useRef(() => {});
|
||||
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
|
||||
null,
|
||||
);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
|
||||
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLowPowerDevice()) return;
|
||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
||||
if (!section) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = section.getBoundingClientRect();
|
||||
const span = Math.max(
|
||||
rect.height - window.innerHeight,
|
||||
window.innerHeight * 0.8,
|
||||
);
|
||||
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;
|
||||
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);
|
||||
let alive = true;
|
||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
: undefined;
|
||||
const stopVisibility = trackVisibility(section, setVisible);
|
||||
// sem WebGL2, com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
|
||||
giveUp.current = () => {
|
||||
stopDive?.();
|
||||
if (alive) setFailed(true);
|
||||
};
|
||||
const queue = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", queue, { passive: true });
|
||||
window.addEventListener("resize", queue);
|
||||
|
||||
let intersecting = false;
|
||||
const sync = () => setVisible(intersecting && !document.hidden);
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersecting = entry.isIntersecting;
|
||||
sync();
|
||||
const stopWaiting = whenIdle(() => {
|
||||
loadScene(() => alive)
|
||||
.then((component) => {
|
||||
if (!alive) return;
|
||||
if (component) setScene(() => component);
|
||||
else giveUp.current();
|
||||
})
|
||||
.catch(() => giveUp.current());
|
||||
});
|
||||
observer.observe(section);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("scroll", queue);
|
||||
window.removeEventListener("resize", queue);
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
alive = false;
|
||||
stopWaiting();
|
||||
stopVisibility();
|
||||
stopDive?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLowPowerDevice()) return;
|
||||
if (!supportsWebGL2()) return;
|
||||
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const id = window.requestIdleCallback(() => setMount(true), {
|
||||
timeout: 2500,
|
||||
});
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const id = setTimeout(() => setMount(true), 1200);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const handleReady = useCallback(() => setReady(true), []);
|
||||
const handleError = useCallback(() => setFailed(true), []);
|
||||
const handleError = useCallback(() => giveUp.current(), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -110,7 +223,7 @@ export default function HeroField() {
|
||||
className="hero-field"
|
||||
data-ready={ready && !failed ? "" : undefined}
|
||||
>
|
||||
{mount && !failed && (
|
||||
{Scene && !failed && (
|
||||
<Scene
|
||||
progressRef={progressRef}
|
||||
visible={visible && !done}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
import HeroFieldScene, { prepareField } from "./HeroFieldScene";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
|
||||
@@ -23,6 +23,7 @@ const mockState: {
|
||||
throwOnUseThree: boolean;
|
||||
} = { frame: null, three: null, throwOnUseThree: false };
|
||||
const mockMaterials: THREE.ShaderMaterial[] = [];
|
||||
const mockCounts = { vectors: 0 };
|
||||
const mockGeometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
jest.mock("three", () => {
|
||||
@@ -39,10 +40,17 @@ jest.mock("three", () => {
|
||||
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
||||
}
|
||||
}
|
||||
class CountingVector3 extends actual.Vector3 {
|
||||
constructor(x?: number, y?: number, z?: number) {
|
||||
super(x, y, z);
|
||||
mockCounts.vectors += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
ShaderMaterial: CapturingMaterial,
|
||||
BufferGeometry: CapturingGeometry,
|
||||
Vector3: CountingVector3,
|
||||
};
|
||||
});
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
@@ -166,6 +174,7 @@ function pointerMove(type: string, x = 600, y = 400) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCounts.vectors = 0;
|
||||
mockMaterials.length = 0;
|
||||
mockGeometries.length = 0;
|
||||
mockState.frame = null;
|
||||
@@ -237,15 +246,19 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
expect(scene.uniforms().uIntro.value).toBe(1);
|
||||
});
|
||||
|
||||
it("path: the scene fades out only at the very end of the dive", () => {
|
||||
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
scene.settle(10);
|
||||
expect(scene.uniforms().uFade.value).toBe(1);
|
||||
|
||||
scene.progressRef.current = 0.9;
|
||||
scene.settle(120);
|
||||
expect(scene.uniforms().uFade.value).toBe(1);
|
||||
|
||||
scene.progressRef.current = 1;
|
||||
scene.settle(120);
|
||||
|
||||
expect(scene.uniforms().uFade.value).toBeCloseTo(0, 6);
|
||||
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 6);
|
||||
});
|
||||
|
||||
it("path: layers spread apart only while they face the viewer", () => {
|
||||
@@ -279,13 +292,17 @@ describe("Field: enquadramento e foco", () => {
|
||||
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", () => {
|
||||
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
const spread = scene.uniforms().uSpread.value;
|
||||
expect(scene.group.position.y).toBeCloseTo(0, 6);
|
||||
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
|
||||
expect(scene.camera.position.z).toBeLessThan(0);
|
||||
expect(scene.camera.position.z).toBeGreaterThan(-spread);
|
||||
expect(scene.camera.position.z + spread).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||
@@ -297,12 +314,30 @@ describe("Field: enquadramento e foco", () => {
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
||||
});
|
||||
|
||||
it("path: camera past every layer -> the focus never goes below 1.5", () => {
|
||||
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.uniforms().uFocus.value).toBe(1.5);
|
||||
const spread = scene.uniforms().uSpread.value;
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(
|
||||
scene.camera.position.z + spread,
|
||||
6,
|
||||
);
|
||||
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
|
||||
});
|
||||
|
||||
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
|
||||
const scene = setup({ progress: 0.55 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
const spread = scene.uniforms().uSpread.value;
|
||||
const z = scene.camera.position.z;
|
||||
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
|
||||
expect(z).toBeGreaterThan(0.2);
|
||||
expect(z).toBeLessThan(spread);
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -472,6 +507,43 @@ describe("Field: quando pedir o próximo quadro", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
||||
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
||||
prepareField();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
const prepared = mockGeometries[0];
|
||||
|
||||
prepareField(); // de novo, sem montar: nada é refeito
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
|
||||
setup();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
expect(mockGeometries[0]).toBe(prepared);
|
||||
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("path: the preparation is used once; a second scene builds its own points", () => {
|
||||
prepareField();
|
||||
setup();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
|
||||
setup();
|
||||
expect(mockGeometries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("path: no vector is created per frame, not even with the mouse driving the cursor over the layer", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
pointerMove("mouse");
|
||||
scene.frame(5.1, 1 / 60);
|
||||
const before = mockCounts.vectors;
|
||||
|
||||
for (let i = 0; i < 60; i++) scene.frame(5.2 + i / 60, 1 / 60);
|
||||
|
||||
expect(mockCounts.vectors).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
const pointCount = () => {
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
|
||||
@@ -25,12 +25,14 @@ export type HeroFieldSceneProps = {
|
||||
* 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.
|
||||
* 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 Hero explicam cada camada nessa hora).
|
||||
* frente e a câmera atravessa uma a uma, até parar diante dos dados (as legendas de Hero explicam cada
|
||||
* camada nessa hora; no fim, a seção seguinte sobe e cobre o palco).
|
||||
* 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.
|
||||
* e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
||||
* sem cursor nem rolagem (FramePulse), não desenha nada.
|
||||
*/
|
||||
|
||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||
@@ -371,7 +373,8 @@ const FRAME = 5.2;
|
||||
|
||||
/*
|
||||
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
|
||||
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
|
||||
* atravessa, enquadra os dados e fica ali (o mergulho não acaba no escuro: Projetos sobe sobre a última
|
||||
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) seguem
|
||||
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
|
||||
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
|
||||
* camadas se afastam mais para a câmera caber entre elas.
|
||||
@@ -387,11 +390,209 @@ function cameraFrames(rest: number, spread: number, frame: number) {
|
||||
[0.74, -0.3],
|
||||
[0.78, -spread + frame],
|
||||
[0.89, -spread + frame * 0.87],
|
||||
[0.95, -spread - 0.3],
|
||||
[1, -spread - 2],
|
||||
[1, -spread + frame * 0.8],
|
||||
] as const;
|
||||
}
|
||||
|
||||
/** Tela larga o bastante (e CPU suficiente) para a densidade cheia de pontos. */
|
||||
const isDense = () =>
|
||||
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
|
||||
|
||||
let prepared: THREE.BufferGeometry | null = null;
|
||||
|
||||
/**
|
||||
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
|
||||
* isto num intervalo ocioso, antes de montar o Canvas, para a criação do contexto WebGL não carregar também
|
||||
* essa conta. Chamar de novo, sem a cena ter montado, não refaz nada.
|
||||
*/
|
||||
export function prepareField() {
|
||||
prepared ??= buildField(isDense());
|
||||
}
|
||||
|
||||
/** A geometria pronta (que a montagem consome), ou uma nova se ninguém a preparou antes. */
|
||||
function takeField() {
|
||||
const geometry = prepared ?? buildField(isDense());
|
||||
prepared = null;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
type Uniforms = THREE.ShaderMaterial["uniforms"];
|
||||
|
||||
/** Enquadramento da câmera para o tamanho atual da tela: só muda quando a tela muda, não a cada quadro. */
|
||||
function frameLayout(
|
||||
width: number,
|
||||
height: number,
|
||||
fov: number,
|
||||
pixelRatio: number,
|
||||
) {
|
||||
const aspect = width / height;
|
||||
const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2));
|
||||
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
|
||||
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
|
||||
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
|
||||
const rest = Math.max(9, frameDist * 1.1);
|
||||
return {
|
||||
wide: aspect > 1.1,
|
||||
tanHalf,
|
||||
spreadMax,
|
||||
rest,
|
||||
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
|
||||
scale:
|
||||
((pixelRatio * height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME),
|
||||
camera: cameraFrames(rest, spreadMax, frameDist),
|
||||
};
|
||||
}
|
||||
|
||||
type Layout = ReturnType<typeof frameLayout>;
|
||||
|
||||
/** Uniforms do tempo, da entrada e do mergulho. Devolve o quanto as camadas já estão de frente (0 a 1). */
|
||||
function updateUniforms(
|
||||
u: Uniforms,
|
||||
layout: Layout,
|
||||
p: number,
|
||||
time: number,
|
||||
dt: number,
|
||||
) {
|
||||
u.uTime.value = time;
|
||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||
u.uScroll.value = p;
|
||||
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
|
||||
const face = smooth(0.02, 0.28, p);
|
||||
u.uSpread.value = 1.05 + (layout.spreadMax - 1.05) * face * face;
|
||||
u.uFade.value = 1 - 0.4 * smooth(0.93, 1, p);
|
||||
u.uScale.value = layout.scale;
|
||||
return face;
|
||||
}
|
||||
|
||||
type FieldState = {
|
||||
progress: number;
|
||||
pointer: THREE.Vector2;
|
||||
lastMove: number;
|
||||
mouse: THREE.Vector2;
|
||||
tilt: THREE.Vector2;
|
||||
frames: number;
|
||||
};
|
||||
|
||||
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
|
||||
type Helpers = {
|
||||
ray: THREE.Raycaster;
|
||||
plane: THREE.Plane;
|
||||
up: THREE.Vector3;
|
||||
hit: THREE.Vector3;
|
||||
zero: THREE.Vector2;
|
||||
target: THREE.Vector2;
|
||||
};
|
||||
|
||||
/** O mouse se mexeu há pouco? Sem ele (toque, ou mouse parado), um cursor fantasma percorre a tela. */
|
||||
const cursorIsActive = (s: FieldState, time: number) =>
|
||||
s.lastMove > 0 && time - s.lastMove < 2.5;
|
||||
|
||||
/** Posição do grupo, rotação, câmera e foco: a vista de três quartos que balança e depois se endireita no mergulho. */
|
||||
function updateRig(
|
||||
g: THREE.Group,
|
||||
camera: THREE.Camera,
|
||||
u: Uniforms,
|
||||
layout: Layout,
|
||||
s: FieldState,
|
||||
h: Helpers,
|
||||
p: number,
|
||||
face: number,
|
||||
time: number,
|
||||
dt: number,
|
||||
cursorActive: boolean,
|
||||
) {
|
||||
s.tilt.lerp(cursorActive ? s.pointer : h.zero, 1 - Math.exp(-dt * 3));
|
||||
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
|
||||
g.rotation.set(
|
||||
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
|
||||
s.tilt.x * 0.15 * (1 - face),
|
||||
0,
|
||||
);
|
||||
// em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras
|
||||
// da direita); no celular, mais abaixo, atrás dos botões
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
g.position.set(
|
||||
0,
|
||||
(layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38) * settle,
|
||||
0,
|
||||
);
|
||||
camera.position.z = keyframes(layout.camera, p);
|
||||
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
||||
const spread = u.uSpread.value;
|
||||
const behind = camera.position.z - 0.2;
|
||||
const ahead = spread < behind ? spread : 0 < behind ? 0 : -spread;
|
||||
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||
g.updateMatrixWorld();
|
||||
}
|
||||
|
||||
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em h.target. Falso se o raio não toca a camada. */
|
||||
function cursorOnLayer(
|
||||
g: THREE.Group,
|
||||
camera: THREE.Camera,
|
||||
s: FieldState,
|
||||
h: Helpers,
|
||||
spread: number,
|
||||
) {
|
||||
h.ray.setFromCamera(s.pointer, camera);
|
||||
h.plane.set(h.up, -spread).applyMatrix4(g.matrixWorld);
|
||||
if (!h.ray.ray.intersectPlane(h.plane, h.hit)) return false;
|
||||
g.worldToLocal(h.hit);
|
||||
h.target.set(h.hit.x, h.hit.z);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
|
||||
function updatePointer(
|
||||
g: THREE.Group,
|
||||
camera: THREE.Camera,
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
h: Helpers,
|
||||
p: number,
|
||||
time: number,
|
||||
dt: number,
|
||||
cursorActive: boolean,
|
||||
) {
|
||||
if (cursorActive) {
|
||||
if (cursorOnLayer(g, camera, s, h, u.uSpread.value))
|
||||
s.mouse.lerp(h.target, 1 - Math.exp(-dt * 10));
|
||||
} else {
|
||||
s.mouse.lerp(
|
||||
h.target.set(
|
||||
Math.sin(time * 0.31) * 1.5,
|
||||
Math.sin(time * 0.23 + 1.3) * 1.1,
|
||||
),
|
||||
1 - Math.exp(-dt * 1.5),
|
||||
);
|
||||
}
|
||||
u.uMouse.value.copy(s.mouse);
|
||||
const targetAmt = (cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
||||
u.uMouseAmt.value +=
|
||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
return targetAmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
|
||||
* rolagem); este pede o quadro seguinte (a 60) só enquanto algo ainda muda. A entrada e a rolagem terminam de
|
||||
* assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta com o hero
|
||||
* à vista.
|
||||
*/
|
||||
function needsFrame(
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
target: number,
|
||||
amount: number,
|
||||
cursorActive: boolean,
|
||||
visible: boolean,
|
||||
) {
|
||||
const settling = u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
|
||||
const interacting =
|
||||
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
|
||||
return settling || (visible && interacting);
|
||||
}
|
||||
|
||||
function Field({
|
||||
progressRef,
|
||||
visible,
|
||||
@@ -400,11 +601,7 @@ function Field({
|
||||
}: HeroFieldSceneProps) {
|
||||
const { gl, camera, size } = useThree();
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const dense = useMemo(
|
||||
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
|
||||
[],
|
||||
);
|
||||
const geometry = useMemo(() => buildField(dense), [dense]);
|
||||
const geometry = useMemo(takeField, []);
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
@@ -427,7 +624,17 @@ function Field({
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const state = useRef({
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
frameLayout(
|
||||
size.width,
|
||||
size.height,
|
||||
(camera as THREE.PerspectiveCamera).fov,
|
||||
gl.getPixelRatio(),
|
||||
),
|
||||
[camera, gl, size.height, size.width],
|
||||
);
|
||||
const state = useRef<FieldState>({
|
||||
progress: 0,
|
||||
pointer: new THREE.Vector2(),
|
||||
lastMove: -10,
|
||||
@@ -435,10 +642,11 @@ function Field({
|
||||
tilt: new THREE.Vector2(),
|
||||
frames: 0,
|
||||
});
|
||||
const helpers = useMemo(
|
||||
const helpers = useMemo<Helpers>(
|
||||
() => ({
|
||||
ray: new THREE.Raycaster(),
|
||||
plane: new THREE.Plane(),
|
||||
up: new THREE.Vector3(0, 1, 0),
|
||||
hit: new THREE.Vector3(),
|
||||
zero: new THREE.Vector2(),
|
||||
target: new THREE.Vector2(),
|
||||
@@ -477,95 +685,41 @@ function Field({
|
||||
const s = state.current;
|
||||
const u = material.uniforms;
|
||||
const time = frame.clock.elapsedTime;
|
||||
const target = progressRef.current ?? 0;
|
||||
|
||||
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||
s.progress +=
|
||||
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
const p = s.progress;
|
||||
const aspect = size.width / size.height;
|
||||
const wide = aspect > 1.1;
|
||||
const tanHalf = Math.tan(
|
||||
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
|
||||
const cursorActive = cursorIsActive(s, time);
|
||||
const face = updateUniforms(u, layout, p, time, dt);
|
||||
updateRig(
|
||||
g,
|
||||
camera,
|
||||
u,
|
||||
layout,
|
||||
s,
|
||||
helpers,
|
||||
p,
|
||||
face,
|
||||
time,
|
||||
dt,
|
||||
cursorActive,
|
||||
);
|
||||
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
|
||||
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
|
||||
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
|
||||
const rest = Math.max(9, frameDist * 1.1);
|
||||
|
||||
u.uTime.value = time;
|
||||
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
|
||||
u.uScroll.value = p;
|
||||
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
|
||||
const face = smooth(0.02, 0.28, p);
|
||||
u.uSpread.value = 1.05 + (spreadMax - 1.05) * face * face;
|
||||
u.uFade.value = 1 - smooth(0.93, 1, p);
|
||||
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
|
||||
u.uScale.value =
|
||||
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
|
||||
Math.sqrt(frameDist / FRAME);
|
||||
|
||||
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
|
||||
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
|
||||
s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
|
||||
|
||||
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
|
||||
g.rotation.set(
|
||||
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
|
||||
s.tilt.x * 0.15 * (1 - face),
|
||||
0,
|
||||
const amount = updatePointer(
|
||||
g,
|
||||
camera,
|
||||
u,
|
||||
s,
|
||||
helpers,
|
||||
p,
|
||||
time,
|
||||
dt,
|
||||
cursorActive,
|
||||
);
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
// em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras
|
||||
// da direita); no celular, mais abaixo, atrás dos botões
|
||||
g.position.set(0, (wide ? -0.5 : -rest * tanHalf * 0.38) * settle, 0);
|
||||
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)
|
||||
const spread = u.uSpread.value;
|
||||
const ahead =
|
||||
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
|
||||
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||
g.updateMatrixWorld();
|
||||
|
||||
if (mouseActive) {
|
||||
helpers.ray.setFromCamera(s.pointer, camera);
|
||||
helpers.plane
|
||||
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
|
||||
.applyMatrix4(g.matrixWorld);
|
||||
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
||||
g.worldToLocal(helpers.hit);
|
||||
s.mouse.lerp(
|
||||
helpers.target.set(helpers.hit.x, helpers.hit.z),
|
||||
1 - Math.exp(-dt * 10),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
s.mouse.lerp(
|
||||
helpers.target.set(
|
||||
Math.sin(time * 0.31) * 1.5,
|
||||
Math.sin(time * 0.23 + 1.3) * 1.1,
|
||||
),
|
||||
1 - Math.exp(-dt * 1.5),
|
||||
);
|
||||
}
|
||||
u.uMouse.value.copy(s.mouse);
|
||||
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
||||
u.uMouseAmt.value +=
|
||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
|
||||
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();
|
||||
if (needsFrame(u, s, target, amount, cursorActive, visible))
|
||||
frame.invalidate();
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
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,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import FramePulse from "./FramePulse";
|
||||
|
||||
function GridFloor() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
const material = meshRef.current.material as THREE.ShaderMaterial;
|
||||
if (material.uniforms) {
|
||||
material.uniforms.uTime.value = state.clock.elapsedTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const gridShader = useMemo(
|
||||
() => ({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color("#ccff00") },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vPosition = position;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
|
||||
void main() {
|
||||
vec2 grid = abs(fract(vPosition.xz * 0.5) - 0.5) / fwidth(vPosition.xz * 0.5);
|
||||
float line = min(grid.x, grid.y);
|
||||
float alpha = 1.0 - min(line, 1.0);
|
||||
|
||||
// Distance fade
|
||||
float dist = length(vPosition.xz) * 0.08;
|
||||
alpha *= max(0.0, 1.0 - dist);
|
||||
alpha *= 0.12;
|
||||
|
||||
// Pulse wave
|
||||
float wave = sin(length(vPosition.xz) * 0.3 - uTime * 0.8) * 0.5 + 0.5;
|
||||
alpha *= 0.5 + wave * 0.5;
|
||||
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, -4, 0]}>
|
||||
<planeGeometry args={[40, 40, 1, 1]} />
|
||||
<shaderMaterial
|
||||
{...gridShader}
|
||||
transparent
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraRig() {
|
||||
const { camera } = useThree();
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useFrame(() => {
|
||||
const targetX = mouseRef.current.x * 0.5;
|
||||
const targetY = mouseRef.current.y * 0.3 + 0.5;
|
||||
camera.position.x += (targetX - camera.position.x) * 0.02;
|
||||
camera.position.y += (targetY - camera.position.y) * 0.02;
|
||||
camera.lookAt(0, 0, 0);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
};
|
||||
window.addEventListener("mousemove", handler);
|
||||
return () => window.removeEventListener("mousemove", handler);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fundo ambiente das páginas internas: desenha sob demanda, a 30 quadros por segundo e só à vista. */
|
||||
export default function HeroScene({
|
||||
isVisible = true,
|
||||
}: {
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="absolute inset-0 z-0"
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="demand"
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
|
||||
<ambientLight intensity={0.25} />
|
||||
<directionalLight
|
||||
position={[5, 5, 5]}
|
||||
intensity={0.4}
|
||||
color="#ccff00"
|
||||
/>
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||
|
||||
<FramePulse active={isVisible && inView} />
|
||||
<CameraRig />
|
||||
<GridFloor />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { motion };
|
||||
});
|
||||
|
||||
describe("MagneticButton semantics", () => {
|
||||
it("renders a list item directly when used inside a list", () => {
|
||||
const { container } = render(
|
||||
<ul>
|
||||
<MagneticButton as="li" className="ds-tag">
|
||||
TypeScript
|
||||
</MagneticButton>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
|
||||
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { type MouseEvent, type ReactNode, useState } from "react";
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
strength?: number;
|
||||
as?: "a" | "button" | "div" | "span" | "li";
|
||||
target?: string;
|
||||
rel?: string;
|
||||
download?: boolean | string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
wrapperClassName?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export default function MagneticButton({
|
||||
children,
|
||||
className = "",
|
||||
href,
|
||||
onClick,
|
||||
strength = 0.3,
|
||||
as = "button",
|
||||
target,
|
||||
rel,
|
||||
download,
|
||||
type,
|
||||
wrapperClassName = "",
|
||||
"aria-label": ariaLabel,
|
||||
}: MagneticButtonProps) {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleMouse = (e: MouseEvent<HTMLElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
setPosition({
|
||||
x: (e.clientX - centerX) * strength,
|
||||
y: (e.clientY - centerY) * strength,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const Tag =
|
||||
as === "a"
|
||||
? motion.a
|
||||
: as === "button"
|
||||
? motion.button
|
||||
: as === "div"
|
||||
? motion.div
|
||||
: as === "span"
|
||||
? motion.span
|
||||
: motion.li;
|
||||
const linkProps = as === "a" ? { href, target, rel, download } : {};
|
||||
const buttonProps = as === "button" ? { type } : {};
|
||||
|
||||
const motionProps = {
|
||||
animate: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
if (as === "li") {
|
||||
return (
|
||||
<Tag
|
||||
className={`${className} ${wrapperClassName}`.trim()}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-block ${wrapperClassName}`.trim()}>
|
||||
<Tag
|
||||
className={className}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import ScrollProgress from "./ScrollProgress";
|
||||
|
||||
describe("ScrollProgress", () => {
|
||||
it("is decorative: hidden from assistive technology, with no text", () => {
|
||||
const { container } = render(<ScrollProgress />);
|
||||
|
||||
for (const piece of container.querySelectorAll(".ds-scroll-progress"))
|
||||
expect(piece).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("has the top bar and the side rail with its fill and dot, ready for the CSS scroll timeline", () => {
|
||||
const { container } = render(<ScrollProgress />);
|
||||
|
||||
expect(container.querySelector(".ds-scroll-bar")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".ds-scroll-rail .ds-scroll-fill"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".ds-scroll-rail .ds-scroll-dot"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drives nothing from JavaScript: no inline transform or opacity, no scroll listener", () => {
|
||||
const listen = jest.spyOn(window, "addEventListener");
|
||||
const { container } = render(<ScrollProgress />);
|
||||
|
||||
for (const element of container.querySelectorAll<HTMLElement>("*")) {
|
||||
expect(element.style.transform).toBe("");
|
||||
expect(element.style.opacity).toBe("");
|
||||
}
|
||||
expect(listen.mock.calls.map(([type]) => type)).not.toContain("scroll");
|
||||
listen.mockRestore();
|
||||
});
|
||||
|
||||
it("does not need the animation library", () => {
|
||||
jest.isolateModules(() => {
|
||||
// o pacote "motion" saiu do projeto: virtual, para o dublê valer mesmo sem ele no node_modules
|
||||
jest.doMock(
|
||||
"motion/react",
|
||||
() => {
|
||||
throw new Error(
|
||||
"motion não pode ser carregado pela barra de rolagem",
|
||||
);
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
expect(() => require("./ScrollProgress")).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useScroll, useSpring, useTransform } from "motion/react";
|
||||
|
||||
/**
|
||||
* Barra de progresso no topo e trilho lateral da rolagem da página. Sem JavaScript de rolagem: o CSS liga a
|
||||
* animação à posição da rolagem (`animation-timeline: scroll()`, em globals.css). Onde o navegador não
|
||||
* suporta, os dois somem.
|
||||
*/
|
||||
export default function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.02, 0.98, 1],
|
||||
[0, 1, 1, 0],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top progress bar */}
|
||||
<motion.div
|
||||
className="fixed left-0 right-0 top-0 z-[9995] h-[2px] origin-left bg-[var(--slash)]"
|
||||
style={{ scaleX, opacity }}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="ds-scroll-progress ds-scroll-bar fixed left-0 right-0 top-0 z-[9995] h-[2px] origin-left bg-[var(--slash)]"
|
||||
/>
|
||||
|
||||
{/* Side scroll indicator */}
|
||||
<motion.div
|
||||
className="fixed right-6 top-1/2 z-[9994] hidden -translate-y-1/2 lg:block"
|
||||
style={{ opacity }}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="ds-scroll-progress ds-scroll-rail fixed right-6 top-1/2 z-[9994] hidden -translate-y-1/2 lg:block"
|
||||
>
|
||||
<div className="relative h-[120px] w-px bg-[rgba(255,255,255,0.08)]">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 w-full origin-top bg-[var(--slash)]"
|
||||
style={{
|
||||
scaleY: scrollYProgress,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-[var(--slash)]"
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
top: useTransform(scrollYProgress, [0, 1], ["0%", "100%"]),
|
||||
boxShadow: "0 0 10px rgba(204, 255, 0, 0.5)",
|
||||
}}
|
||||
/>
|
||||
<div className="ds-scroll-fill absolute left-0 top-0 h-full w-full origin-top bg-[var(--slash)]" />
|
||||
<div className="ds-scroll-dot absolute left-1/2 top-0 h-[6px] w-[6px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--slash)] shadow-[0_0_10px_rgba(204,255,0,0.5)]" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,37 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useRef } from "react";
|
||||
import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
|
||||
import { 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
|
||||
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx), com o hook montado
|
||||
* numa página de verdade:
|
||||
* - cliques em â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.
|
||||
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos, com Lenis e sem ele (toque);
|
||||
* - ref vazia, movimento reduzido e a limpeza dos ouvintes ao desmontar.
|
||||
* O Lenis é trocado por um dublê que registra o que recebe; o GSAP nem existe mais (se alguém o importar, o
|
||||
* dublê abaixo quebra o teste).
|
||||
*/
|
||||
|
||||
type Call = unknown[];
|
||||
const mockTimelines: Array<{ calls: Call[] }> = [];
|
||||
const mockTicker = {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
lagSmoothing: jest.fn(),
|
||||
time: 0,
|
||||
};
|
||||
jest.mock(
|
||||
"gsap",
|
||||
() => {
|
||||
throw new Error("gsap não deve ser carregado");
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock(
|
||||
"gsap/ScrollTrigger",
|
||||
() => {
|
||||
throw new Error("gsap/ScrollTrigger não deve ser carregado");
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
const mockLenis: MockLenis[] = [];
|
||||
|
||||
class MockLenis {
|
||||
isScrolling: false | "smooth" = false;
|
||||
on = jest.fn();
|
||||
raf = jest.fn();
|
||||
scrollTo = jest.fn();
|
||||
destroy = jest.fn();
|
||||
@@ -34,30 +40,6 @@ class MockLenis {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -126,7 +108,6 @@ function Home({ attach = true }: { attach?: boolean }) {
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const OriginalResizeObserver = global.ResizeObserver;
|
||||
const originalOffsetTop = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
@@ -216,14 +197,7 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
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(() => {});
|
||||
@@ -236,7 +210,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
global.ResizeObserver = OriginalResizeObserver;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -527,113 +500,13 @@ describe("useCinematicScroll: salto direto x rolagem curta", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const { prevented } = click(link("projetos"));
|
||||
expect(prevented).toBe(false);
|
||||
});
|
||||
|
||||
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
|
||||
@@ -644,6 +517,20 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
const view = render(<Home />);
|
||||
|
||||
// sem esperar importação nenhuma: o clique já é tratado
|
||||
const { prevented } = click(link("projetos"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
|
||||
@@ -662,59 +549,16 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
|
||||
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();
|
||||
it("path: after unmount an anchor click is no longer intercepted", async () => {
|
||||
const view = await mount();
|
||||
act(() => {
|
||||
resizers[0].callback();
|
||||
});
|
||||
|
||||
const target = link("projetos");
|
||||
view.unmount();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
document.body.append(target);
|
||||
|
||||
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||
expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
const { prevented } = click(target);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
target.remove();
|
||||
});
|
||||
});
|
||||
@@ -2,171 +2,41 @@ import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
anchorDuration,
|
||||
ENTER_OFFSET,
|
||||
enterScene,
|
||||
exitScene,
|
||||
anchorTop,
|
||||
IDLE_SECONDS,
|
||||
isPlainPrimaryClick,
|
||||
JUMP_SCREENS,
|
||||
MAX_ANCHOR_SECONDS,
|
||||
PASSAGE_END,
|
||||
samePageHashTarget,
|
||||
useCinematicScroll,
|
||||
} from "./useCinematicScroll";
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------
|
||||
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
|
||||
* ------------------------------------------------------------------------------------------------ */
|
||||
/*
|
||||
* A rolagem da home sem biblioteca de animação por quadro: as passagens entre cenas são CSS; este hook só
|
||||
* liga o Lenis (mouse) e leva os links internos até o destino. Aqui: o hook montado (Lenis trocado por um
|
||||
* dublê que registra o que recebe) e as funções puras que ele usa.
|
||||
*/
|
||||
|
||||
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
|
||||
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
|
||||
// o GSAP e o ScrollTrigger saíram da página: se alguém voltar a importá-los, estes dublês quebram o teste
|
||||
jest.mock(
|
||||
"gsap",
|
||||
() => {
|
||||
throw new Error("gsap não deve ser carregado");
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock(
|
||||
"gsap/ScrollTrigger",
|
||||
() => {
|
||||
throw new Error("gsap/ScrollTrigger não deve ser carregado");
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
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();
|
||||
@@ -175,30 +45,6 @@ class MockLenis {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -226,7 +72,6 @@ function Home() {
|
||||
<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>
|
||||
@@ -257,15 +102,20 @@ function mockMedia(active: string[]) {
|
||||
|
||||
async function mount() {
|
||||
const view = render(<Home />);
|
||||
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
|
||||
// a importação dinâmica do Lenis resolve 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"));
|
||||
/** Quadros de animação pedidos pelo hook desde que o espião foi criado. */
|
||||
let frames: jest.SpyInstance;
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||
@@ -286,12 +136,9 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockTimelines.length = 0;
|
||||
jest.useFakeTimers();
|
||||
frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
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(() => {});
|
||||
@@ -299,162 +146,286 @@ beforeEach(() => {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes longer for farther anchors, up to the cap, and the same time up or down", () => {
|
||||
const near = anchorDuration(200, 800) as number;
|
||||
const far = anchorDuration(1600, 800) as number;
|
||||
|
||||
expect(far).toBeGreaterThan(near);
|
||||
expect(anchorDuration(-1600, 800)).toBe(far);
|
||||
expect(anchorDuration(0, 0)).not.toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPlainPrimaryClick", () => {
|
||||
const click = (init: MouseEventInit = {}) =>
|
||||
new MouseEvent("click", { cancelable: true, button: 0, ...init });
|
||||
|
||||
it("accepts a plain click of the main button", () => {
|
||||
expect(isPlainPrimaryClick(click())).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
|
||||
"refuses a click with %s held (new tab, selection)",
|
||||
(modifier) => {
|
||||
expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])("refuses mouse button %i", (button) => {
|
||||
expect(isPlainPrimaryClick(click({ button }))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a click somebody else already cancelled", () => {
|
||||
const event = click();
|
||||
event.preventDefault();
|
||||
|
||||
expect(isPlainPrimaryClick(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePageHashTarget", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function clickOn(markup: string, selector: string) {
|
||||
document.body.innerHTML = markup;
|
||||
const target = document.querySelector(selector) as Element;
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
it("finds the element a same-page #link points to", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
|
||||
"a",
|
||||
);
|
||||
|
||||
const hit = samePageHashTarget(event);
|
||||
|
||||
expect(hit?.hash).toBe("#alvo");
|
||||
expect(hit?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("works from inside the link (the text, an icon)", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
|
||||
"#inside",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another site", `<a href="https://example.com/#alvo">x</a>`],
|
||||
["another page of this site", `<a href="/outra#alvo">x</a>`],
|
||||
["no anchor", `<a href="/">x</a>`],
|
||||
["an empty anchor", `<a href="/#">x</a>`],
|
||||
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
|
||||
["an id that does not exist", `<a href="/#nada">x</a>`],
|
||||
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
|
||||
["no link at all", `<p>x</p>`],
|
||||
])("returns null for %s", (_label, markup) => {
|
||||
const event = clickOn(
|
||||
`${markup}<section id="alvo"></section>`,
|
||||
markup.startsWith("<a") ? "a" : "p",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles target=_self like a plain link", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
|
||||
"a",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("ignores a click whose target is not an element", () => {
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: document });
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchorTop", () => {
|
||||
const scenes = () => {
|
||||
document.body.innerHTML = `
|
||||
<section id="a" data-top="1800"><h2 id="a-title" data-top="1900">t</h2></section>
|
||||
<section id="b" data-top="2400"></section>
|
||||
<p id="loose" data-top="9000">x</p>`;
|
||||
return [...document.querySelectorAll<HTMLElement>("section")];
|
||||
};
|
||||
const byId = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function headerOffset(px: string) {
|
||||
jest
|
||||
.spyOn(window, "getComputedStyle")
|
||||
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
|
||||
}
|
||||
|
||||
it("is the scene top when the target is the whole scene (the header offset is ignored)", () => {
|
||||
const all = scenes();
|
||||
headerOffset("80px");
|
||||
|
||||
expect(anchorTop(byId("a"), all)).toBe(1800);
|
||||
});
|
||||
|
||||
it("is the target minus the fixed header when it is inside a scene", () => {
|
||||
const all = scenes();
|
||||
headerOffset("80px");
|
||||
|
||||
expect(anchorTop(byId("a-title"), all)).toBe(1900 - 80);
|
||||
});
|
||||
|
||||
it("never goes above the top of the scene", () => {
|
||||
const all = scenes();
|
||||
headerOffset("300px");
|
||||
|
||||
expect(anchorTop(byId("a-title"), all)).toBe(1800);
|
||||
});
|
||||
|
||||
it("measures from the top of the page, minus the header, for a target outside every scene", () => {
|
||||
const all = scenes();
|
||||
headerOffset("100px");
|
||||
|
||||
expect(anchorTop(byId("loose"), all)).toBe(9000 - 100);
|
||||
});
|
||||
|
||||
it("treats a missing header offset as zero", () => {
|
||||
const all = scenes();
|
||||
headerOffset("");
|
||||
|
||||
expect(anchorTop(byId("a-title"), all)).toBe(1900);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll", () => {
|
||||
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
|
||||
it("never hides, pins or restyles a scene: the passages are CSS, nothing inline", 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("");
|
||||
|
||||
for (const element of document.querySelectorAll<HTMLElement>(
|
||||
"main > section",
|
||||
)) {
|
||||
expect(element.getAttribute("style")).toBeNull();
|
||||
}
|
||||
// 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 () => {
|
||||
it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
|
||||
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
act(() => {
|
||||
link.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("smooth scroll", () => {
|
||||
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
|
||||
it("is not created on a touch screen: the package is not even loaded, and the native scroll does the work", 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);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
advance(500);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
|
||||
it("is created with a mouse, with the smoothing options", async () => {
|
||||
await mount();
|
||||
|
||||
expect(mockLenis).toHaveLength(1);
|
||||
expect(mockLenis[0].options).toEqual({
|
||||
lerp: 0.075,
|
||||
wheelMultiplier: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("feeds Lenis in milliseconds while scrolling, and lets the animation clock go once idle", async () => {
|
||||
await mount();
|
||||
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
|
||||
// rolando: segue ligado, a cada quadro
|
||||
lenis.isScrolling = "smooth";
|
||||
tick(0.5);
|
||||
expect(lenis.raf).toHaveBeenLastCalledWith(500);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
advance(500);
|
||||
const whileScrolling = lenis.raf.mock.calls.length;
|
||||
expect(whileScrolling).toBeGreaterThan(20);
|
||||
expect(lenis.raf.mock.calls.at(-1)?.[0]).toBeGreaterThan(400);
|
||||
|
||||
// parado há mais que o tempo de espera: o relógio é solto
|
||||
// parado: depois de IDLE_SECONDS o relógio é solto de vez (nenhum quadro de animação pedido)
|
||||
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);
|
||||
advance(IDLE_SECONDS * 1000 + 200);
|
||||
const stopped = lenis.raf.mock.calls.length;
|
||||
const requested = frames.mock.calls.length;
|
||||
advance(3000);
|
||||
expect(lenis.raf.mock.calls.length).toBe(stopped);
|
||||
expect(frames.mock.calls.length).toBe(requested);
|
||||
});
|
||||
|
||||
it("wakes up again with the wheel or any scroll event, and a second event does not start a second clock", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
advance(IDLE_SECONDS * 1000 + 500);
|
||||
lenis.raf.mockClear();
|
||||
|
||||
// a roda do mouse o acorda de novo
|
||||
mockTicker.time = 10;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
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);
|
||||
advance(100);
|
||||
|
||||
// um laço só: cerca de um quadro a cada 16 ms
|
||||
expect(lenis.raf.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(lenis.raf.mock.calls.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
|
||||
@@ -467,7 +438,7 @@ describe("useCinematicScroll", () => {
|
||||
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
||||
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
|
||||
expect(farTop).toBe(TOPS.contact);
|
||||
expect(farOptions).toMatchObject({ immediate: true });
|
||||
expect(farOptions).toMatchObject({ immediate: true, duration: 0 });
|
||||
expect(window.location.hash).toBe("#contact");
|
||||
expect(document.getElementById("contact")).toHaveFocus();
|
||||
|
||||
@@ -481,7 +452,22 @@ describe("useCinematicScroll", () => {
|
||||
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||
});
|
||||
|
||||
it("without Lenis (touch) a far anchor is an instant jump", async () => {
|
||||
it("wakes the clock before an anchor scroll, so the animation actually runs after a long rest", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
advance(IDLE_SECONDS * 1000 + 500);
|
||||
lenis.raf.mockClear();
|
||||
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||
});
|
||||
advance(100);
|
||||
|
||||
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(lenis.raf).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
act(() => {
|
||||
@@ -501,17 +487,33 @@ describe("useCinematicScroll", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
|
||||
it("releases Lenis, the clock and the listeners 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();
|
||||
lenis.raf.mockClear();
|
||||
const requested = frames.mock.calls.length;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
advance(200);
|
||||
expect(lenis.raf).not.toHaveBeenCalled();
|
||||
expect(frames.mock.calls.length).toBe(requested);
|
||||
});
|
||||
|
||||
it("creates nothing when the page unmounted before the package finished loading", async () => {
|
||||
const view = render(<Home />);
|
||||
view.unmount();
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,38 +4,30 @@ import { type RefObject, useEffect } from "react";
|
||||
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||
|
||||
/*
|
||||
* Rolagem como um vídeo: o GSAP liga cada passagem de uma cena para a seguinte à posição da rolagem (rolar
|
||||
* para cima volta o "vídeo") e, no mouse, o Lenis suaviza a roda.
|
||||
* Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação:
|
||||
*
|
||||
* Uma única passagem para a home inteira, só com opacidade e um deslocamento vertical curto:
|
||||
* - a cena que entra chega com fade e sobe ENTER_OFFSET px até o lugar dela;
|
||||
* - a cena que sai some com fade e termina SEMPRE em opacidade 0 (nenhuma cena anterior fica "fantasma" atrás
|
||||
* das seguintes);
|
||||
* - nada de recorte (clip-path), escala ou rotação, e nenhuma cena é escondida com visibility: o conteúdo
|
||||
* nasce visível no HTML do servidor e a opacidade só muda por rolagem (Ctrl+F e leitores de tela enxergam
|
||||
* 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.
|
||||
* 1. As passagens entre cenas são CSS (globals.css, `animation-timeline: view()`): o conteúdo de cada seção
|
||||
* (`.ds-section-shell`) chega com fade e uma subida curta enquanto a seção entra na janela, e o fundo da
|
||||
* seção fica sempre inteiro (seções do mesmo tom não piscam em cinza). Nada de JavaScript por quadro, e onde
|
||||
* o navegador não tem o recurso o conteúdo simplesmente já está lá. Qualquer filho direto de <main> é uma
|
||||
* cena; nada aqui depende de uma seção específica existir.
|
||||
* 2. Este hook cuida só do que o CSS não faz: no mouse, o Lenis suaviza a roda (com o relógio ligado apenas
|
||||
* enquanto algo se move, nunca com a página parada), e os links internos (#id) "avançam o vídeo" até o
|
||||
* destino em vez de saltar. No toque a rolagem nativa corre em outra thread: nem o Lenis é baixado.
|
||||
*
|
||||
* 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
|
||||
* entrada da mesma cena terminou (cenas curtas não brigam pela mesma opacidade). Qualquer filho direto de
|
||||
* <main> é uma cena; nada aqui depende de uma seção específica existir.
|
||||
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
|
||||
*/
|
||||
|
||||
/** Deslocamento vertical, em px, com que a cena chega (entre 24 e 40). */
|
||||
export const ENTER_OFFSET = 32;
|
||||
/** A passagem corre enquanto o topo da cena que entra sobe de 1 altura de tela até esta fração dela. */
|
||||
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. */
|
||||
/** Depois deste tempo parada, a rolagem suave solta o relógio (requestAnimationFrame). */
|
||||
export const IDLE_SECONDS = 1;
|
||||
|
||||
const vh = () => window.innerHeight;
|
||||
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das passagens, getBoundingClientRect não). */
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms que as passagens aplicam). */
|
||||
function pageTop(el: HTMLElement) {
|
||||
let top = 0;
|
||||
for (
|
||||
@@ -47,32 +39,6 @@ function pageTop(el: HTMLElement) {
|
||||
return top;
|
||||
}
|
||||
|
||||
/** A cena chega: fade e subida curta. Ao terminar, o estilo inline some (sem transform sobrando na cena). */
|
||||
export function enterScene(tl: gsap.core.Timeline, scene: HTMLElement) {
|
||||
tl.fromTo(
|
||||
scene,
|
||||
{ opacity: 0, y: ENTER_OFFSET },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
ease: "power2.out",
|
||||
clearProps: "opacity,transform",
|
||||
immediateRender: false,
|
||||
},
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** A cena sai: fade até 0. É o último estado dela até a rolagem voltar. */
|
||||
export function exitScene(tl: gsap.core.Timeline, scene: HTMLElement) {
|
||||
tl.fromTo(
|
||||
scene,
|
||||
{ opacity: 1 },
|
||||
{ opacity: 0, ease: "power2.in", immediateRender: false },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */
|
||||
export function anchorDuration(distance: number, viewport: number) {
|
||||
const screens = Math.abs(distance) / Math.max(1, viewport);
|
||||
@@ -83,204 +49,158 @@ export function anchorDuration(distance: number, viewport: number) {
|
||||
const easeInOutCubic = (t: number) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
|
||||
/** Clique do botão principal, sem tecla modificadora, que nenhum outro código já tratou. */
|
||||
export function isPlainPrimaryClick(event: MouseEvent) {
|
||||
return !(
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
/** O link (<a>) que o clique atingiu, se abre na mesma aba; `null` para qualquer outro clique. */
|
||||
function sameTabLink(event: MouseEvent) {
|
||||
const link =
|
||||
event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
if (!(link instanceof HTMLAnchorElement)) return null;
|
||||
return !link.target || link.target === "_self" ? link : null;
|
||||
}
|
||||
|
||||
/** O `#id` de um link para esta mesma página; `null` se ele leva a outra página ou não tem âncora. */
|
||||
function hashOnThisPage(link: HTMLAnchorElement) {
|
||||
const url = new URL(link.href);
|
||||
const same =
|
||||
url.origin === location.origin && url.pathname === location.pathname;
|
||||
return same && url.hash ? url.hash : null;
|
||||
}
|
||||
|
||||
function elementForHash(hash: string) {
|
||||
try {
|
||||
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Link para outro ponto da MESMA página (`#id`): o elemento de destino e o hash; `null` para qualquer outro clique. */
|
||||
export function samePageHashTarget(event: MouseEvent) {
|
||||
const link = sameTabLink(event);
|
||||
const hash = link && hashOnThisPage(link);
|
||||
const target = hash ? elementForHash(hash) : null;
|
||||
return hash && target ? { target, hash } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posição da rolagem (px) para mostrar `target`: o alvo que é a cena inteira para no topo dela; um alvo
|
||||
* interno desconta o cabeçalho fixo, sem passar do topo da cena.
|
||||
*/
|
||||
export function anchorTop(target: HTMLElement, scenes: readonly HTMLElement[]) {
|
||||
const scene = scenes.find((candidate) => candidate.contains(target));
|
||||
const sceneTop = scene ? pageTop(scene) : 0;
|
||||
if (scene === target) return sceneTop;
|
||||
const offset =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
return Math.max(sceneTop, pageTop(target) - offset);
|
||||
}
|
||||
|
||||
type SmoothScroll = {
|
||||
scrollTo: (top: number, duration: number | null) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lenis com o relógio ligado só enquanto a rolagem se move; parado por IDLE_SECONDS, o requestAnimationFrame
|
||||
* para. Devolve `null` (sem criar nada) se a página já saiu de cena enquanto o pacote carregava.
|
||||
*/
|
||||
async function startSmoothScroll(
|
||||
isAlive: () => boolean,
|
||||
): Promise<SmoothScroll | null> {
|
||||
const { default: Lenis } = await import("lenis");
|
||||
if (!isAlive()) return null;
|
||||
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
|
||||
let frame = 0;
|
||||
let lastBusy = 0;
|
||||
const tick = (time: number) => {
|
||||
lenis.raf(time);
|
||||
if (lenis.isScrolling) lastBusy = time;
|
||||
frame =
|
||||
time - lastBusy > IDLE_SECONDS * 1000 ? 0 : requestAnimationFrame(tick);
|
||||
};
|
||||
const wake = () => {
|
||||
lastBusy = performance.now();
|
||||
if (!frame) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
window.addEventListener("wheel", wake, { passive: true });
|
||||
window.addEventListener("scroll", wake, { passive: true });
|
||||
wake();
|
||||
return {
|
||||
scrollTo(top, duration) {
|
||||
wake();
|
||||
lenis.scrollTo(top, {
|
||||
immediate: duration === null,
|
||||
duration: duration ?? 0,
|
||||
easing: easeInOutCubic,
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener("wheel", wake);
|
||||
window.removeEventListener("scroll", wake);
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
lenis.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
let smooth: SmoothScroll | null = null;
|
||||
|
||||
(async () => {
|
||||
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
||||
const smooth = !isCoarsePointer();
|
||||
const [{ gsap }, { ScrollTrigger }, lenisModule] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
smooth ? import("lenis") : Promise.resolve(null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
// Lenis: o relógio do GSAP só roda enquanto a rolagem anima; parada por IDLE_SECONDS, ele é solto
|
||||
const lenis = lenisModule
|
||||
? new lenisModule.default({ lerp: 0.075, wheelMultiplier: 0.85 })
|
||||
: null;
|
||||
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 });
|
||||
}
|
||||
|
||||
const scenes = Array.from(main.children) as HTMLElement[];
|
||||
const context = gsap.context(() => {
|
||||
scenes.forEach((scene, index) => {
|
||||
const following = scenes[index + 1];
|
||||
const trigger = (
|
||||
start: () => number,
|
||||
end: () => number,
|
||||
extra: ScrollTrigger.Vars = {},
|
||||
) => ({
|
||||
trigger: scene,
|
||||
start,
|
||||
end,
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
...extra,
|
||||
});
|
||||
const timeline = (vars: ScrollTrigger.Vars) =>
|
||||
gsap.timeline({
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
scrollTrigger: vars,
|
||||
});
|
||||
|
||||
if (index > 0)
|
||||
enterScene(
|
||||
timeline(
|
||||
trigger(
|
||||
() => pageTop(scene) - vh(),
|
||||
() => pageTop(scene) - vh() * PASSAGE_END,
|
||||
),
|
||||
),
|
||||
scene,
|
||||
);
|
||||
if (following)
|
||||
exitScene(
|
||||
timeline(
|
||||
trigger(
|
||||
// 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);
|
||||
|
||||
// a página mudou de altura (fontes, imagens, seções que abrem e fecham): o GSAP remede as passagens
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
|
||||
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
||||
if (!isCoarsePointer())
|
||||
startSmoothScroll(() => !cancelled).then((scroll) => {
|
||||
smooth = scroll;
|
||||
});
|
||||
resizeObserver.observe(main);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
const scrollToTop = (top: number) => {
|
||||
const duration = anchorDuration(top - window.scrollY, vh());
|
||||
if (smooth) smooth.scrollTo(top, duration);
|
||||
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) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const url = new URL(link.href);
|
||||
if (
|
||||
url.origin !== location.origin ||
|
||||
url.pathname !== location.pathname ||
|
||||
!url.hash
|
||||
)
|
||||
return;
|
||||
let target: HTMLElement | null = null;
|
||||
try {
|
||||
target = document.getElementById(
|
||||
decodeURIComponent(url.hash.slice(1)),
|
||||
);
|
||||
} catch {}
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
// o alvo é a cena inteira: para no topo dela; alvo interno: desconta o cabeçalho fixo, sem passar do topo da cena
|
||||
const destination = target;
|
||||
const scene = scenes.find((candidate) =>
|
||||
candidate.contains(destination),
|
||||
);
|
||||
const offset =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const sceneTop = scene ? pageTop(scene) : 0;
|
||||
const top =
|
||||
scene === destination
|
||||
? sceneTop
|
||||
: Math.max(sceneTop, pageTop(destination) - offset);
|
||||
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
|
||||
scrollToTop(top);
|
||||
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
|
||||
if (!destination.hasAttribute("tabindex")) destination.tabIndex = -1;
|
||||
destination.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("click", onClick, true);
|
||||
window.removeEventListener("wheel", wake);
|
||||
window.removeEventListener("scroll", wake);
|
||||
clearTimeout(refreshTimer);
|
||||
resizeObserver.disconnect();
|
||||
context.revert();
|
||||
sleep();
|
||||
lenis?.destroy();
|
||||
};
|
||||
})();
|
||||
// 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) => {
|
||||
if (!isPlainPrimaryClick(event)) return;
|
||||
const hit = samePageHashTarget(event);
|
||||
if (!hit) return;
|
||||
event.preventDefault();
|
||||
if (location.hash !== hit.hash) history.pushState(null, "", hit.hash);
|
||||
scrollToTop(
|
||||
anchorTop(hit.target, Array.from(main.children) as HTMLElement[]),
|
||||
);
|
||||
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
|
||||
if (!hit.target.hasAttribute("tabindex")) hit.target.tabIndex = -1;
|
||||
hit.target.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
document.removeEventListener("click", onClick, true);
|
||||
smooth?.destroy();
|
||||
};
|
||||
}, [mainRef]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
|
||||
describe("PhoneFrame", () => {
|
||||
it("shows only the poster when it has no children (the pinned reel does not download the phone video)", () => {
|
||||
const { container } = render(
|
||||
<PhoneFrame poster="/videos/x/mobile.webp" className="ds-reel-phone" />,
|
||||
);
|
||||
|
||||
const phone = container.firstElementChild as HTMLElement;
|
||||
const screenEl = phone.querySelector(".ds-phone-screen") as HTMLElement;
|
||||
expect(phone).toHaveClass("ds-phone", "ds-reel-phone");
|
||||
expect(screenEl.style.backgroundImage).toContain("/videos/x/mobile.webp");
|
||||
expect(screenEl).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders its children inside the screen", () => {
|
||||
render(
|
||||
<PhoneFrame>
|
||||
<p>demo</p>
|
||||
</PhoneFrame>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("demo").parentElement).toHaveClass(
|
||||
"ds-phone-screen",
|
||||
);
|
||||
});
|
||||
|
||||
it("has no background without a poster, and passes the style along", () => {
|
||||
const { container } = render(<PhoneFrame style={{ width: 120 }} />);
|
||||
|
||||
expect(
|
||||
(container.querySelector(".ds-phone-screen") as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toBe("");
|
||||
expect((container.firstElementChild as HTMLElement).style.width).toBe(
|
||||
"120px",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
/** Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta). */
|
||||
/**
|
||||
* Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta).
|
||||
* Sem filhos, a tela mostra só o pôster.
|
||||
*/
|
||||
export function PhoneFrame({
|
||||
children,
|
||||
poster,
|
||||
className = "",
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getShowcaseConfig,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { ShowcaseProvider, useShowcaseCard } from "./ShowcaseProvider";
|
||||
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
@@ -21,11 +21,10 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
}));
|
||||
|
||||
function Card({ id }: { id: string }) {
|
||||
const { frameRef, open } = useShowcaseCard(id);
|
||||
const openProject = useShowcaseOpen();
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} />
|
||||
<button type="button" onClick={open}>
|
||||
<button type="button" onClick={() => openProject(id)}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
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 { ProjectMedia } from "./ProjectMedia";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: () => <div role="dialog" aria-label="ampliado" />,
|
||||
}));
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const nutriscan = projects.find((project) => project.id === "nutriscan");
|
||||
if (!nutriscan) throw new Error("projeto ausente");
|
||||
|
||||
type Callback = IntersectionObserverCallback;
|
||||
const callbacks: Callback[] = [];
|
||||
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(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
callbacks.length = 0;
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback: Callback) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
global.IntersectionObserver = Original;
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function renderCard() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<article className="ds-card">
|
||||
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
|
||||
</article>
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
const frame = view.container.querySelector(".ds-frame") as HTMLElement;
|
||||
frame.getBoundingClientRect = () =>
|
||||
({
|
||||
top: 300,
|
||||
height: 300,
|
||||
bottom: 600,
|
||||
left: 0,
|
||||
right: 400,
|
||||
width: 400,
|
||||
x: 0,
|
||||
y: 300,
|
||||
}) as DOMRect;
|
||||
return { ...view, frame };
|
||||
}
|
||||
|
||||
function show(frame: HTMLElement) {
|
||||
act(() => {
|
||||
for (const callback of callbacks)
|
||||
callback(
|
||||
[
|
||||
{
|
||||
target: frame,
|
||||
intersectionRatio: 1,
|
||||
isIntersecting: true,
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProjectMedia", () => {
|
||||
it("frames the video with the project's address and a button that enlarges it", () => {
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByText("nutriscan.evandro.dev.br")).toBeInTheDocument();
|
||||
const expand = screen.getByRole("button", {
|
||||
name: "Ampliar o vídeo de NutriScan",
|
||||
});
|
||||
expect(expand).toHaveAttribute("aria-haspopup", "dialog");
|
||||
fireEvent.click(expand);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "ampliado" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plays only while the card is the active one", () => {
|
||||
const { frame, container } = renderCard();
|
||||
const video = container.querySelector("video") as HTMLVideoElement;
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"false",
|
||||
);
|
||||
expect(video.querySelectorAll("source")).toHaveLength(0);
|
||||
|
||||
show(frame);
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
expect(video.querySelectorAll("source")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("lets the visitor pause the videos that play on their own, and resume them", () => {
|
||||
const { frame, container } = renderCard();
|
||||
show(frame);
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Pausar os vídeos dos projetos" }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Retomar os vídeos dos projetos" }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("has no pause button when nothing plays on its own (reduced motion)", () => {
|
||||
reducedMotion = true;
|
||||
renderCard();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /vídeos dos projetos/ }),
|
||||
).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", () => {
|
||||
const { container } = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectMedia project={{ id: "twins-lab", title: "Twins Lab" }} />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(container.querySelector(".ds-frame")).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing, without crashing, when used outside the provider", () => {
|
||||
const { container } = render(
|
||||
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Ampliar o vídeo de NutriScan" }),
|
||||
);
|
||||
expect(container.querySelector(".ds-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { getProjectVideo, getShowcaseConfig } from "@/lib/project-showcase";
|
||||
import { ProjectVideo } from "../ProjectVideo";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
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
|
||||
* 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({
|
||||
project,
|
||||
}: {
|
||||
project: {
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
};
|
||||
}) {
|
||||
const { frameRef, active, open, userPaused, toggleAutoplay } =
|
||||
useShowcaseCard(project.id);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const device = useViewportDevice();
|
||||
const near = useNearViewport(frameRef);
|
||||
const video = device ? getProjectVideo(project.id, device) : undefined;
|
||||
const config = getShowcaseConfig(project.id);
|
||||
if (!getProjectVideo(project.id) || !config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frameRef}
|
||||
className="ds-frame"
|
||||
data-active={active}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)}>
|
||||
<div
|
||||
className="ds-frame-screen"
|
||||
style={
|
||||
video && near
|
||||
? { backgroundImage: `url(${video.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{video && (
|
||||
<ProjectVideo
|
||||
video={video}
|
||||
title={project.title}
|
||||
active={active}
|
||||
showPoster={near}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ds-frame-expand"
|
||||
onClick={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`Ampliar o vídeo de ${project.title}`}
|
||||
>
|
||||
<span className="ds-frame-chip" aria-hidden="true">
|
||||
Ampliar
|
||||
</span>
|
||||
</button>
|
||||
{!reducedMotion && (
|
||||
<button
|
||||
type="button"
|
||||
className="ds-frame-pause"
|
||||
onClick={toggleAutoplay}
|
||||
aria-label={
|
||||
userPaused
|
||||
? "Retomar os vídeos dos projetos"
|
||||
: "Pausar os vídeos dos projetos"
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
{userPaused ? (
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
) : (
|
||||
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -239,6 +239,7 @@ const listEl = () =>
|
||||
screen.queryByRole("region", { name: "Projetos de clientes" });
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
|
||||
|
||||
describe("ProjectReel: qual versão entra", () => {
|
||||
it("path: no projects in the showcase -> renders nothing at all", () => {
|
||||
@@ -354,7 +355,10 @@ describe("ProjectReel: measure()", () => {
|
||||
|
||||
scrollTo(0.9);
|
||||
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
expect(activeLayer().querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("path: scrolled to a later project -> that project becomes the active layer", () => {
|
||||
@@ -586,6 +590,70 @@ describe("ProjectReel: vídeos", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () => {
|
||||
const states = (selector: string, attribute = "data-state") =>
|
||||
[...document.querySelectorAll(selector)].map((element) =>
|
||||
element.getAttribute(attribute),
|
||||
);
|
||||
|
||||
it("path: a middle project in scene -> layers before it are 'before', the ones after are 'after'", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 2, 0.5));
|
||||
|
||||
const layers = states(".ds-reel-layer");
|
||||
expect(layers.length).toBeGreaterThan(3);
|
||||
expect(layers).toEqual(
|
||||
layers.map((_, i) => (i < 2 ? "before" : i === 2 ? "active" : "after")),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: first project, first chapter -> the ticks read now, todo, todo", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(0);
|
||||
|
||||
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo", "todo"]);
|
||||
expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent(
|
||||
"01/03",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: end of the project -> the chapter ticks before the current one are 'done', the ones after 'todo'", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 0, 0.95));
|
||||
|
||||
const ticks = states(".ds-reel-ticks li");
|
||||
const current = ticks.indexOf("now");
|
||||
expect(current).toBeGreaterThan(0);
|
||||
expect(ticks).toEqual(
|
||||
ticks.map((_, i) =>
|
||||
i < current ? "done" : i === current ? "now" : "todo",
|
||||
),
|
||||
);
|
||||
expect(ticks.filter((state) => state === "done")).toHaveLength(current);
|
||||
});
|
||||
|
||||
it("path: project with a subtitle -> shown under the title", () => {
|
||||
expect(projects[0].subtitle).toBeTruthy();
|
||||
renderReel();
|
||||
|
||||
expect(caption().querySelector(".ds-reel-sub")).toHaveTextContent(
|
||||
projects[0].subtitle,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: project without a subtitle -> no empty subtitle line in the caption", () => {
|
||||
renderReel([{ ...projects[0], subtitle: "" }, ...projects.slice(1)]);
|
||||
|
||||
expect(caption().querySelector(".ds-reel-sub")).toBeNull();
|
||||
expect(caption().querySelector(".ds-reel-title")).toHaveTextContent(
|
||||
projects[0].title,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: rolagem suave até um projeto", () => {
|
||||
function pressProject(name: string) {
|
||||
fireEvent.click(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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, mobileFractionAt } from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { getShowcaseConfig } from "@/lib/project-showcase";
|
||||
import { CAPTION_OUT_MS, ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { reelOffset, videoProgress } from "./showcase-math";
|
||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||
@@ -169,6 +171,10 @@ function scrollTo(progress: number) {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
// a legenda leva CAPTION_OUT_MS para trocar de projeto
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 20);
|
||||
});
|
||||
}
|
||||
|
||||
const sources = () =>
|
||||
@@ -179,6 +185,9 @@ const sources = () =>
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
/** A legenda do projeto em cena: um bloco só, fora das camadas. */
|
||||
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
|
||||
|
||||
describe("ProjectReel", () => {
|
||||
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
@@ -244,7 +253,7 @@ describe("ProjectReel", () => {
|
||||
expect(
|
||||
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
|
||||
@@ -258,7 +267,7 @@ describe("ProjectReel", () => {
|
||||
expect(layers[0]).not.toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
|
||||
it("moves to the next project and syncs the laptop video to the scroll; the phone is just a poster", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
@@ -267,18 +276,20 @@ describe("ProjectReel", () => {
|
||||
scrollTo(t);
|
||||
|
||||
const layer = activeLayer();
|
||||
expect(layer).toHaveTextContent("Lavação 047");
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
expect(layer.querySelector(".ds-reel-title")).toBeNull();
|
||||
expect(caption()).toHaveTextContent("Lavação 047");
|
||||
const p = videoProgress(0.5);
|
||||
const desktop = layer.querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
const phone = layer.querySelector(
|
||||
".ds-phone-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
|
||||
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
|
||||
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
|
||||
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
|
||||
expect(layer.querySelector(".ds-phone video")).toBeNull();
|
||||
expect(
|
||||
(layer.querySelector(".ds-phone-screen") as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("lavacao-047/mobile.webp");
|
||||
});
|
||||
|
||||
it("names the chapter on screen and counts them", () => {
|
||||
@@ -286,15 +297,14 @@ describe("ProjectReel", () => {
|
||||
readyVideos();
|
||||
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
|
||||
|
||||
const layer = activeLayer();
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
||||
/^0\d\/03$/,
|
||||
);
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).toMatch(/^0\d\/03$/);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
layer.querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
caption().querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -309,7 +319,7 @@ describe("ProjectReel", () => {
|
||||
expect(first.currentTime).toBe(0);
|
||||
|
||||
scrollTo(1);
|
||||
expect(activeLayer()).toHaveTextContent("AS Serviços");
|
||||
expect(caption()).toHaveTextContent("AS Serviços");
|
||||
const last = activeLayer().querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
@@ -336,9 +346,8 @@ describe("ProjectReel", () => {
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
|
||||
).toBe(true);
|
||||
// o celular da moldura é só pôster: o vídeo de celular nunca é baixado na vitrine fixa
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile"))).toBe(false);
|
||||
// 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);
|
||||
@@ -378,7 +387,7 @@ describe("ProjectReel", () => {
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not download the phone video while the phone frame is not on screen", () => {
|
||||
it("never downloads the phone video, whether or not the phone frame is on screen (it is only a poster)", () => {
|
||||
phoneWidth = 0;
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
@@ -487,8 +496,7 @@ describe("ProjectReel", () => {
|
||||
|
||||
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;
|
||||
const actions = caption().querySelector(".ds-reel-actions") as HTMLElement;
|
||||
expect(
|
||||
within(actions).getByRole("link", { name: /Ver case/ }),
|
||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||
@@ -502,20 +510,17 @@ describe("ProjectReel", () => {
|
||||
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();
|
||||
scrollTo(1);
|
||||
expect(caption()).toHaveTextContent("AS Serviços");
|
||||
expect(
|
||||
within(asServicos as HTMLElement).queryByRole("link", {
|
||||
name: /Abrir site/,
|
||||
hidden: true,
|
||||
}),
|
||||
within(caption()).queryByRole("link", { name: /Abrir site/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the enlarged video from the pinned scene", () => {
|
||||
renderReel();
|
||||
fireEvent.click(
|
||||
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
within(caption()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
@@ -551,3 +556,156 @@ describe("ProjectReel", () => {
|
||||
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
||||
/** Move a rolagem para o meio do trecho do projeto e deixa passar só `ms`. */
|
||||
function moveTo(index: number, ms: number) {
|
||||
reelTop = HEADER - reelOffset(weights, index, 0.5) * SCROLLABLE;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
}
|
||||
|
||||
const titles = () =>
|
||||
[...document.querySelectorAll(".ds-reel-title")].map(
|
||||
(title) => title.textContent,
|
||||
);
|
||||
|
||||
it("has one caption for the whole pinned scene: title, counter and buttons exist once, outside the layers", () => {
|
||||
renderReel();
|
||||
|
||||
expect(document.querySelectorAll(".ds-reel-caption")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-title")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-chapter-count")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-layer")).toHaveLength(5);
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
|
||||
expect(layer.closest(".ds-reel-caption")).toBeNull();
|
||||
expect(layer.querySelector(".ds-reel-caption")).toBeNull();
|
||||
expect(layer.querySelector("h3, button, a")).toBeNull();
|
||||
}
|
||||
// os aparelhos e a legenda dividem o mesmo bloco, a legenda abaixo
|
||||
const set = document.querySelector(".ds-reel-set") as HTMLElement;
|
||||
expect(set.querySelector(".ds-reel-devices")).toBeInTheDocument();
|
||||
expect(set.lastElementChild).toBe(caption());
|
||||
});
|
||||
|
||||
it("each layer holds only the devices: the laptop with its video and the phone", () => {
|
||||
renderReel();
|
||||
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
|
||||
expect(layer.querySelectorAll(".ds-bframe")).toHaveLength(1);
|
||||
expect(layer.querySelectorAll(".ds-phone")).toHaveLength(1);
|
||||
expect(layer.querySelectorAll(".ds-reel-screen video")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("fades the old caption out first, with its old content and no second caption, then brings the new one in", () => {
|
||||
renderReel();
|
||||
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
|
||||
// o projeto muda; só uns milissegundos depois (a legenda ainda está saindo)
|
||||
moveTo(1, 60);
|
||||
expect(activeLayer()).toHaveAttribute("data-state", "active");
|
||||
expect(activeLayer().querySelector(".ds-bframe-url")?.textContent).not.toBe(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
expect(caption()).toHaveAttribute("data-leaving");
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
|
||||
|
||||
// passou o tempo de saída: o conteúdo novo entra, e a legenda volta
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||
});
|
||||
expect(titles()).toEqual(["Lavação 047"]);
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
});
|
||||
|
||||
it("leaves in 120 ms: before that the old caption is still there, right after it the new one", () => {
|
||||
expect(CAPTION_OUT_MS).toBe(120);
|
||||
renderReel();
|
||||
moveTo(1, 20);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS - 30);
|
||||
});
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60);
|
||||
});
|
||||
expect(titles()).toEqual(["Lavação 047"]);
|
||||
});
|
||||
|
||||
it("at no moment of the change are two project titles (or two sets of buttons) on screen", () => {
|
||||
renderReel();
|
||||
const seen: number[] = [];
|
||||
for (const [index, ms] of [
|
||||
[1, 16],
|
||||
[1, 50],
|
||||
[1, 80],
|
||||
[2, 16],
|
||||
[2, 100],
|
||||
[2, 200],
|
||||
[3, 10],
|
||||
[0, 10],
|
||||
[0, 300],
|
||||
] as const) {
|
||||
moveTo(index, ms);
|
||||
seen.push(
|
||||
document.querySelectorAll(".ds-reel-title").length,
|
||||
document.querySelectorAll(".ds-reel-actions").length,
|
||||
document.querySelectorAll(".ds-reel-chapter-count").length,
|
||||
);
|
||||
}
|
||||
|
||||
expect(new Set(seen)).toEqual(new Set([1]));
|
||||
});
|
||||
|
||||
it("keeps coming back to the project in scene when the scroll goes back before the caption finished leaving", () => {
|
||||
renderReel();
|
||||
|
||||
moveTo(1, 40);
|
||||
moveTo(0, 40);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
});
|
||||
|
||||
it("changes the chapter inside the same project in place, without fading the caption", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
scrollTo(reelOffset(weights, 0, 0.9)); // fim do primeiro projeto: outro trecho
|
||||
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).not.toBe("01/03");
|
||||
});
|
||||
|
||||
it("never moves the caption text: no inline transform, and the CSS only fades it", () => {
|
||||
renderReel();
|
||||
moveTo(1, 60);
|
||||
|
||||
expect(caption().getAttribute("style")).toBeNull();
|
||||
for (const element of caption().querySelectorAll("*"))
|
||||
expect((element as HTMLElement).style.transform).toBe("");
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, "showcase.css"), "utf8");
|
||||
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(rule).toContain("opacity");
|
||||
expect(rule).not.toMatch(/transform|translate/);
|
||||
expect(rule).toContain("0.12s");
|
||||
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(layer).toContain("opacity");
|
||||
expect(layer).not.toMatch(/transform|translate/);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
chapterIndexAt,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
@@ -54,6 +54,9 @@ const headerHeight = () => {
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/** Saída da legenda (ms) antes de a do novo projeto entrar: curta, para a troca nunca mostrar duas legendas. */
|
||||
export const CAPTION_OUT_MS = 120;
|
||||
|
||||
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
|
||||
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||
if (!video || video.readyState < 1 || !video.duration) return;
|
||||
@@ -147,15 +150,49 @@ export function ProjectReel() {
|
||||
);
|
||||
}
|
||||
|
||||
const layerState = (index: number, active: number) => {
|
||||
if (index === active) return "active";
|
||||
return index < active ? "before" : "after";
|
||||
};
|
||||
|
||||
/** Estado do traço de capítulo: ainda por vir, o de agora ou já visto. */
|
||||
const tickState = (tick: number, current: number) => {
|
||||
if (tick > current) return "todo";
|
||||
return tick === current ? "now" : "done";
|
||||
};
|
||||
|
||||
/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */
|
||||
function targetsAt(count: number, index: number, local: number) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
if (i < index) return 1;
|
||||
return i === index ? videoProgress(local) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Onde a rolagem está na vitrine: projeto em cena, fração dele e se ela está a ~1 tela da janela. Só lê o DOM. */
|
||||
function readReel(container: HTMLElement, pin: HTMLElement, weights: number[]) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollable = rect.height - pin.offsetHeight;
|
||||
const progress =
|
||||
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
|
||||
const { index, local } = locateInReel(weights, progress);
|
||||
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
|
||||
const near =
|
||||
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
||||
return { index, local, near };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; o celular é só um
|
||||
* pôster da mesma tela (o vídeo de celular é do celular: aqui ele nem é baixado).
|
||||
*
|
||||
* Os aparelhos de cada projeto são uma camada (as camadas trocam por fade cruzado); a legenda (nome, trecho,
|
||||
* contador e botões) é UM bloco só, fora das camadas, que sai e entra sem nunca existir em dobro.
|
||||
*
|
||||
* 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.
|
||||
* da janela; com "economizar dados" ninguém baixa vídeo.
|
||||
*/
|
||||
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
@@ -163,14 +200,12 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const [chapter, setChapter] = useState(0);
|
||||
const [near, setNear] = useState(false);
|
||||
const [settled, setSettled] = useState(0);
|
||||
const [phoneVisible, setPhoneVisible] = useState(true);
|
||||
const saveData = useSaveData();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
const indexRef = useRef<HTMLOListElement>(null);
|
||||
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
const phoneVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
const bars = useRef<Array<HTMLElement | null>>([]);
|
||||
const live = useRef({
|
||||
target: [] as number[],
|
||||
@@ -180,7 +215,6 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
raf: 0,
|
||||
last: 0,
|
||||
near: false,
|
||||
phone: true,
|
||||
});
|
||||
|
||||
const weights = useMemo(
|
||||
@@ -224,12 +258,9 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
useEffect(() => {
|
||||
items.forEach((_, index) => {
|
||||
if (isAttached(index)) return;
|
||||
for (const video of [
|
||||
desktopVideos.current[index],
|
||||
phoneVideos.current[index],
|
||||
])
|
||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||
video.load();
|
||||
const video = desktopVideos.current[index];
|
||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||
video.load();
|
||||
});
|
||||
}, [items, isAttached]);
|
||||
|
||||
@@ -239,19 +270,14 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
||||
state.last = now;
|
||||
let moving = false;
|
||||
items.forEach((item, index) => {
|
||||
items.forEach((_, index) => {
|
||||
if (Math.abs(index - state.active) > 1) return;
|
||||
const before = state.smooth[index] ?? 0;
|
||||
const next = easeToward(before, state.target[index] ?? 0, seconds);
|
||||
const target = state.target[index] ?? 0;
|
||||
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
||||
state.smooth[index] = next;
|
||||
if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true;
|
||||
if (Math.abs(next - target) > 0.0004) moving = true;
|
||||
seekVideo(desktopVideos.current[index], next);
|
||||
seekVideo(
|
||||
phoneVideos.current[index],
|
||||
mobileFractionAt(item.config, next),
|
||||
);
|
||||
const bar = bars.current[index];
|
||||
if (bar) bar.style.setProperty("--p", next.toFixed(4));
|
||||
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
||||
});
|
||||
const current = items[state.active];
|
||||
if (current) {
|
||||
@@ -270,16 +296,25 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
);
|
||||
|
||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||
const syncVideos = useCallback(
|
||||
const syncVideo = useCallback((index: number) => {
|
||||
const state = live.current;
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
state.smooth[index] ?? state.target[index] ?? 0,
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
|
||||
const changeActive = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
const at = live.current.smooth[index] ?? live.current.target[index] ?? 0;
|
||||
seekVideo(desktopVideos.current[index], at);
|
||||
if (item)
|
||||
seekVideo(
|
||||
phoneVideos.current[index],
|
||||
mobileFractionAt(item.config, at),
|
||||
);
|
||||
const state = live.current;
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
setActive(index);
|
||||
setChapter(state.chapter);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
@@ -288,46 +323,20 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const container = containerRef.current;
|
||||
const pin = pinRef.current;
|
||||
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
|
||||
if (!container || !pin || items.length === 0 || !container.offsetHeight)
|
||||
return;
|
||||
if (!container?.offsetHeight || !pin) return;
|
||||
const state = live.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollable = rect.height - pin.offsetHeight;
|
||||
const progress =
|
||||
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
|
||||
const { index, local } = locateInReel(weights, progress);
|
||||
state.target = items.map((_, i) =>
|
||||
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 =
|
||||
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
||||
if (near !== state.near) {
|
||||
state.near = 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) {
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
setActive(index);
|
||||
setChapter(state.chapter);
|
||||
const { index, local, near: isNear } = readReel(container, pin, weights);
|
||||
state.target = targetsAt(items.length, index, local);
|
||||
if (isNear !== state.near) {
|
||||
state.near = isNear;
|
||||
setNear(isNear);
|
||||
}
|
||||
if (index !== state.active) changeActive(index);
|
||||
if (state.near && !state.raf) {
|
||||
state.last = 0;
|
||||
state.raf = requestAnimationFrame(frame);
|
||||
}
|
||||
}, [frame, items, weights]);
|
||||
}, [changeActive, frame, items.length, weights]);
|
||||
|
||||
useEffect(() => {
|
||||
let pending = 0;
|
||||
@@ -386,187 +395,41 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
<h2 className="sr-only">Projetos de clientes</h2>
|
||||
<div className="ds-reel-pin" ref={pinRef}>
|
||||
<div className="ds-reel-grid">
|
||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||
<p className="ds-reel-hint">
|
||||
<span aria-hidden="true" className="ds-reel-hint-line" />
|
||||
Em movimento
|
||||
<span className="ds-reel-hint-sub">Role para avançar</span>
|
||||
</p>
|
||||
<ol ref={indexRef}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item.config.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-label={item.project.title}
|
||||
onClick={() => goTo(index)}
|
||||
>
|
||||
{/* 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) */}
|
||||
<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>
|
||||
<i
|
||||
className="ds-reel-bar"
|
||||
aria-hidden="true"
|
||||
ref={(element) => {
|
||||
bars.current[index] = element;
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{index === active && (
|
||||
<ol
|
||||
className="ds-reel-chapters"
|
||||
aria-label={`Trechos de ${item.project.title}`}
|
||||
>
|
||||
{item.config.chapters.map((entry, i) => (
|
||||
<li key={entry.label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={i === chapter ? "true" : undefined}
|
||||
onClick={() => goToChapter(index, i)}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<ReelIndex
|
||||
items={items}
|
||||
active={active}
|
||||
chapter={chapter}
|
||||
listRef={indexRef}
|
||||
bars={bars}
|
||||
onProject={goTo}
|
||||
onChapter={goToChapter}
|
||||
/>
|
||||
|
||||
<div className="ds-reel-stage">
|
||||
{items.map((item, index) => {
|
||||
const { config, project } = item;
|
||||
const isActive = index === active;
|
||||
const attach = isAttached(index);
|
||||
const posters = showsPoster(index);
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className="ds-reel-layer"
|
||||
data-state={
|
||||
isActive ? "active" : index < active ? "before" : "after"
|
||||
}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
aria-hidden={isActive ? undefined : true}
|
||||
inert={!isActive}
|
||||
>
|
||||
<div className="ds-reel-set">
|
||||
<div className="ds-reel-devices">
|
||||
<BrowserFrame
|
||||
url={displayUrl(project)}
|
||||
className="ds-reel-laptop"
|
||||
>
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
posters
|
||||
? { backgroundImage: `url(${desktop.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach
|
||||
? getScrubSource(config.id, "desktop")
|
||||
: undefined
|
||||
}
|
||||
poster={posters ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={() => syncVideos(index)}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={posters ? phone.poster : undefined}
|
||||
>
|
||||
<video
|
||||
ref={(element) => {
|
||||
phoneVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach && phoneVisible
|
||||
? getScrubSource(config.id, "mobile")
|
||||
: undefined
|
||||
}
|
||||
poster={posters ? phone.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={() => syncVideos(index)}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
<div className="ds-reel-set">
|
||||
<div className="ds-reel-devices">
|
||||
{items.map((item, index) => (
|
||||
<ReelLayer
|
||||
key={item.config.id}
|
||||
item={item}
|
||||
state={layerState(index, active)}
|
||||
poster={showsPoster(index)}
|
||||
attached={isAttached(index)}
|
||||
videoRef={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
onVideoLoaded={() => syncVideo(index)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
<div className="ds-reel-chapter">
|
||||
<span className="ds-reel-chapter-count">
|
||||
{String((isActive ? chapter : 0) + 1).padStart(
|
||||
2,
|
||||
"0",
|
||||
)}
|
||||
/{String(config.chapters.length).padStart(2, "0")}
|
||||
</span>
|
||||
<strong>
|
||||
{config.chapters[isActive ? chapter : 0].label}
|
||||
</strong>
|
||||
<ol className="ds-reel-ticks" aria-hidden="true">
|
||||
{config.chapters.map((entry, tick) => (
|
||||
<li
|
||||
key={entry.label}
|
||||
data-state={
|
||||
!isActive || tick > chapter
|
||||
? "todo"
|
||||
: tick === chapter
|
||||
? "now"
|
||||
: "done"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions
|
||||
item={item}
|
||||
onExpand={() => openProject(config.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ReelCaption
|
||||
items={items}
|
||||
active={active}
|
||||
chapter={chapter}
|
||||
onExpand={openProject}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
@@ -578,3 +441,217 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. */
|
||||
function ReelIndex({
|
||||
items,
|
||||
active,
|
||||
chapter,
|
||||
listRef,
|
||||
bars,
|
||||
onProject,
|
||||
onChapter,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
active: number;
|
||||
chapter: number;
|
||||
listRef: RefObject<HTMLOListElement | null>;
|
||||
bars: RefObject<Array<HTMLElement | null>>;
|
||||
onProject: (index: number) => void;
|
||||
onChapter: (index: number, chapter: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||
<p className="ds-reel-hint">
|
||||
<span aria-hidden="true" className="ds-reel-hint-line" />
|
||||
Em movimento
|
||||
<span className="ds-reel-hint-sub">Role para avançar</span>
|
||||
</p>
|
||||
<ol ref={listRef}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item.config.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-reel-pick"
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-label={item.project.title}
|
||||
onClick={() => onProject(index)}
|
||||
>
|
||||
{/* 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) */}
|
||||
<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>
|
||||
<i
|
||||
className="ds-reel-bar"
|
||||
aria-hidden="true"
|
||||
ref={(element) => {
|
||||
bars.current[index] = element;
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{index === active && (
|
||||
<ol
|
||||
className="ds-reel-chapters"
|
||||
aria-label={`Trechos de ${item.project.title}`}
|
||||
>
|
||||
{item.config.chapters.map((entry, i) => (
|
||||
<li key={entry.label}>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-reel-chapter-pick"
|
||||
aria-current={i === chapter ? "true" : undefined}
|
||||
onClick={() => onChapter(index, i)}
|
||||
>
|
||||
<span className="ds-reel-chapter-num" aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Os aparelhos de um projeto: notebook com o vídeo que acompanha a rolagem e celular só com o pôster. Camadas
|
||||
* empilhadas na mesma célula; quem não está em cena fica transparente, inerte e escondido dos leitores de tela.
|
||||
*/
|
||||
function ReelLayer({
|
||||
item,
|
||||
state,
|
||||
poster,
|
||||
attached,
|
||||
videoRef,
|
||||
onVideoLoaded,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
state: "active" | "before" | "after";
|
||||
/** O pôster já pode ser pedido (a vitrine está perto e este projeto é o da vez ou um vizinho). */
|
||||
poster: boolean;
|
||||
/** O vídeo de rolagem já pode ser baixado. */
|
||||
attached: boolean;
|
||||
videoRef: (element: HTMLVideoElement | null) => void;
|
||||
onVideoLoaded: () => void;
|
||||
}) {
|
||||
const { config, project } = item;
|
||||
const isActive = state === "active";
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
<div
|
||||
className="ds-reel-layer"
|
||||
data-state={state}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
aria-hidden={isActive ? undefined : true}
|
||||
inert={!isActive}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attached ? getScrubSource(config.id) : undefined}
|
||||
poster={poster ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={onVideoLoaded}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={poster ? phone.poster : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Legenda do projeto em cena: um bloco só, fora das camadas. Na troca de projeto a legenda atual some em
|
||||
* CAPTION_OUT_MS e só então o conteúdo muda e ela volta (fade, sem deslocamento): nunca há título, contador ou
|
||||
* botões em dobro. Dentro do mesmo projeto, o trecho troca no lugar.
|
||||
*/
|
||||
function ReelCaption({
|
||||
items,
|
||||
active,
|
||||
chapter,
|
||||
onExpand,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
active: number;
|
||||
chapter: number;
|
||||
onExpand: (id: string) => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState({ index: active, chapter });
|
||||
const leaving = shown.index !== active;
|
||||
// o que vale quando a saída termina: o trecho continua mudando durante os 120 ms, e isso não reinicia a espera
|
||||
const latest = useRef({ active, chapter });
|
||||
useEffect(() => {
|
||||
latest.current = { active, chapter };
|
||||
});
|
||||
|
||||
// dentro do mesmo projeto o trecho troca no lugar
|
||||
useEffect(() => {
|
||||
if (shown.index === active && shown.chapter !== chapter)
|
||||
setShown({ index: active, chapter });
|
||||
}, [active, chapter, shown]);
|
||||
|
||||
// projeto novo: a legenda sai e, passado CAPTION_OUT_MS, o conteúdo muda
|
||||
useEffect(() => {
|
||||
if (shown.index === active) return;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
setShown({
|
||||
index: latest.current.active,
|
||||
chapter: latest.current.chapter,
|
||||
}),
|
||||
CAPTION_OUT_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shown.index]);
|
||||
|
||||
const item = items[shown.index];
|
||||
const { config, project } = item;
|
||||
return (
|
||||
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
|
||||
<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>}
|
||||
<div className="ds-reel-chapter">
|
||||
<span className="ds-reel-chapter-count">
|
||||
{pad(shown.chapter + 1)}/{pad(config.chapters.length)}
|
||||
</span>
|
||||
<strong>{config.chapters[shown.chapter].label}</strong>
|
||||
<ol className="ds-reel-ticks" aria-hidden="true">
|
||||
{config.chapters.map((entry, tick) => (
|
||||
<li key={entry.label} data-state={tickState(tick, shown.chapter)} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,557 +1,166 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
type ShowcaseItem,
|
||||
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.
|
||||
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx). O provedor só guarda a
|
||||
* lista da vitrine e qual projeto está ampliado: aqui, a janela aberta quando a lista muda, a troca de projeto
|
||||
* com a janela já aberta e a estabilidade do que ele entrega (lista e função de abrir) entre renderizações.
|
||||
* A janela ampliada é um dublê que só mostra o índice.
|
||||
*/
|
||||
|
||||
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);
|
||||
const clientIds = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||
const clients = projects.filter((project) => clientIds.includes(project.id));
|
||||
|
||||
function Cards({ ids }: { ids: string[] }) {
|
||||
function Opener({ id }: { id: string }) {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<ShowcaseProvider projects={projects}>
|
||||
{ids.map((id) => (
|
||||
<Card key={id} id={id} />
|
||||
))}
|
||||
</ShowcaseProvider>
|
||||
<button type="button" onClick={() => open(id)}>
|
||||
abrir {id}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
describe("ShowcaseProvider: janela ampliada e mudança da lista", () => {
|
||||
it("path: window open and its project leaves the list -> the window goes away", () => {
|
||||
const { rerender } = render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="sc-plus" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir sc-plus" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:2 de 4");
|
||||
|
||||
rerender(
|
||||
<ShowcaseProvider
|
||||
projects={clients.filter((project) => project.id !== "sc-plus")}
|
||||
>
|
||||
<Opener id="sc-plus" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: window open and another project is opened -> it moves to the new project, one window only", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="sev-modaintima" />
|
||||
<Opener id="as-servicos" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "abrir sev-modaintima" }),
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0 de 4");
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir as-servicos" }));
|
||||
|
||||
expect(screen.getAllByRole("dialog")).toHaveLength(1);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:3 de 4");
|
||||
});
|
||||
|
||||
it("path: a project outside the showcase is asked while another window is open -> no window is left", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Opener id="sev-modaintima" />
|
||||
<Opener id="reddit-explorer" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "abrir sev-modaintima" }),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "abrir reddit-explorer" }),
|
||||
);
|
||||
|
||||
// o id desconhecido não acha posição na lista: a janela não mostra projeto nenhum
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: closed window -> reopening the same project works again", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="lavacao-047" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir lavacao-047" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir lavacao-047" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1 de 4");
|
||||
});
|
||||
});
|
||||
|
||||
type Captured = {
|
||||
items: ShowcaseItem[];
|
||||
open: (id: string) => void;
|
||||
};
|
||||
|
||||
function Probe({ onRender }: { onRender: (captured: Captured) => void }) {
|
||||
onRender({ items: useShowcaseItems(), open: useShowcaseOpen() });
|
||||
return null;
|
||||
}
|
||||
|
||||
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" },
|
||||
describe("ShowcaseProvider: o que entrega a quem está dentro", () => {
|
||||
it("path: re-render with the same projects -> the same list and the same open function", () => {
|
||||
const seen: Captured[] = [];
|
||||
const tree = () => (
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Probe onRender={(captured) => seen.push(captured)} />
|
||||
</ShowcaseProvider>
|
||||
);
|
||||
flush();
|
||||
const { rerender } = render(tree());
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
rerender(tree());
|
||||
|
||||
expect(seen.length).toBeGreaterThan(1);
|
||||
expect(seen[seen.length - 1].items).toBe(seen[0].items);
|
||||
expect(seen[seen.length - 1].open).toBe(seen[0].open);
|
||||
});
|
||||
|
||||
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" },
|
||||
it("path: a different list of projects -> a new list of items", () => {
|
||||
const seen: Captured[] = [];
|
||||
const probe = <Probe onRender={(captured) => seen.push(captured)} />;
|
||||
const { rerender } = render(
|
||||
<ShowcaseProvider projects={clients}>{probe}</ShowcaseProvider>,
|
||||
);
|
||||
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 />
|
||||
rerender(
|
||||
<ShowcaseProvider projects={clients.slice(0, 2)}>
|
||||
{probe}
|
||||
</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([]);
|
||||
expect(seen[0].items).toHaveLength(4);
|
||||
expect(seen[seen.length - 1].items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +1,52 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
ShowcaseProvider,
|
||||
useShowcaseCard,
|
||||
useShowcaseItems,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
/** A janela ampliada é testada em ProjectLightbox.test.tsx; aqui ela é um dublê que só mostra o índice e navega. */
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: ({
|
||||
index,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="ampliado">
|
||||
aberto:{index}
|
||||
<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 setVisibility(element: Element, ratio: number) {
|
||||
for (const observer of observers) {
|
||||
if (!observer.targets.has(element)) continue;
|
||||
act(() => {
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target: element,
|
||||
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 } = useShowcaseCard(id);
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} data-testid={id} data-active={active} />
|
||||
<button type="button" onClick={open}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const clientIds = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||
const clients = projects.filter((project) => clientIds.includes(project.id));
|
||||
|
||||
function setup() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="sev-modaintima" />
|
||||
<Card id="lavacao-047" />
|
||||
<Card id="nutriscan" />
|
||||
</ShowcaseProvider>,
|
||||
function Opener({ id }: { id: string }) {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open(id)}>
|
||||
abrir {id}
|
||||
</button>
|
||||
);
|
||||
const frames = {
|
||||
sev: screen.getByTestId("sev-modaintima"),
|
||||
lavacao: screen.getByTestId("lavacao-047"),
|
||||
nutri: screen.getByTestId("nutriscan"),
|
||||
};
|
||||
// tela de 768px de altura (jsdom): o meio fica em 384
|
||||
place(frames.sev, 100); // centro 250
|
||||
place(frames.lavacao, 230); // centro 380
|
||||
place(frames.nutri, 900); // fora da tela
|
||||
return { view, frames };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
observers = [];
|
||||
global.IntersectionObserver =
|
||||
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
});
|
||||
|
||||
const isActive = (element: Element) =>
|
||||
element.getAttribute("data-active") === "true";
|
||||
|
||||
describe("ShowcaseProvider", () => {
|
||||
describe("buildShowcaseItems", () => {
|
||||
it("lists only projects that have a recorded showcase, in showcase order", () => {
|
||||
const items = buildShowcaseItems(projects);
|
||||
expect(items.map((item) => item.config.id)).toEqual([
|
||||
@@ -159,11 +62,6 @@ describe("ShowcaseProvider", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -172,160 +70,99 @@ describe("ShowcaseProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
it("ignores projects missing from the list and builds nothing from an empty one", () => {
|
||||
const only = projects.filter((project) =>
|
||||
["as-servicos", "sev-modaintima"].includes(project.id),
|
||||
);
|
||||
function Opener() {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open("sc-plus")}>
|
||||
abrir sc-plus
|
||||
</button>
|
||||
);
|
||||
|
||||
expect(buildShowcaseItems(only).map((item) => item.config.id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(buildShowcaseItems([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider", () => {
|
||||
it("shares the same list of items with whoever is inside it", () => {
|
||||
function Count() {
|
||||
return <p>itens:{useShowcaseItems().length}</p>;
|
||||
}
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener />
|
||||
<Count />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
frames.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
flush();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByText("itens:4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the enlarged window on the project's position in the list, and closes it", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="sc-plus" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
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", () => {
|
||||
const { frames } = setup();
|
||||
flush();
|
||||
expect(Object.values(frames).some(isActive)).toBe(false);
|
||||
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
expect(isActive(frames.sev)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count a card with less than a third on screen", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.2);
|
||||
setVisibility(frames.lavacao, 0.1);
|
||||
flush();
|
||||
expect(Object.values(frames).some(isActive)).toBe(false);
|
||||
});
|
||||
|
||||
it("hands the video to the card under the mouse and takes it back when the mouse leaves", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
|
||||
const sevCard = frames.sev.closest(".ds-card") as HTMLElement;
|
||||
fireEvent.pointerEnter(sevCard, { pointerType: "mouse" });
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(true);
|
||||
expect(isActive(frames.lavacao)).toBe(false);
|
||||
|
||||
fireEvent.pointerLeave(sevCard, { pointerType: "mouse" });
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores touch pointers: there is no hover on a phone", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
|
||||
fireEvent.pointerEnter(frames.sev.closest(".ds-card") as HTMLElement, {
|
||||
pointerType: "touch",
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(false);
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
});
|
||||
|
||||
it("follows keyboard focus inside a card", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
|
||||
fireEvent.focusIn(
|
||||
screen.getByRole("button", { name: "ampliar sev-modaintima" }),
|
||||
it("does nothing when asked to open a project that is not in the showcase", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Opener id="reddit-explorer" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-evaluates on scroll", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
|
||||
place(frames.sev, 230);
|
||||
place(frames.lavacao, 600);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(true);
|
||||
});
|
||||
|
||||
it("pauses every card while the enlarged window is open, and resumes after", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
||||
screen.getByRole("button", { name: "abrir reddit-explorer" }),
|
||||
);
|
||||
flush();
|
||||
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||
"aberto:1",
|
||||
);
|
||||
expect(isActive(frames.lavacao)).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||
flush();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
});
|
||||
|
||||
it("never plays a card for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.lavacao, 1);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops listening when it unmounts", () => {
|
||||
const { view, frames } = setup();
|
||||
setVisibility(frames.lavacao, 1);
|
||||
view.unmount();
|
||||
expect(observers.every((observer) => observer.targets.size === 0)).toBe(
|
||||
true,
|
||||
it("changes project when the window navigates to a valid index, and closes on an invalid one", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="lavacao-047" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks used outside the provider", () => {
|
||||
it("give an empty list, and opening does nothing", () => {
|
||||
function Outsider() {
|
||||
const items = useShowcaseItems();
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open("sev-modaintima")}>
|
||||
itens:{items.length}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(<Outsider />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "itens:0" });
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,12 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
|
||||
import { ProjectLightbox } from "./ProjectLightbox";
|
||||
import { pickActiveCard } from "./showcase-math";
|
||||
import "./showcase.css";
|
||||
|
||||
export interface ShowcaseItem {
|
||||
config: ShowcaseConfig;
|
||||
@@ -23,24 +18,11 @@ export interface ShowcaseItem {
|
||||
}
|
||||
|
||||
interface ShowcaseApi {
|
||||
register(id: string, element: HTMLElement): () => void;
|
||||
setHover(id: string, hovered: boolean): void;
|
||||
open(id: string): void;
|
||||
/** Pausa (ou retoma) os vídeos que tocam sozinhos nos cards: quem não quer movimento na página tem como desligar. */
|
||||
toggleAutoplay(): void;
|
||||
items: ShowcaseItem[];
|
||||
}
|
||||
|
||||
interface ActiveState {
|
||||
activeId: string | null;
|
||||
userPaused: boolean;
|
||||
}
|
||||
|
||||
const ApiContext = createContext<ShowcaseApi | null>(null);
|
||||
const ActiveContext = createContext<ActiveState>({
|
||||
activeId: null,
|
||||
userPaused: false,
|
||||
});
|
||||
|
||||
/** Só aparece como card com vídeo o projeto que existe na lista e tem vídeo gravado. */
|
||||
export function buildShowcaseItems(
|
||||
@@ -54,7 +36,7 @@ export function buildShowcaseItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
|
||||
/** Itens da vitrine para quem está dentro do provedor. */
|
||||
export function useShowcaseItems() {
|
||||
return useContext(ApiContext)?.items ?? [];
|
||||
}
|
||||
@@ -66,8 +48,8 @@ export function useShowcaseOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Um vídeo por vez nos cards: toca o card sob o mouse (ou com foco); sem mouse sobre nenhum, o mais perto do
|
||||
* centro da tela. Também guarda qual card está ampliado e mostra a janela ampliada.
|
||||
* Guarda os projetos da vitrine e qual deles está ampliado, e mostra a janela ampliada. A vitrine animada
|
||||
* (ProjectReel) e a lista do celular (ProjectList) leem a mesma lista de itens.
|
||||
*/
|
||||
export function ShowcaseProvider({
|
||||
projects,
|
||||
@@ -76,121 +58,10 @@ export function ShowcaseProvider({
|
||||
projects: PortfolioProjectView[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const items = useMemo(() => buildShowcaseItems(projects), [projects]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [userPaused, setUserPaused] = useState(false);
|
||||
|
||||
const cards = useRef(
|
||||
new Map<string, { el: HTMLElement; visible: boolean }>(),
|
||||
);
|
||||
const hovered = useRef<string | null>(null);
|
||||
const paused = useRef(false);
|
||||
const queued = useRef(false);
|
||||
const frame = useRef(0);
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const evaluate = useCallback(() => {
|
||||
queued.current = false;
|
||||
if (paused.current || document.hidden) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
const middle = window.innerHeight / 2;
|
||||
const candidates = [...cards.current].map(([id, { el, visible }]) => {
|
||||
// só quem está à vista entra na disputa: os outros nem têm o layout lido a cada quadro de rolagem
|
||||
if (!visible) return { id, visible, distance: Number.POSITIVE_INFINITY };
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
visible,
|
||||
distance: Math.abs(rect.top + rect.height / 2 - middle),
|
||||
};
|
||||
});
|
||||
setActiveId(pickActiveCard(candidates, hovered.current));
|
||||
}, []);
|
||||
|
||||
// sem cards registrados (a home mostra só a vitrine animada) não há o que decidir a cada quadro de rolagem;
|
||||
// `force` vale para o que acabou de mudar a lista (um card saiu), que ainda precisa ser reavaliado
|
||||
const schedule = useCallback(
|
||||
(force = false) => {
|
||||
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
|
||||
useEffect(() => {
|
||||
paused.current = reducedMotion || openId !== null || userPaused;
|
||||
schedule(true);
|
||||
}, [schedule, openId, reducedMotion, userPaused]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => schedule();
|
||||
window.addEventListener("scroll", onChange, { passive: true });
|
||||
window.addEventListener("resize", onChange);
|
||||
document.addEventListener("visibilitychange", onChange);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onChange);
|
||||
window.removeEventListener("resize", onChange);
|
||||
document.removeEventListener("visibilitychange", onChange);
|
||||
cancelAnimationFrame(frame.current);
|
||||
queued.current = false;
|
||||
observer.current?.disconnect();
|
||||
observer.current = null;
|
||||
};
|
||||
}, [schedule]);
|
||||
|
||||
const api = useMemo<ShowcaseApi>(
|
||||
() => ({
|
||||
items,
|
||||
register(id, element) {
|
||||
if (!observer.current && typeof IntersectionObserver !== "undefined") {
|
||||
observer.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
for (const card of cards.current.values()) {
|
||||
// um card só vale como "à vista" com pelo menos um terço dele na tela
|
||||
if (card.el === entry.target)
|
||||
card.visible = entry.intersectionRatio >= 0.33;
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
},
|
||||
{ threshold: [0, 0.33, 0.66, 1] },
|
||||
);
|
||||
}
|
||||
cards.current.set(id, { el: element, visible: false });
|
||||
observer.current?.observe(element);
|
||||
return () => {
|
||||
observer.current?.unobserve(element);
|
||||
cards.current.delete(id);
|
||||
if (hovered.current === id) hovered.current = null;
|
||||
schedule(true);
|
||||
};
|
||||
},
|
||||
setHover(id, isHovered) {
|
||||
if (isHovered) hovered.current = id;
|
||||
else if (hovered.current === id) hovered.current = null;
|
||||
schedule();
|
||||
},
|
||||
open(id) {
|
||||
setOpenId(id);
|
||||
},
|
||||
toggleAutoplay() {
|
||||
setUserPaused((paused) => !paused);
|
||||
},
|
||||
}),
|
||||
[items, schedule],
|
||||
);
|
||||
|
||||
const activeState = useMemo(
|
||||
() => ({ activeId, userPaused }),
|
||||
[activeId, userPaused],
|
||||
);
|
||||
const api = useMemo<ShowcaseApi>(() => ({ items, open: setOpenId }), [items]);
|
||||
|
||||
const openIndex = openId
|
||||
? items.findIndex((item) => item.config.id === openId)
|
||||
@@ -198,60 +69,15 @@ export function ShowcaseProvider({
|
||||
|
||||
return (
|
||||
<ApiContext.Provider value={api}>
|
||||
<ActiveContext.Provider value={activeState}>
|
||||
{children}
|
||||
{openIndex >= 0 && (
|
||||
<ProjectLightbox
|
||||
items={items}
|
||||
index={openIndex}
|
||||
onClose={() => setOpenId(null)}
|
||||
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||
/>
|
||||
)}
|
||||
</ActiveContext.Provider>
|
||||
{children}
|
||||
{openIndex >= 0 && (
|
||||
<ProjectLightbox
|
||||
items={items}
|
||||
index={openIndex}
|
||||
onClose={() => setOpenId(null)}
|
||||
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||
/>
|
||||
)}
|
||||
</ApiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Liga um card à vitrine: devolve a ref da moldura, se ele é o card que toca agora e como ampliá-lo. */
|
||||
export function useShowcaseCard(id: string) {
|
||||
const api = useContext(ApiContext);
|
||||
const { activeId, userPaused } = useContext(ActiveContext);
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = frameRef.current;
|
||||
if (!api || !element) return;
|
||||
const unregister = api.register(id, element);
|
||||
const card = element.closest<HTMLElement>(".ds-card") ?? element;
|
||||
const enter = (event: PointerEvent) => {
|
||||
// no toque não existe "passar o mouse": só o mouse e a caneta mandam na vitrine
|
||||
if (event.pointerType !== "touch") api.setHover(id, true);
|
||||
};
|
||||
const leave = () => api.setHover(id, false);
|
||||
const focusIn = () => api.setHover(id, true);
|
||||
const focusOut = (event: FocusEvent) => {
|
||||
if (!card.contains(event.relatedTarget as Node | null))
|
||||
api.setHover(id, false);
|
||||
};
|
||||
card.addEventListener("pointerenter", enter);
|
||||
card.addEventListener("pointerleave", leave);
|
||||
card.addEventListener("focusin", focusIn);
|
||||
card.addEventListener("focusout", focusOut);
|
||||
return () => {
|
||||
unregister();
|
||||
card.removeEventListener("pointerenter", enter);
|
||||
card.removeEventListener("pointerleave", leave);
|
||||
card.removeEventListener("focusin", focusIn);
|
||||
card.removeEventListener("focusout", focusOut);
|
||||
};
|
||||
}, [api, id]);
|
||||
|
||||
return {
|
||||
frameRef,
|
||||
active: activeId === id,
|
||||
userPaused,
|
||||
toggleAutoplay: useCallback(() => api?.toggleAutoplay(), [api]),
|
||||
open: useCallback(() => api?.open(id), [api, id]),
|
||||
};
|
||||
}
|
||||
@@ -1,50 +1,10 @@
|
||||
import {
|
||||
easeToward,
|
||||
locateInReel,
|
||||
pickActiveCard,
|
||||
reelStarts,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
|
||||
const card = (id: string, visible: boolean, distance: number) => ({
|
||||
id,
|
||||
visible,
|
||||
distance,
|
||||
});
|
||||
|
||||
describe("pickActiveCard", () => {
|
||||
it("plays the visible card closest to the middle of the screen", () => {
|
||||
expect(
|
||||
pickActiveCard(
|
||||
[card("a", true, 300), card("b", true, 80), card("c", false, 5)],
|
||||
null,
|
||||
),
|
||||
).toBe("b");
|
||||
});
|
||||
|
||||
it("lets the hovered card win over a closer one, but only while it is visible", () => {
|
||||
const cards = [card("a", true, 10), card("b", true, 400)];
|
||||
expect(pickActiveCard(cards, "b")).toBe("b");
|
||||
expect(
|
||||
pickActiveCard([card("a", true, 10), card("b", false, 0)], "b"),
|
||||
).toBe("a");
|
||||
});
|
||||
|
||||
it("keeps the first card on a tie, so the left one of a row wins", () => {
|
||||
expect(
|
||||
pickActiveCard(
|
||||
[card("left", true, 120), card("right", true, 120.3)],
|
||||
null,
|
||||
),
|
||||
).toBe("left");
|
||||
});
|
||||
|
||||
it("plays nothing when no card is on screen", () => {
|
||||
expect(pickActiveCard([card("a", false, 0)], null)).toBeNull();
|
||||
expect(pickActiveCard([], null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("locateInReel", () => {
|
||||
const weights = [1, 2, 1];
|
||||
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
/** Contas puras da vitrine (sem DOM), separadas para serem testadas uma a uma. */
|
||||
|
||||
export interface CardCandidate {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
/** Distância do centro do card ao centro da tela, em pixels. */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qual card toca: o que está sob o mouse (se visível); senão o visível mais perto do centro da tela.
|
||||
* Empate fica com o primeiro da lista (ordem do DOM: esquerda antes de direita).
|
||||
*/
|
||||
export function pickActiveCard(
|
||||
candidates: CardCandidate[],
|
||||
hoveredId: string | null,
|
||||
): string | null {
|
||||
const visible = candidates.filter((candidate) => candidate.visible);
|
||||
if (hoveredId && visible.some((candidate) => candidate.id === hoveredId))
|
||||
return hoveredId;
|
||||
let best: CardCandidate | null = null;
|
||||
for (const candidate of visible) {
|
||||
if (!best || candidate.distance < best.distance - 0.5) best = candidate;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface ReelPosition {
|
||||
/** Projeto em cena. */
|
||||
index: number;
|
||||
|
||||
@@ -163,11 +163,6 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-frame-chip {
|
||||
@@ -231,11 +226,6 @@
|
||||
&:hover svg {
|
||||
background: rgba(5, 5, 5, 0.9);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* sem mouse não existe "passar por cima": o aviso fica sempre à vista */
|
||||
@@ -345,46 +335,40 @@
|
||||
& > ol::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ol > li > button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
.ds-reel-pick {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* por dentro: a faixa rolável (overflow-x) cortaria o anel desenhado para fora */
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-hint {
|
||||
@@ -431,44 +415,42 @@
|
||||
list-style: none;
|
||||
margin: 6px 0 10px 16px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
text-align: left;
|
||||
.ds-reel-chapter-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
text-align: left;
|
||||
|
||||
& span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: var(--slash);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
& span {
|
||||
color: var(--slash);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: var(--slash);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-chapter-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ds-reel-chapter-pick[aria-current="true"] .ds-reel-chapter-num {
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -478,7 +460,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (pointer: coarse) {
|
||||
.ds-reel-chapters button {
|
||||
.ds-reel-chapter-pick {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -495,7 +477,7 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ds-reel-index > ol > li > button[aria-current="true"] .ds-reel-bar {
|
||||
.ds-reel-pick[aria-current="true"] .ds-reel-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -516,7 +498,7 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ds-reel-index > ol > li > button {
|
||||
.ds-reel-pick {
|
||||
width: 100%;
|
||||
gap: 14px;
|
||||
padding: 0 16px;
|
||||
@@ -525,35 +507,12 @@
|
||||
|
||||
.ds-reel-stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.ds-reel-layer {
|
||||
--accent: var(--slash);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
pointer-events: none;
|
||||
transform: translateY(14px);
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.6s var(--ease-out);
|
||||
|
||||
&[data-state="before"] {
|
||||
transform: translateY(-14px);
|
||||
}
|
||||
|
||||
&[data-state="active"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
min-height: 0;
|
||||
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* aparelhos + legenda, um bloco só: a legenda tem sempre a mesma largura e o mesmo canto esquerdo do notebook */
|
||||
@@ -568,9 +527,33 @@
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
/* as camadas de aparelhos (uma por projeto) ficam empilhadas na mesma célula: o bloco tem a altura de um par */
|
||||
.ds-reel-devices {
|
||||
position: relative;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ds-reel-set {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* a troca de projeto é um fade cruzado só dos aparelhos (a legenda é um bloco à parte, abaixo): sem deslocamento.
|
||||
Quem não está em cena já é inert (sem foco, sem clique, fora da busca) e aria-hidden. */
|
||||
.ds-reel-layer {
|
||||
--accent: var(--slash);
|
||||
position: relative;
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.45s ease;
|
||||
|
||||
&[data-state="active"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* brilho na cor do site, atrás dos aparelhos */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -587,10 +570,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-set {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.ds-reel-laptop {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -620,11 +599,20 @@
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
/* uma só legenda para o projeto em cena: na troca ela some depressa (120 ms, o mesmo CAPTION_OUT_MS de
|
||||
ProjectReel.tsx) e volta com o conteúdo novo; só opacidade, o texto nunca se desloca */
|
||||
.ds-reel-caption {
|
||||
flex: none;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
|
||||
&[data-leaving] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-title {
|
||||
@@ -761,11 +749,11 @@
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (max-height: 700px) and (pointer: fine) {
|
||||
.ds-reel-index > ol > li > button {
|
||||
.ds-reel-pick {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.ds-reel-chapters button {
|
||||
.ds-reel-chapter-pick {
|
||||
min-height: 28px;
|
||||
}
|
||||
}
|
||||
@@ -882,8 +870,9 @@
|
||||
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 */
|
||||
/* um cartão mais baixo: sem subtítulo a legenda cabe e o celular da moldura ganha espaço; texto corrido em 15 px */
|
||||
.ds-reel-item .ds-reel-sub {
|
||||
font-size: 15px;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
@@ -998,75 +987,65 @@ html.ds-lb-open {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
& button {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
.ds-lb-toggle button {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 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"] {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--slash);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* a versão escolhida: fundo claro e sublinhado em lima (a lima só marca, não preenche o botão) */
|
||||
&[aria-pressed="true"] {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--slash);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
& button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
.ds-lb-nav button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
@media (hover: hover) {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-nav button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* o palco mede a si mesmo: o aparelho cresce até o limite da largura ou da altura que sobrar */
|
||||
@@ -1128,11 +1107,6 @@ html.ds-lb-open {
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -1186,11 +1160,6 @@ html.ds-lb-open {
|
||||
border-radius: 0;
|
||||
background: var(--slash);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-chapters {
|
||||
@@ -1199,44 +1168,39 @@ html.ds-lb-open {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
& button {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
.ds-lb-chapters button {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
& span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-chapters button span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
@@ -1361,3 +1325,27 @@ html.ds-lb-open {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Foco ─────────────────────────── */
|
||||
|
||||
/* Anel de foco da vitrine (sempre sobre fundo escuro): lima. Em tudo o que fica dentro de um contêiner que corta
|
||||
o que passa da borda (a faixa rolável do índice, as telas, a lista de trechos) o anel fica por dentro; nos
|
||||
controles soltos, por fora. Sem o halo do anel duplo global (globals.css): ele é para fundos claros. */
|
||||
:is(
|
||||
.ds-frame-expand,
|
||||
.ds-frame-pause,
|
||||
.ds-reel-pick,
|
||||
.ds-reel-chapter-pick,
|
||||
.ds-lb-toggle button,
|
||||
.ds-lb-chapters button
|
||||
):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:is(.ds-lb-nav button, .ds-lb-play, .ds-lb-range):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
// src/components/ui/NumberIcons.tsx
|
||||
import type React from "react";
|
||||
|
||||
interface NumberIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** Tamanho em pixels. Escala mantendo o viewBox 80x80 */
|
||||
size?: number | string;
|
||||
/** Classe opcional para sobreposição de estilos */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
fill: "none",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
viewBox: "0 0 80 80",
|
||||
};
|
||||
|
||||
/**
|
||||
* Primitive SVG elements compartilhados.
|
||||
* Depende da classe pai 'group' para engatilhar as micro-interações.
|
||||
*/
|
||||
const GraphicShell = () => (
|
||||
<>
|
||||
{/* Base Background */}
|
||||
<rect width="80" height="80" rx="8" className="fill-[#0A0A0A]" />
|
||||
|
||||
{/* Borda reativa ao hover */}
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="78"
|
||||
height="78"
|
||||
rx="7"
|
||||
className="stroke-white/5 transition-colors duration-400 ease-[var(--ease-out)] group-hover:stroke-white/15"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Grid de background sutil (Cyberpunk/Tech feel) */}
|
||||
<g className="stroke-white/[0.03]" strokeWidth="1">
|
||||
<path d="M0 20H80M0 60H80M20 0V80M60 0V80" />
|
||||
</g>
|
||||
|
||||
{/* Brand Slash (Acento) com Motion */}
|
||||
<polygon
|
||||
points="58,66 62,66 70,50 66,50"
|
||||
className="fill-[#E8FF00] transition-transform duration-400 ease-[var(--ease-out)] origin-bottom-right group-hover:-translate-y-1 group-hover:scale-110"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export const NumberOne = ({
|
||||
size = 80,
|
||||
className = "",
|
||||
...props
|
||||
}: NumberIconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={`group ${className}`}
|
||||
role="img"
|
||||
aria-label="Passo 1"
|
||||
{...baseProps}
|
||||
{...props}
|
||||
>
|
||||
<GraphicShell />
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
className="fill-white transition-transform duration-400 ease-[var(--ease-out)] group-hover:-translate-y-0.5"
|
||||
>
|
||||
1
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const NumberTwo = ({
|
||||
size = 80,
|
||||
className = "",
|
||||
...props
|
||||
}: NumberIconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={`group ${className}`}
|
||||
role="img"
|
||||
aria-label="Passo 2"
|
||||
{...baseProps}
|
||||
{...props}
|
||||
>
|
||||
<GraphicShell />
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
className="fill-white transition-transform duration-400 ease-[var(--ease-out)] group-hover:-translate-y-0.5"
|
||||
>
|
||||
2
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const NumberThree = ({
|
||||
size = 80,
|
||||
className = "",
|
||||
...props
|
||||
}: NumberIconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={`group ${className}`}
|
||||
role="img"
|
||||
aria-label="Passo 3"
|
||||
{...baseProps}
|
||||
{...props}
|
||||
>
|
||||
<GraphicShell />
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
className="fill-white transition-transform duration-400 ease-[var(--ease-out)] group-hover:-translate-y-0.5"
|
||||
>
|
||||
3
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const NumberFour = ({
|
||||
size = 80,
|
||||
className = "",
|
||||
...props
|
||||
}: NumberIconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={`group ${className}`}
|
||||
role="img"
|
||||
aria-label="Passo 4"
|
||||
{...baseProps}
|
||||
{...props}
|
||||
>
|
||||
<GraphicShell />
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
className="fill-white transition-transform duration-400 ease-[var(--ease-out)] group-hover:-translate-y-0.5"
|
||||
>
|
||||
4
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
Reference in new issue
Block a user