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>
|
||||
);
|
||||
+259
-214
@@ -53,7 +53,6 @@
|
||||
--fs-section-title: min(max(clamp(24px, 4vw, 44px), 3.05vw), 68px);
|
||||
--fs-lead: min(max(15px, 1.05vw), 19px);
|
||||
--font-display: var(--font-space-grotesk), sans-serif;
|
||||
--font-hero: var(--font-monument-extended), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
--font-body: var(--font-display);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
@@ -150,9 +149,12 @@ html {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
/* Anel de foco que se vê em qualquer fundo: faixa lima, faixa escura e faixa lima. No fundo escuro valem as
|
||||
limas; no creme, a escura (lima sobre creme dá só 1,1:1). */
|
||||
:where(a, button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid var(--ink);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 6px var(--slash);
|
||||
}
|
||||
|
||||
.film-grain {
|
||||
@@ -275,9 +277,7 @@ textarea {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.ds-section-shell,
|
||||
.ds-loading,
|
||||
.ds-error {
|
||||
.ds-section-shell {
|
||||
width: min(var(--shell), 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -556,14 +556,6 @@ textarea {
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
.ds-loading,
|
||||
.ds-error {
|
||||
padding: var(--sp-10) clamp(24px, 6vw, 64px);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-eyebrow,
|
||||
.ds-section-dark .ds-kicker,
|
||||
.ds-section-dark .ds-metric {
|
||||
@@ -614,16 +606,6 @@ textarea {
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes ds-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
@@ -753,47 +735,6 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.ds-skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ds-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.ds-skeleton::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, #111 0%, #1f1f1f 50%, #111 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: ds-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.ds-skeleton-kicker {
|
||||
height: 10px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.ds-skeleton-title {
|
||||
height: 26px;
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.ds-skeleton-line {
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-skeleton-line-short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -833,6 +774,12 @@ textarea {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* texto corrido de no mínimo 15 px no celular (o que não traz tamanho próprio herda daqui); só os rótulos mono
|
||||
curtos (.ds-eyebrow, .ds-tag, .ds-metric) ficam em 12 */
|
||||
body {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ds-section {
|
||||
padding: clamp(52px, 13vw, 64px) 20px;
|
||||
}
|
||||
@@ -1007,7 +954,7 @@ textarea {
|
||||
radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
/* Hero: palco de 100svh; em telas grandes ele fica fixo enquanto a rolagem conduz o mergulho da cena 3D.
|
||||
/* Hero: palco de 100svh; com a cena 3D (data-dive, mais abaixo) ele fica fixo enquanto a rolagem conduz o mergulho.
|
||||
--hero-p (0 a 1) e --hero-open (0 a 1: a máscara do fundo se abrindo) vêm de cinematic/HeroField.tsx. */
|
||||
.hero {
|
||||
--hero-p: 0;
|
||||
@@ -1095,7 +1042,8 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Entrada: as palavras do título já pintam no primeiro quadro (sem opacity:0, bom para o LCP) e ganham foco em cascata */
|
||||
/* Entrada: as palavras do título já pintam no primeiro quadro (sem opacity:0, bom para o LCP) e sobem em cascata
|
||||
(só deslocamento: desfocar texto grande a cada quadro obriga a redesenhá-lo) */
|
||||
.hero-word {
|
||||
display: inline-block;
|
||||
animation: hero-word 1.1s var(--ease-out) both;
|
||||
@@ -1105,7 +1053,6 @@ textarea {
|
||||
@keyframes hero-word {
|
||||
from {
|
||||
transform: translateY(0.42em) rotate(2deg);
|
||||
filter: blur(12px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,101 +1107,118 @@ textarea {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hero fixo com o mergulho: em qualquer largura, desde que a tela tenha altura para o conteúdo caber */
|
||||
/* Mergulho: só onde a cena 3D roda. HeroField marca o hero com data-dive (mouse, aparelho que aguenta, sem
|
||||
movimento reduzido) e a tela precisa ter altura para o conteúdo caber. Sem 3D (toque, aparelho fraco,
|
||||
"economizar dados", movimento reduzido) o hero é só o palco de 100svh: nenhum bloco extra de rolagem, nenhum
|
||||
desfoque ligado à rolagem e nenhum ouvinte de rolagem. */
|
||||
.ds-main {
|
||||
/* rolagem do mergulho: o progresso (--hero-p) vai de 0 a 1 nela */
|
||||
--hero-dive: 110svh;
|
||||
}
|
||||
|
||||
@media (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
||||
.hero {
|
||||
height: 220svh;
|
||||
}
|
||||
/* O palco (100svh, preso) fica parado durante o mergulho e mais uma tela: nela Projetos sobe sobre ele, com
|
||||
margin-top de -100svh. A cena acaba de frente para os dados e Projetos a cobre: nunca uma tela vazia entre o
|
||||
mergulho e a seção seguinte (HeroField.tsx usa a mesma conta: o progresso acaba quando Projetos começa a subir). */
|
||||
.hero[data-dive] {
|
||||
height: calc(200svh + var(--hero-dive));
|
||||
|
||||
/* Legendas do mergulho: cada uma aparece enquanto a câmera atravessa a sua camada (--hero-p) */
|
||||
.hero-captions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6) 11svh;
|
||||
list-style: none;
|
||||
pointer-events: none;
|
||||
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
|
||||
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
|
||||
opacity: clamp(
|
||||
0,
|
||||
min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14),
|
||||
1
|
||||
);
|
||||
& + .ds-section {
|
||||
position: relative;
|
||||
margin-top: -100svh;
|
||||
box-shadow: 0 -32px 64px -24px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
& li {
|
||||
--mid: calc((var(--from) + var(--to)) / 2);
|
||||
grid-area: 1 / 1;
|
||||
max-width: 26ch;
|
||||
color: #f4f2ed;
|
||||
font: 600 clamp(24px, 2.8vw + 12px, 48px) / 1.12 var(--font-display);
|
||||
letter-spacing: -0.035em;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
|
||||
/* Legendas do mergulho: cada uma aparece enquanto a câmera atravessa a sua camada (--hero-p) */
|
||||
& .hero-captions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6) 11svh;
|
||||
list-style: none;
|
||||
pointer-events: none;
|
||||
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
|
||||
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
|
||||
opacity: clamp(
|
||||
0,
|
||||
min(
|
||||
(var(--hero-p) - var(--from)) *
|
||||
14,
|
||||
(var(--to) - var(--hero-p)) *
|
||||
14
|
||||
),
|
||||
min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14),
|
||||
1
|
||||
);
|
||||
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
||||
|
||||
& li {
|
||||
--mid: calc((var(--from) + var(--to)) / 2);
|
||||
grid-area: 1 / 1;
|
||||
max-width: 26ch;
|
||||
color: #f4f2ed;
|
||||
font: 600 clamp(24px, 2.8vw + 12px, 48px) / 1.12 var(--font-display);
|
||||
letter-spacing: -0.035em;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
|
||||
opacity: clamp(
|
||||
0,
|
||||
min(
|
||||
(var(--hero-p) - var(--from)) *
|
||||
14,
|
||||
(var(--to) - var(--hero-p)) *
|
||||
14
|
||||
),
|
||||
1
|
||||
);
|
||||
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
||||
}
|
||||
|
||||
/* mesmos intervalos em que a câmera enquadra cada camada (cameraFrames em HeroFieldScene.tsx) */
|
||||
& li:nth-child(1) {
|
||||
--from: 0.26;
|
||||
--to: 0.46;
|
||||
}
|
||||
|
||||
& li:nth-child(2) {
|
||||
--from: 0.52;
|
||||
--to: 0.71;
|
||||
}
|
||||
|
||||
& li:nth-child(3) {
|
||||
--from: 0.76;
|
||||
--to: 0.92;
|
||||
}
|
||||
|
||||
& .hero-caption-title {
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* mesmos intervalos em que a câmera enquadra cada camada (CAMERA_Z em HeroFieldScene.tsx) */
|
||||
& li:nth-child(1) {
|
||||
--from: 0.26;
|
||||
--to: 0.46;
|
||||
& .hero-stage {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
}
|
||||
|
||||
& li:nth-child(2) {
|
||||
--from: 0.52;
|
||||
--to: 0.71;
|
||||
/* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e some (sem desfoque: só opacidade e
|
||||
transform, que a placa de vídeo compõe sem redesenhar o texto a cada quadro) */
|
||||
& .hero-content {
|
||||
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0)
|
||||
scale(calc(1 - var(--hero-p) * 0.08));
|
||||
transform-origin: 0 50%;
|
||||
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||
}
|
||||
|
||||
& li:nth-child(3) {
|
||||
--from: 0.76;
|
||||
--to: 0.92;
|
||||
& .hero-foot {
|
||||
opacity: calc(1 - var(--hero-p) * 5);
|
||||
}
|
||||
|
||||
& .hero-caption-title {
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: 12px;
|
||||
/* a coluna da direita (molduras) sai junto com o texto: o fundo 3D se abre sem passar atrás dela */
|
||||
& .hero-aside {
|
||||
transform: translate3d(0, calc(var(--hero-p) * -10vh), 0);
|
||||
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-stage {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
}
|
||||
|
||||
/* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e perde o foco */
|
||||
.hero-content {
|
||||
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0)
|
||||
scale(calc(1 - var(--hero-p) * 0.08));
|
||||
transform-origin: 0 50%;
|
||||
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||
filter: blur(calc(var(--hero-p) * 18px));
|
||||
}
|
||||
|
||||
.hero-foot {
|
||||
opacity: calc(1 - var(--hero-p) * 5);
|
||||
}
|
||||
|
||||
/* a coluna da direita (molduras) sai junto com o texto: o fundo 3D se abre sem passar atrás dela */
|
||||
.hero-aside {
|
||||
transform: translate3d(0, calc(var(--hero-p) * -10vh), 0);
|
||||
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||
}
|
||||
|
||||
.hero-scrim {
|
||||
opacity: calc(1 - var(--hero-p) * 1.4);
|
||||
& .hero-scrim {
|
||||
opacity: calc(1 - var(--hero-p) * 1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1267,12 +1231,33 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Transições entre seções: app/components/cinematic/useCinematicScroll.ts (GSAP + Lenis).
|
||||
O fundo escuro aparece nas frestas enquanto uma seção entra sobre a outra. */
|
||||
/* Passagem entre seções: só o conteúdo (.ds-section-shell) de cada cena chega com fade e uma subida curta
|
||||
enquanto ela entra na janela, ligado à rolagem (rolar para cima desfaz). O fundo da seção nunca muda de
|
||||
opacidade: entre seções do mesmo tom não há fade nem fresta de outra cor. O primeiro filho (o hero) não tem
|
||||
passagem. Sem suporte a animation-timeline o conteúdo já está no lugar, sem animação. */
|
||||
.ds-main {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
@keyframes ds-scene-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
.ds-main > :not(.hero) > .ds-section-shell {
|
||||
/* backwards: antes de entrar o conteúdo espera escondido; depois, volta ao estilo normal (sem transform
|
||||
sobrando, que prenderia a vitrine fixa dentro de um bloco de transform) */
|
||||
animation: ds-scene-in linear backwards;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Lenis controla a suavidade da rolagem: o scroll-behavior nativo brigaria com ele */
|
||||
html.lenis,
|
||||
html.lenis body {
|
||||
@@ -1283,66 +1268,6 @@ html.lenis {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
||||
.ds-cover {
|
||||
position: relative;
|
||||
flex: none; /* o card e uma coluna flex: sem isto a capa encolhe e perde a proporcao */
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--sp-5);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--ink);
|
||||
|
||||
& video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& img,
|
||||
& video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
transition: transform 0.7s var(--ease-out);
|
||||
}
|
||||
|
||||
.ds-card:hover & img,
|
||||
.ds-card:hover & video {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-cover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ds-cover-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-4);
|
||||
color: var(--paper);
|
||||
background:
|
||||
radial-gradient(120% 90% at 100% 0%, var(--slash-dim), transparent 60%),
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100%
|
||||
32px,
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 /
|
||||
32px 100%,
|
||||
var(--ink);
|
||||
|
||||
& .ds-cover-mark {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(48px, 7vw, 96px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
/* so decorativa: no celular some, para a pagina nao crescer sem informacao nova */
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cinematic card hover effect */
|
||||
.ds-card {
|
||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
@@ -1359,11 +1284,6 @@ html.lenis {
|
||||
0 0 1px rgba(204, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Cinematic section reveal transforms */
|
||||
.cinematic-reveal {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
|
||||
recua e desfoca, a nova chega de baixo. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
||||
.site-header {
|
||||
@@ -1436,3 +1356,128 @@ html.lenis {
|
||||
transform: translateY(0.35em);
|
||||
}
|
||||
}
|
||||
|
||||
/* Entradas por rolagem (Section, Reveal, DrawLine, LazyRevealImage): useInView marca data-shown (ou data-loaded)
|
||||
e a transição é CSS. O estado escondido só existe com JavaScript e sem movimento reduzido; de outro modo o
|
||||
conteúdo já nasce visível. */
|
||||
@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
|
||||
.ds-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--reveal-y, 18px));
|
||||
transition:
|
||||
opacity var(--reveal-duration, 0.8s) var(--ease-out),
|
||||
transform var(--reveal-duration, 0.8s) var(--ease-out);
|
||||
transition-delay: var(--reveal-delay, 0s);
|
||||
}
|
||||
|
||||
.ds-reveal[data-shown],
|
||||
.ds-section-header[data-shown] .ds-reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ds-draw-line {
|
||||
opacity: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition:
|
||||
opacity 0.6s var(--ease-out),
|
||||
transform 0.6s var(--ease-out);
|
||||
transition-delay: var(--reveal-delay, 0s);
|
||||
}
|
||||
|
||||
.ds-draw-line[data-shown] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ds-lazy-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(1.02);
|
||||
transition:
|
||||
opacity 0.75s var(--ease-out),
|
||||
transform 0.75s var(--ease-out);
|
||||
}
|
||||
|
||||
.ds-lazy-reveal[data-loaded] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ScrollProgress: barra do topo e trilho lateral ligados à rolagem da página, sem JavaScript */
|
||||
@keyframes ds-progress-x {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ds-progress-y {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ds-progress-dot {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(120px);
|
||||
}
|
||||
}
|
||||
|
||||
/* aparece depois dos primeiros 2% e some nos últimos 2% */
|
||||
@keyframes ds-progress-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
2%,
|
||||
98% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.ds-scroll-bar {
|
||||
animation:
|
||||
ds-progress-x linear both,
|
||||
ds-progress-fade linear both;
|
||||
animation-timeline: scroll(root), scroll(root);
|
||||
}
|
||||
|
||||
.ds-scroll-rail {
|
||||
animation: ds-progress-fade linear both;
|
||||
animation-timeline: scroll(root);
|
||||
}
|
||||
|
||||
.ds-scroll-fill {
|
||||
animation: ds-progress-y linear both;
|
||||
animation-timeline: scroll(root);
|
||||
}
|
||||
|
||||
.ds-scroll-dot {
|
||||
animation: ds-progress-dot linear both;
|
||||
animation-timeline: scroll(root);
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (animation-timeline: scroll()) {
|
||||
.ds-scroll-progress {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { useInView } from "./useInView";
|
||||
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
options?: IntersectionObserverInit;
|
||||
observed: Element[];
|
||||
disconnect: jest.Mock;
|
||||
};
|
||||
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,
|
||||
options?: IntersectionObserverInit,
|
||||
) {
|
||||
this.record = { callback, options, observed: [], disconnect: jest.fn() };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.observed.push(target);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = original;
|
||||
});
|
||||
|
||||
function report(ratio: number, visibleHeight: number) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: ratio > 0,
|
||||
intersectionRatio: ratio,
|
||||
intersectionRect: { height: visibleHeight },
|
||||
target: observer.observed[0],
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function Probe({ amount, once }: { amount?: number; once?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inView = useInView(ref, { amount, once });
|
||||
return <div ref={ref} data-in-view={String(inView)} />;
|
||||
}
|
||||
|
||||
const state = (container: HTMLElement) =>
|
||||
container.firstElementChild?.getAttribute("data-in-view");
|
||||
|
||||
describe("useInView", () => {
|
||||
it("is false until enough of the element is on screen (20 % by default)", () => {
|
||||
const { container } = render(<Probe />);
|
||||
expect(state(container)).toBe("false");
|
||||
expect(observers).toHaveLength(1);
|
||||
expect(observers[0].observed).toHaveLength(1);
|
||||
|
||||
report(0.1, 40); // pouco à vista: ainda não
|
||||
expect(state(container)).toBe("false");
|
||||
|
||||
report(0.25, 100);
|
||||
expect(state(container)).toBe("true");
|
||||
});
|
||||
|
||||
it("stays true after the element leaves (once), and stops observing", () => {
|
||||
const { container } = render(<Probe />);
|
||||
report(1, 300);
|
||||
expect(state(container)).toBe("true");
|
||||
expect(observers[0].disconnect).toHaveBeenCalled();
|
||||
|
||||
report(0, 0);
|
||||
expect(state(container)).toBe("true");
|
||||
});
|
||||
|
||||
it("follows the element in and out when once is false", () => {
|
||||
const { container } = render(<Probe once={false} />);
|
||||
report(1, 300);
|
||||
expect(state(container)).toBe("true");
|
||||
|
||||
report(0, 0);
|
||||
expect(state(container)).toBe("false");
|
||||
|
||||
report(0.5, 200);
|
||||
expect(state(container)).toBe("true");
|
||||
});
|
||||
|
||||
it("counts a very tall element once a fifth of the window is showing, even if that is a tiny share of it", () => {
|
||||
const { container } = render(<Probe />);
|
||||
|
||||
report(0.02, 100); // 100 px de um bloco de 5000: menos de 20% da janela (800 px)
|
||||
expect(state(container)).toBe("false");
|
||||
|
||||
report(0.04, 200); // 200 px >= 20% de 800
|
||||
expect(state(container)).toBe("true");
|
||||
});
|
||||
|
||||
it("asks the observer for the amount it was given", () => {
|
||||
render(<Probe amount={0.7} />);
|
||||
|
||||
expect(observers[0].options?.threshold).toEqual([0, 0.7]);
|
||||
});
|
||||
|
||||
it("is true at once when the browser has no IntersectionObserver (content is never left hidden)", () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
|
||||
(global as any).IntersectionObserver = undefined;
|
||||
|
||||
const { container } = render(<Probe />);
|
||||
|
||||
expect(state(container)).toBe("true");
|
||||
});
|
||||
|
||||
it("disconnects the observer when the component unmounts", () => {
|
||||
const { unmount } = render(<Probe />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(observers[0].disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
|
||||
interface InViewOptions {
|
||||
/** Fração do elemento (0 a 1) que precisa estar à vista; em elementos altos, vale também essa fração da altura da janela. */
|
||||
amount?: number;
|
||||
/** Verdadeiro (padrão): depois de aparecer uma vez, continua verdadeiro. Falso: acompanha a entrada e a saída. */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
function isShown(entry: IntersectionObserverEntry, amount: number) {
|
||||
return (
|
||||
entry.isIntersecting &&
|
||||
(entry.intersectionRatio >= amount ||
|
||||
entry.intersectionRect.height >= window.innerHeight * amount)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* O elemento está à vista? Usa IntersectionObserver (sem ouvir a rolagem). Sem IntersectionObserver devolve
|
||||
* verdadeiro logo na montagem: o conteúdo nunca fica escondido por falta do recurso.
|
||||
*/
|
||||
export function useInView(
|
||||
ref: RefObject<Element | null>,
|
||||
{ amount = 0.2, once = true }: InViewOptions = {},
|
||||
): boolean {
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setInView(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const shown = isShown(entry, amount);
|
||||
if (shown) {
|
||||
setInView(true);
|
||||
if (once) observer.disconnect();
|
||||
} else if (!once) setInView(false);
|
||||
}
|
||||
},
|
||||
{ threshold: [0, amount] },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, amount, once]);
|
||||
|
||||
return inView;
|
||||
}
|
||||
@@ -1,49 +1,92 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { useReducedMotion } from "./useReducedMotion";
|
||||
|
||||
// Mock do Framer Motion
|
||||
jest.mock("motion/react", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
type Listener = () => void;
|
||||
|
||||
/** matchMedia que responde só à consulta de movimento reduzido e deixa o teste mudar a preferência depois. */
|
||||
function mockPreference(initial: boolean) {
|
||||
const listeners = new Set<Listener>();
|
||||
const query = {
|
||||
matches: initial,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener: jest.fn((_: string, listener: Listener) =>
|
||||
listeners.add(listener),
|
||||
),
|
||||
removeEventListener: jest.fn((_: string, listener: Listener) =>
|
||||
listeners.delete(listener),
|
||||
),
|
||||
};
|
||||
window.matchMedia = jest.fn().mockReturnValue(query);
|
||||
return {
|
||||
query,
|
||||
change(value: boolean) {
|
||||
query.matches = value;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const original = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
describe("useReducedMotion", () => {
|
||||
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(false);
|
||||
it("is false when the system does not ask for reduced motion", () => {
|
||||
mockPreference(false);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(true);
|
||||
it("is true when the system asks for reduced motion", () => {
|
||||
mockPreference(true);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(window.matchMedia).toHaveBeenCalledWith(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
);
|
||||
});
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(null);
|
||||
|
||||
it("follows the preference when it changes while the page is open", () => {
|
||||
const preference = mockPreference(false);
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => preference.change(true));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => preference.change(false));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(undefined);
|
||||
it("stops listening when the component unmounts", () => {
|
||||
const preference = mockPreference(false);
|
||||
const { unmount } = renderHook(() => useReducedMotion());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(preference.query.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is false on the server and in browsers without matchMedia, so the HTML always hydrates the same", () => {
|
||||
mockPreference(true);
|
||||
function Probe() {
|
||||
return createElement("i", { "data-reduced": String(useReducedMotion()) });
|
||||
}
|
||||
expect(renderToString(createElement(Probe))).toContain(
|
||||
'data-reduced="false"',
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
|
||||
(window as any).matchMedia = undefined;
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion as useFramerReducedMotion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
/**
|
||||
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
||||
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
||||
*
|
||||
* @returns {boolean} true se prefers-reduced-motion está ativo, false caso contrário
|
||||
* Preferência `prefers-reduced-motion` do sistema operacional, lida com matchMedia (sem biblioteca de animação).
|
||||
* Falso no servidor e na primeira renderização no navegador, para o HTML hidratar igual ao do servidor.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const reducedMotion = useReducedMotion();
|
||||
*
|
||||
* return (
|
||||
* <motion.div
|
||||
* animate={{ opacity: 1, y: reducedMotion ? 0 : 20 }}
|
||||
* />
|
||||
* );
|
||||
* ```
|
||||
* return <div data-calm={reducedMotion || undefined} />;
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
const systemPreference = useFramerReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia(QUERY);
|
||||
const update = () => setReduced(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return isMounted && systemPreference === true;
|
||||
return reduced;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Template from "./template";
|
||||
|
||||
let mockPathname = "/cases";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
const mockMounts = jest.fn();
|
||||
const mockProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
// o React estável do Jest não exporta <ViewTransition> (só o canary do Next); este dublê conta as montagens
|
||||
jest.mock("react", () => {
|
||||
const actual = jest.requireActual("react");
|
||||
function ViewTransition({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) {
|
||||
mockProps.push(props);
|
||||
actual.useEffect(() => {
|
||||
mockMounts();
|
||||
}, []);
|
||||
return actual.createElement("div", { "data-testid": "vt" }, children);
|
||||
}
|
||||
return { ...actual, ViewTransition };
|
||||
});
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
useEffect(() => {
|
||||
setCount(1);
|
||||
}, []);
|
||||
return <p data-testid="state">{count}</p>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/cases";
|
||||
mockMounts.mockClear();
|
||||
mockProps.length = 0;
|
||||
});
|
||||
|
||||
describe("app/template", () => {
|
||||
it("wraps the page in a ViewTransition named page that does not animate in-place updates", () => {
|
||||
render(
|
||||
<Template>
|
||||
<main>Conteúdo</main>
|
||||
</Template>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("vt")).toContainElement(screen.getByRole("main"));
|
||||
expect(mockProps[0]).toMatchObject({ name: "page", update: "none" });
|
||||
});
|
||||
|
||||
it("makes the page leaving and the page entering a pair when only the second segment changes (/cases -> /cases/um-projeto)", () => {
|
||||
const { rerender } = render(
|
||||
<Template>
|
||||
<Counter />
|
||||
</Template>,
|
||||
);
|
||||
expect(mockMounts).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("state")).toHaveTextContent("1");
|
||||
|
||||
mockPathname = "/cases/sev-modaintima";
|
||||
rerender(
|
||||
<Template>
|
||||
<Counter />
|
||||
</Template>,
|
||||
);
|
||||
|
||||
// a chave é o caminho: a transição desmonta a página que sai e monta a que entra (e o estado dela recomeça)
|
||||
expect(mockMounts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not remount when the path stays the same (only a re-render, or a #fragment change)", () => {
|
||||
const { rerender } = render(
|
||||
<Template>
|
||||
<Counter />
|
||||
</Template>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<Template>
|
||||
<Counter />
|
||||
</Template>,
|
||||
);
|
||||
|
||||
expect(mockMounts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+18
-2
@@ -1,13 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ViewTransition } from "react";
|
||||
|
||||
/**
|
||||
* O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css).
|
||||
* O conteúdo leva o nome "page" e anima com page-out/page-in (globals.css) a cada troca de página.
|
||||
* key={pathname}: o template da raiz só remonta quando muda o primeiro segmento da rota (de /cases para
|
||||
* /cases/um-projeto ele continua o mesmo), e sem a chave a troca seria uma atualização no lugar, que
|
||||
* update="none" corta. Com a chave, a página que sai e a que entra formam o par que o React anima.
|
||||
* update="none": a hidratação e a revelação de Suspense também são transições do React e animariam a página
|
||||
* inteira na chegada (a tela piscava duas vezes); só a troca de página anima.
|
||||
*
|
||||
* Só no `next dev`, algumas rotas (/servicos/engenharia-de-software, /servicos/arquitetura-de-software e
|
||||
* /servicos/integracao-hardware-software-iot, às vezes o 404) mostram no console "Each child in a list should have a
|
||||
* unique key prop. Check the render method of OuterLayoutRouter". Não vem da `key` daqui nem do conteúdo da página
|
||||
* (uma página vazia também mostra): sem template.tsx o aviso some, e com qualquer template.tsx (testado com um
|
||||
* template de servidor e sem `key`) o servidor de desenvolvimento embrulha o template em
|
||||
* um nó do explorador de segmentos, e, quando o React o serializa como referência preguiçosa (`$L`) dentro da lista
|
||||
* [estilos, scripts, template] do TemplateContext, a conferência de keys o vê sem chave. O embrulho só existe com
|
||||
* `__NEXT_DEV_SERVER` e o aviso só existe no React de desenvolvimento: em produção não acontece.
|
||||
*/
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<ViewTransition name="page" update="none">
|
||||
<ViewTransition key={pathname} name="page" update="none">
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
|
||||
@@ -127,8 +127,7 @@ describe("showcase assets", () => {
|
||||
const assets = getShowcaseAssets(id, device);
|
||||
return [assets.poster, ...assets.sources.map((s) => s.src)];
|
||||
}),
|
||||
getScrubSource(id, "desktop"),
|
||||
getScrubSource(id, "mobile"),
|
||||
getScrubSource(id),
|
||||
getOgImagePath(id),
|
||||
];
|
||||
for (const file of files) {
|
||||
@@ -137,4 +136,13 @@ describe("showcase assets", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PROJECT_VIDEO_IDS)(
|
||||
"does not keep a scrub video for the phone frame of %s (it only shows the poster)",
|
||||
(id) => {
|
||||
expect(existsSync(publicFile(`/videos/${id}/scrub-mobile.mp4`))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,9 @@
|
||||
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
||||
* - desktop.webp e mobile.webp: pôsteres; a versão de celular vale abaixo de 768 px (lib/device.ts);
|
||||
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
||||
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
|
||||
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
|
||||
* - scrub-desktop.mp4 (960x600, H.264 sem áudio, ~0,4 a 1,9 MB): o mesmo vídeo com um quadro-chave a cada 6
|
||||
* quadros, para a imagem acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro. É o único
|
||||
* vídeo da vitrine fixa: o celular da moldura mostra só o pôster, então não existe scrub-mobile.mp4;
|
||||
* - og.jpg: capa 1200x630 das imagens de compartilhamento.
|
||||
*/
|
||||
|
||||
@@ -126,8 +127,9 @@ export function getShowcaseAssets(id: string, device: Device) {
|
||||
} satisfies ShowcaseAssets;
|
||||
}
|
||||
|
||||
export function getScrubSource(id: string, device: Device) {
|
||||
return `/videos/${id}/scrub-${device}.mp4`;
|
||||
/** Vídeo da vitrine fixa (só o do computador: o celular da moldura mostra o pôster). */
|
||||
export function getScrubSource(id: string) {
|
||||
return `/videos/${id}/scrub-desktop.mp4`;
|
||||
}
|
||||
|
||||
export function getOgImagePath(id: string) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* mkdir rec && cd rec && npm init -y && npm i ffmpeg-static
|
||||
* node <repo>/scripts/encode-scrub-video.mjs --in desktop-original.mp4 --out <repo>/public/videos/<id>/scrub-desktop.mp4 --size 1120x700
|
||||
* node <repo>/scripts/encode-scrub-video.mjs --in mobile-original.mp4 --out <repo>/public/videos/<id>/scrub-mobile.mp4 --size 390x844
|
||||
*
|
||||
* Só existe o scrub do computador: o celular da moldura mostra o pôster, sem vídeo.
|
||||
*
|
||||
* Use como entrada o vídeo de melhor qualidade que existir (a entrada de record-project-videos.mjs antes do corte
|
||||
* para MP4/WebM), não os MP4 já comprimidos de public/videos.
|
||||
|
||||
Reference in new issue
Block a user