perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
No files matched your search
@@ -0,0 +1,141 @@
|
|||||||
|
import { startFramePulse } from "./FramePulse";
|
||||||
|
|
||||||
|
const invalidate = jest.fn();
|
||||||
|
let stops: Array<() => void> = [];
|
||||||
|
|
||||||
|
/** Liga a batida e guarda o desligamento para o afterEach não deixar ouvinte para trás. */
|
||||||
|
function start(options?: Parameters<typeof startFramePulse>[1]) {
|
||||||
|
const stop = startFramePulse(invalidate, options);
|
||||||
|
stops.push(stop);
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
invalidate.mockClear();
|
||||||
|
stops = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const stop of stops) stop();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const advance = (ms: number) => jest.advanceTimersByTime(ms);
|
||||||
|
|
||||||
|
describe("startFramePulse", () => {
|
||||||
|
it("asks for frames at about 30 per second, not 60", () => {
|
||||||
|
start();
|
||||||
|
advance(1000);
|
||||||
|
expect(invalidate.mock.calls.length).toBeGreaterThan(20);
|
||||||
|
expect(invalidate.mock.calls.length).toBeLessThanOrEqual(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the first frame right away", () => {
|
||||||
|
start();
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a single setInterval, never a second animation-frame loop", () => {
|
||||||
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
start();
|
||||||
|
expect(jest.getTimerCount()).toBe(1);
|
||||||
|
advance(1000);
|
||||||
|
expect(frames).not.toHaveBeenCalled();
|
||||||
|
frames.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing once it is stopped (the hero is off screen)", () => {
|
||||||
|
const stop = start();
|
||||||
|
advance(200);
|
||||||
|
const running = invalidate.mock.calls.length;
|
||||||
|
expect(running).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
advance(1000);
|
||||||
|
expect(invalidate.mock.calls.length).toBe(running);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes its listeners when it stops", () => {
|
||||||
|
const stop = start();
|
||||||
|
stop();
|
||||||
|
invalidate.mockClear();
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event("pointermove"));
|
||||||
|
advance(500);
|
||||||
|
expect(invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be stopped twice", () => {
|
||||||
|
const stop = start();
|
||||||
|
stop();
|
||||||
|
expect(() => stop()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startFramePulse: stops when nobody is looking", () => {
|
||||||
|
it("stops drawing, and has no timer left, after 5 s without a cursor or scroll", () => {
|
||||||
|
start();
|
||||||
|
advance(4000);
|
||||||
|
const drawn = invalidate.mock.calls.length;
|
||||||
|
expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo
|
||||||
|
|
||||||
|
advance(1500); // passou dos 5 s
|
||||||
|
const stopped = invalidate.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(invalidate.mock.calls.length).toBe(stopped);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])(
|
||||||
|
"wakes up again with %s and keeps drawing",
|
||||||
|
(type) => {
|
||||||
|
start();
|
||||||
|
advance(7000);
|
||||||
|
const stopped = invalidate.mock.calls.length;
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event(type));
|
||||||
|
advance(500);
|
||||||
|
|
||||||
|
expect(invalidate.mock.calls.length).toBeGreaterThan(stopped + 8);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("wakes up once, not once per gesture (one timer, however many events arrive)", () => {
|
||||||
|
start();
|
||||||
|
for (let i = 0; i < 20; i++) window.dispatchEvent(new Event("pointermove"));
|
||||||
|
expect(jest.getTimerCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each gesture buys another 5 s: it does not stop while there is movement", () => {
|
||||||
|
start();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
advance(3000);
|
||||||
|
window.dispatchEvent(new Event("pointermove"));
|
||||||
|
}
|
||||||
|
const before = invalidate.mock.calls.length;
|
||||||
|
|
||||||
|
advance(1000);
|
||||||
|
|
||||||
|
expect(invalidate.mock.calls.length).toBeGreaterThan(before + 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the idle time and the rate be configured", () => {
|
||||||
|
start({ idleMs: 1000 });
|
||||||
|
advance(1500);
|
||||||
|
const stopped = invalidate.mock.calls.length;
|
||||||
|
advance(2000);
|
||||||
|
expect(invalidate.mock.calls.length).toBe(stopped);
|
||||||
|
|
||||||
|
invalidate.mockClear();
|
||||||
|
start({ fps: 10 });
|
||||||
|
advance(1000);
|
||||||
|
expect(invalidate.mock.calls.length).toBeLessThanOrEqual(11);
|
||||||
|
expect(invalidate.mock.calls.length).toBeGreaterThanOrEqual(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { act, render } from "@testing-library/react";
|
|
||||||
import FramePulse from "./FramePulse";
|
|
||||||
|
|
||||||
const mockInvalidate = jest.fn();
|
|
||||||
|
|
||||||
jest.mock("@react-three/fiber", () => ({
|
|
||||||
useThree: (select: (state: { invalidate: () => void }) => unknown) =>
|
|
||||||
select({ invalidate: mockInvalidate }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
mockInvalidate.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
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 />);
|
|
||||||
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} />);
|
|
||||||
advance(1000);
|
|
||||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
rerender(<FramePulse active />);
|
|
||||||
advance(200);
|
|
||||||
const running = mockInvalidate.mock.calls.length;
|
|
||||||
expect(running).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
rerender(<FramePulse active={false} />);
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/** 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;
|
||||||
|
|
||||||
|
export type FramePulseOptions = {
|
||||||
|
/** Quadros por segundo pedidos (padrão 30). */
|
||||||
|
fps?: number;
|
||||||
|
/** Tempo (ms) sem cursor, rolagem nem teclado depois do qual a batida para de vez (padrão 5000). */
|
||||||
|
idleMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batida das cenas WebGL em demanda: pede um quadro (`invalidate`) a no máximo `fps` por segundo, de um único
|
||||||
|
* `setInterval` (fundo ambiente, sem gastar 60 quadros por segundo e sem um segundo laço de requestAnimationFrame ao
|
||||||
|
* lado do da cena). Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum intervalo, nenhum
|
||||||
|
* quadro) e o último quadro fica na tela; qualquer movimento a religa. Quem precisa de mais (entrada, rolagem, cursor)
|
||||||
|
* pede o próximo quadro por conta própria, dentro do quadro. Devolve a função que desliga tudo.
|
||||||
|
*/
|
||||||
|
export function startFramePulse(
|
||||||
|
invalidate: () => void,
|
||||||
|
{ fps = 30, idleMs = 5000 }: FramePulseOptions = {},
|
||||||
|
) {
|
||||||
|
const period = Math.round(1000 / fps);
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let lastActivity = performance.now();
|
||||||
|
|
||||||
|
const stopTimer = () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
};
|
||||||
|
const beat = () => {
|
||||||
|
// parada por inatividade: sem intervalo até o próximo movimento
|
||||||
|
if (performance.now() - lastActivity > idleMs) stopTimer();
|
||||||
|
else invalidate();
|
||||||
|
};
|
||||||
|
const wake = () => {
|
||||||
|
lastActivity = performance.now();
|
||||||
|
if (timer === undefined) timer = setInterval(beat, period);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const type of ACTIVITY)
|
||||||
|
window.addEventListener(type, wake, { passive: true });
|
||||||
|
wake();
|
||||||
|
invalidate();
|
||||||
|
return () => {
|
||||||
|
for (const type of ACTIVITY) window.removeEventListener(type, wake);
|
||||||
|
stopTimer();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
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 () => {
|
|
||||||
for (const type of ACTIVITY) window.removeEventListener(type, wake);
|
|
||||||
cancelAnimationFrame(id);
|
|
||||||
};
|
|
||||||
}, [active, fps, idleMs, invalidate]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -388,7 +388,7 @@ describe("HeroField: falhas voltam ao hero simples", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HeroField: rolagem e visibilidade", () => {
|
describe("HeroField: rolagem e visibilidade", () => {
|
||||||
function place(top: number, height = 3000) {
|
function place(top: number, height = 2760) {
|
||||||
section().getBoundingClientRect = () =>
|
section().getBoundingClientRect = () =>
|
||||||
({ top, height, bottom: top + height }) as DOMRect;
|
({ top, height, bottom: top + height }) as DOMRect;
|
||||||
}
|
}
|
||||||
@@ -443,7 +443,7 @@ describe("HeroField: rolagem e visibilidade", () => {
|
|||||||
jest.advanceTimersByTime(40);
|
jest.advanceTimersByTime(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
// trilho de 1400 px (3000 de hero - 2 telas de 800)
|
// trilho de 1400 px (2760 de hero - 1,7 tela de 800)
|
||||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||||
0.5,
|
0.5,
|
||||||
);
|
);
|
||||||
@@ -571,3 +571,29 @@ describe("HeroField: navegador sem matchMedia", () => {
|
|||||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HeroField: desmontagem em pontos que faltavam", () => {
|
||||||
|
it("path: unmounted after the geometry was prepared, before the Canvas -> the scene is never mounted", async () => {
|
||||||
|
const view = render(<Hero />);
|
||||||
|
mockPrepare.mockImplementationOnce(() => {
|
||||||
|
act(() => view.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
await advance(1200);
|
||||||
|
await finishLoading();
|
||||||
|
|
||||||
|
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockScenes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no dive on this screen (the dive query does not match) -> unmounting is quiet and the hero stays plain", async () => {
|
||||||
|
mockMedia([]);
|
||||||
|
const view = render(<Hero />);
|
||||||
|
await finishLoading();
|
||||||
|
const hero = section();
|
||||||
|
|
||||||
|
expect(() => view.unmount()).not.toThrow();
|
||||||
|
|
||||||
|
expect(hero).not.toHaveAttribute("data-dive");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,7 +56,7 @@ 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. */
|
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
||||||
function scrollWritesProgress() {
|
function scrollWritesProgress() {
|
||||||
section().getBoundingClientRect = () =>
|
section().getBoundingClientRect = () =>
|
||||||
({ top: -700, height: 3000, bottom: 2300 }) as DOMRect;
|
({ top: -700, height: 2760, bottom: 2300 }) as DOMRect;
|
||||||
act(() => {
|
act(() => {
|
||||||
window.dispatchEvent(new Event("scroll"));
|
window.dispatchEvent(new Event("scroll"));
|
||||||
jest.advanceTimersByTime(40);
|
jest.advanceTimersByTime(40);
|
||||||
@@ -192,10 +192,10 @@ describe("HeroField (ambient background)", () => {
|
|||||||
const hero = container.querySelector(".hero") as HTMLElement;
|
const hero = container.querySelector(".hero") as HTMLElement;
|
||||||
expect(hero).toHaveAttribute("data-dive");
|
expect(hero).toHaveAttribute("data-dive");
|
||||||
await idle();
|
await idle();
|
||||||
// 3000 px de hero: 800 de palco, 800 da tela de Projetos subindo e 1400 de mergulho
|
// 2760 px de hero (altura = 1,7 tela de cauda + o mergulho): 800 de palco, 560 de Projetos subindo e 1400 de mergulho
|
||||||
let top = 0;
|
let top = 0;
|
||||||
hero.getBoundingClientRect = () =>
|
hero.getBoundingClientRect = () =>
|
||||||
({ top, height: 3000, bottom: top + 3000 }) as DOMRect;
|
({ top, height: 2760, bottom: top + 2760 }) as DOMRect;
|
||||||
Object.defineProperty(window, "innerHeight", {
|
Object.defineProperty(window, "innerHeight", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 800,
|
value: 800,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
|||||||
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
/** 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 =
|
export const DIVE_QUERY =
|
||||||
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
|
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
|
||||||
|
/**
|
||||||
|
* Telas de hero além do mergulho (globals.css: height = 170svh + --hero-dive): 100 do palco preso e 70 em que Projetos
|
||||||
|
* sobe sobre ele (margin-top de -70svh). O mergulho (progresso 0 a 1) é o resto da altura do hero.
|
||||||
|
*/
|
||||||
|
export const HERO_TAIL_SCREENS = 1.7;
|
||||||
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
|
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
|
||||||
const IDLE_SLACK_MS = 20;
|
const IDLE_SLACK_MS = 20;
|
||||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||||
@@ -112,9 +117,9 @@ function trackDive(
|
|||||||
const update = () => {
|
const update = () => {
|
||||||
frame = 0;
|
frame = 0;
|
||||||
const rect = section.getBoundingClientRect();
|
const rect = section.getBoundingClientRect();
|
||||||
// o palco fica preso por (altura - 1 tela); a última tela dessa rolagem é de Projetos subindo sobre ele
|
// o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele
|
||||||
const span = Math.max(
|
const span = Math.max(
|
||||||
rect.height - window.innerHeight * 2,
|
rect.height - window.innerHeight * HERO_TAIL_SCREENS,
|
||||||
window.innerHeight * 0.8,
|
window.innerHeight * 0.8,
|
||||||
);
|
);
|
||||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||||
|
|||||||
@@ -1,43 +1,83 @@
|
|||||||
import { act, render } from "@testing-library/react";
|
import {
|
||||||
import * as THREE from "three";
|
type BufferGeometry,
|
||||||
import HeroFieldScene, { prepareField } from "./HeroFieldScene";
|
type Group,
|
||||||
|
type PerspectiveCamera,
|
||||||
|
type Plane,
|
||||||
|
Ray,
|
||||||
|
type ShaderMaterial,
|
||||||
|
type Vector3,
|
||||||
|
} from "three";
|
||||||
|
import {
|
||||||
|
createField,
|
||||||
|
type FieldEngine,
|
||||||
|
needsFrame,
|
||||||
|
prepareField,
|
||||||
|
type Rig,
|
||||||
|
type Tick,
|
||||||
|
updatePointer,
|
||||||
|
updateRig,
|
||||||
|
} from "./HeroFieldScene";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
|
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
|
||||||
* - fim antecipado sem grupo, onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente);
|
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
|
||||||
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
||||||
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
||||||
* - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
|
* - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
|
||||||
* - perda do contexto WebGL, limpeza e a falha de um filho (SceneBoundary).
|
* - perda do contexto WebGL, falha ao desenhar e limpeza.
|
||||||
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega o callback do useFrame; o `three`
|
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
|
||||||
* é o de verdade, com ShaderMaterial e BufferGeometry registrando as instâncias criadas.
|
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
|
||||||
|
* que o teste escolhe.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type FrameState = {
|
const mockMaterials: ShaderMaterial[] = [];
|
||||||
clock: { elapsedTime: number };
|
const mockGeometries: BufferGeometry[] = [];
|
||||||
invalidate: () => void;
|
const mockCameras: PerspectiveCamera[] = [];
|
||||||
};
|
const mockGroups: Group[] = [];
|
||||||
const mockState: {
|
|
||||||
frame: ((state: FrameState, delta: number) => void) | null;
|
|
||||||
three: unknown;
|
|
||||||
throwOnUseThree: boolean;
|
|
||||||
} = { frame: null, three: null, throwOnUseThree: false };
|
|
||||||
const mockMaterials: THREE.ShaderMaterial[] = [];
|
|
||||||
const mockCounts = { vectors: 0 };
|
const mockCounts = { vectors: 0 };
|
||||||
const mockGeometries: THREE.BufferGeometry[] = [];
|
const mockRenderers: MockRenderer[] = [];
|
||||||
|
let mockRenderThrows = false;
|
||||||
|
|
||||||
|
class MockRenderer {
|
||||||
|
domElement = document.createElement("canvas");
|
||||||
|
pixelRatio = 2;
|
||||||
|
render = jest.fn(() => {
|
||||||
|
if (mockRenderThrows) throw new Error("falha ao desenhar");
|
||||||
|
});
|
||||||
|
dispose = jest.fn();
|
||||||
|
forceContextLoss = jest.fn();
|
||||||
|
setSize = jest.fn();
|
||||||
|
setPixelRatio = jest.fn();
|
||||||
|
getPixelRatio = () => this.pixelRatio;
|
||||||
|
constructor() {
|
||||||
|
mockRenderers.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
jest.mock("three", () => {
|
jest.mock("three", () => {
|
||||||
const actual = jest.requireActual("three");
|
const actual = jest.requireActual("three");
|
||||||
class CapturingMaterial extends actual.ShaderMaterial {
|
class CapturingMaterial extends actual.ShaderMaterial {
|
||||||
constructor(parameters: unknown) {
|
constructor(parameters: unknown) {
|
||||||
super(parameters);
|
super(parameters);
|
||||||
mockMaterials.push(this as unknown as THREE.ShaderMaterial);
|
mockMaterials.push(this as unknown as ShaderMaterial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class CapturingGeometry extends actual.BufferGeometry {
|
class CapturingGeometry extends actual.BufferGeometry {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
mockGeometries.push(this as unknown as BufferGeometry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class CapturingCamera extends actual.PerspectiveCamera {
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
super(...args);
|
||||||
|
mockCameras.push(this as unknown as PerspectiveCamera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class CapturingGroup extends actual.Group {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
mockGroups.push(this as unknown as Group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class CountingVector3 extends actual.Vector3 {
|
class CountingVector3 extends actual.Vector3 {
|
||||||
@@ -48,22 +88,14 @@ jest.mock("three", () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
|
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
||||||
ShaderMaterial: CapturingMaterial,
|
ShaderMaterial: CapturingMaterial,
|
||||||
BufferGeometry: CapturingGeometry,
|
BufferGeometry: CapturingGeometry,
|
||||||
|
PerspectiveCamera: CapturingCamera,
|
||||||
|
Group: CapturingGroup,
|
||||||
Vector3: CountingVector3,
|
Vector3: CountingVector3,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
jest.mock("@react-three/fiber", () => ({
|
|
||||||
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
||||||
useFrame: (callback: (state: FrameState, delta: number) => void) => {
|
|
||||||
mockState.frame = callback;
|
|
||||||
},
|
|
||||||
useThree: () => {
|
|
||||||
if (mockState.throwOnUseThree) throw new Error("sem WebGL");
|
|
||||||
return mockState.three;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
jest.mock("./FramePulse", () => ({ __esModule: true, default: () => null }));
|
|
||||||
|
|
||||||
type Setup = {
|
type Setup = {
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -73,76 +105,66 @@ type Setup = {
|
|||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let engines: FieldEngine[] = [];
|
||||||
|
let frameRequests: jest.SpyInstance;
|
||||||
|
|
||||||
function setup({
|
function setup({
|
||||||
width = 1200,
|
width = 1200,
|
||||||
height = 800,
|
height = 800,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
visible = true,
|
visible = true,
|
||||||
}: Setup = {}) {
|
}: Setup = {}) {
|
||||||
const domElement = document.createElement("canvas");
|
const host = document.createElement("div");
|
||||||
const camera = new THREE.PerspectiveCamera(35, width / height, 0.05, 120);
|
Object.defineProperty(host, "clientWidth", { value: width });
|
||||||
camera.position.set(0, 0, 9);
|
Object.defineProperty(host, "clientHeight", { value: height });
|
||||||
camera.updateMatrixWorld();
|
document.body.append(host);
|
||||||
mockState.three = {
|
|
||||||
gl: { domElement, getPixelRatio: () => 2 },
|
|
||||||
camera,
|
|
||||||
size: { width, height },
|
|
||||||
};
|
|
||||||
const progressRef = {
|
const progressRef = {
|
||||||
current: progress === null ? undefined : progress,
|
current: progress === null ? undefined : progress,
|
||||||
} as { current: number };
|
} as { current: number };
|
||||||
const onReady = jest.fn();
|
const onReady = jest.fn();
|
||||||
const onError = jest.fn();
|
const onError = jest.fn();
|
||||||
const element = (isVisible: boolean) => (
|
const engine = createField(host, { progressRef, onReady, onError });
|
||||||
<HeroFieldScene
|
engines.push(engine);
|
||||||
progressRef={progressRef}
|
engine.setVisible(visible);
|
||||||
visible={isVisible}
|
|
||||||
onReady={onReady}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const view = render(element(visible));
|
|
||||||
|
|
||||||
// <group> é um elemento desconhecido no jsdom: ganha o que o três espera de um THREE.Group
|
const renderer = mockRenderers[mockRenderers.length - 1];
|
||||||
const group = view.container.querySelector("group") as HTMLElement | null;
|
const camera = mockCameras[mockCameras.length - 1];
|
||||||
const real = new THREE.Group();
|
const group = mockGroups[mockGroups.length - 1];
|
||||||
if (group)
|
|
||||||
Object.assign(group, {
|
|
||||||
rotation: real.rotation,
|
|
||||||
position: real.position,
|
|
||||||
matrixWorld: real.matrixWorld,
|
|
||||||
updateMatrixWorld: () => real.updateMatrixWorld(true),
|
|
||||||
worldToLocal: (point: THREE.Vector3) => point,
|
|
||||||
});
|
|
||||||
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
||||||
|
let clock = 1000;
|
||||||
|
|
||||||
function frame(time: number, delta = 1 / 60) {
|
/**
|
||||||
const invalidate = jest.fn();
|
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
|
||||||
act(() => {
|
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
|
||||||
mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
|
*/
|
||||||
});
|
function frame(deltaMs = 1000 / 60) {
|
||||||
return invalidate;
|
frameRequests.mockClear();
|
||||||
|
clock += deltaMs;
|
||||||
|
engine.renderAt(clock);
|
||||||
|
return frameRequests.mock.calls.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
||||||
function settle(count: number, delta = 1 / 20, startTime = 100) {
|
function settle(count: number, deltaMs = 50) {
|
||||||
let last = jest.fn();
|
let asked = 0;
|
||||||
for (let i = 0; i < count; i++) last = frame(startTime + i * delta, delta);
|
for (let i = 0; i < count; i++) asked = frame(deltaMs);
|
||||||
return last;
|
return asked;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...view,
|
host,
|
||||||
|
engine,
|
||||||
|
renderer,
|
||||||
camera,
|
camera,
|
||||||
domElement,
|
group,
|
||||||
progressRef,
|
progressRef,
|
||||||
onReady,
|
onReady,
|
||||||
onError,
|
onError,
|
||||||
group: real,
|
|
||||||
uniforms,
|
uniforms,
|
||||||
frame,
|
frame,
|
||||||
settle,
|
settle,
|
||||||
rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
|
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
|
||||||
|
now: () => clock,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,61 +180,59 @@ function setCores(value: number | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setWindowWidth(value: number) {
|
function setWindowSize(width: number, height = 800) {
|
||||||
Object.defineProperty(window, "innerWidth", {
|
Object.defineProperty(window, "innerWidth", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value,
|
value: width,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: height,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointerMove(type: string, x = 600, y = 400) {
|
function pointerMove(type: string, x = 600, y = 400) {
|
||||||
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
||||||
Object.defineProperty(event, "pointerType", { value: type });
|
Object.defineProperty(event, "pointerType", { value: type });
|
||||||
act(() => {
|
window.dispatchEvent(event);
|
||||||
window.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockCounts.vectors = 0;
|
mockCounts.vectors = 0;
|
||||||
mockMaterials.length = 0;
|
mockMaterials.length = 0;
|
||||||
mockGeometries.length = 0;
|
mockGeometries.length = 0;
|
||||||
mockState.frame = null;
|
mockCameras.length = 0;
|
||||||
mockState.throwOnUseThree = false;
|
mockGroups.length = 0;
|
||||||
|
mockRenderers.length = 0;
|
||||||
|
mockRenderThrows = false;
|
||||||
|
engines = [];
|
||||||
setCores(8);
|
setCores(8);
|
||||||
setWindowWidth(1200);
|
setWindowSize(1200);
|
||||||
// as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
|
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
jest.spyOn(performance, "now").mockReturnValue(1000);
|
||||||
|
frameRequests = jest
|
||||||
|
.spyOn(window, "requestAnimationFrame")
|
||||||
|
.mockImplementation(() => 1 as unknown as number);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
for (const engine of engines) engine.dispose();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
if (originalHardware)
|
if (originalHardware)
|
||||||
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
describe("createField: quadro — começo e rolagem", () => {
|
||||||
it("path: the group is gone (unmounted) -> the callback returns at once, nothing is requested", () => {
|
|
||||||
const scene = setup();
|
|
||||||
const callback = mockState.frame;
|
|
||||||
scene.unmount();
|
|
||||||
const invalidate = jest.fn();
|
|
||||||
|
|
||||||
callback?.({ clock: { elapsedTime: 1 }, invalidate }, 1 / 60);
|
|
||||||
|
|
||||||
expect(invalidate).not.toHaveBeenCalled();
|
|
||||||
expect(scene.onReady).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: onReady fires on the second frame, once", () => {
|
it("path: onReady fires on the second frame, once", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
|
|
||||||
scene.frame(0);
|
scene.frame();
|
||||||
expect(scene.onReady).not.toHaveBeenCalled();
|
expect(scene.onReady).not.toHaveBeenCalled();
|
||||||
scene.frame(0.02);
|
scene.frame();
|
||||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
scene.frame(0.04);
|
scene.frame();
|
||||||
scene.frame(0.06);
|
scene.frame();
|
||||||
|
|
||||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -220,9 +240,11 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
|||||||
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
||||||
const scene = setup({ progress: 1 });
|
const scene = setup({ progress: 1 });
|
||||||
|
|
||||||
scene.frame(0, 5); // delta enorme: o passo é limitado a 1/20 de segundo
|
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
|
||||||
|
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
|
||||||
|
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
|
||||||
|
|
||||||
const expected = 1 - Math.exp(-(1 / 20) * 5);
|
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
|
||||||
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
||||||
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
||||||
});
|
});
|
||||||
@@ -238,8 +260,10 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
|||||||
it("path: the intro advances every frame and stops at 1", () => {
|
it("path: the intro advances every frame and stops at 1", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
|
|
||||||
scene.frame(0, 1 / 20);
|
scene.frame(50); // o primeiro quadro: 1/60 de segundo
|
||||||
expect(scene.uniforms().uIntro.value).toBeCloseTo(0.05 / 2.6, 9);
|
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
|
||||||
|
scene.frame(50);
|
||||||
|
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
|
||||||
|
|
||||||
scene.settle(80);
|
scene.settle(80);
|
||||||
|
|
||||||
@@ -271,13 +295,20 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
|||||||
|
|
||||||
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("path: the clock reaches the shader in seconds since the scene started", () => {
|
||||||
|
const scene = setup();
|
||||||
|
scene.frame(0);
|
||||||
|
scene.frame(2000);
|
||||||
|
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Field: enquadramento e foco", () => {
|
describe("createField: enquadramento e foco", () => {
|
||||||
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
||||||
const scene = setup({ width: 1200, height: 800 });
|
const scene = setup({ width: 1200, height: 800 });
|
||||||
|
|
||||||
scene.frame(0);
|
scene.frame();
|
||||||
|
|
||||||
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
||||||
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
||||||
@@ -286,12 +317,26 @@ describe("Field: enquadramento e foco", () => {
|
|||||||
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
||||||
const scene = setup({ width: 400, height: 800 });
|
const scene = setup({ width: 400, height: 800 });
|
||||||
|
|
||||||
scene.frame(0);
|
scene.frame();
|
||||||
|
|
||||||
expect(scene.group.position.y).toBeLessThan(-0.5);
|
expect(scene.group.position.y).toBeLessThan(-0.5);
|
||||||
expect(scene.camera.position.z).toBeGreaterThan(9);
|
expect(scene.camera.position.z).toBeGreaterThan(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("path: the renderer and the camera follow the size of the host", () => {
|
||||||
|
const scene = setup({ width: 900, height: 300 });
|
||||||
|
|
||||||
|
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
|
||||||
|
expect(scene.camera.aspect).toBeCloseTo(3, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
|
||||||
|
const scene = setup({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
|
||||||
|
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
|
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 });
|
const scene = setup({ progress: 1 });
|
||||||
|
|
||||||
@@ -308,7 +353,7 @@ describe("Field: enquadramento e foco", () => {
|
|||||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||||
const scene = setup({ progress: 0 });
|
const scene = setup({ progress: 0 });
|
||||||
|
|
||||||
scene.frame(0);
|
scene.frame();
|
||||||
|
|
||||||
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
||||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
||||||
@@ -341,56 +386,56 @@ describe("Field: enquadramento e foco", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Field: mouse", () => {
|
describe("createField: mouse", () => {
|
||||||
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
|
||||||
pointerMove("touch");
|
pointerMove("touch");
|
||||||
pointerMove("pen");
|
pointerMove("pen");
|
||||||
|
|
||||||
scene.frame(5.5);
|
|
||||||
|
|
||||||
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
||||||
scene.settle(120, 1 / 20, 5.5);
|
scene.settle(120);
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
scene.frame(); // o relógio da cena começa
|
||||||
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
|
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
scene.frame(0);
|
||||||
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
|
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest.spyOn(performance, "now").mockReturnValue(0);
|
(performance.now as jest.Mock).mockReturnValue(0);
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
|
for (let i = 0; i < 30; i++) scene.frame(50);
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
||||||
const scene = setup({ progress: 1 });
|
const scene = setup({ progress: 1 });
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
scene.frame();
|
||||||
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
|
for (let i = 0; i < 200; i++) scene.frame(5);
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
||||||
});
|
});
|
||||||
@@ -398,90 +443,91 @@ describe("Field: mouse", () => {
|
|||||||
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest
|
jest
|
||||||
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
.spyOn(Ray.prototype, "intersectPlane")
|
||||||
.mockImplementation(((_plane: THREE.Plane, target: THREE.Vector3) =>
|
.mockImplementation(((_plane: Plane, target: Vector3) =>
|
||||||
target.set(1, 0, 2)) as never);
|
target.set(1, 0, 2)) as never);
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
|
||||||
|
scene.group.worldToLocal = ((point: Vector3) => point) as never;
|
||||||
|
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||||
|
const before = scene.uniforms().uMouse.value.clone();
|
||||||
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
scene.frame(5.1, 1 / 20);
|
scene.frame(50);
|
||||||
|
|
||||||
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
||||||
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(factor * 1, 6);
|
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
|
||||||
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(factor * 2, 6);
|
before.x + (1 - before.x) * factor,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
|
||||||
|
before.y + (2 - before.y) * factor,
|
||||||
|
6,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest
|
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
|
||||||
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||||
.mockReturnValue(null as never);
|
const before = scene.uniforms().uMouse.value.clone();
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
scene.frame(5.1, 1 / 20);
|
scene.frame(50);
|
||||||
|
|
||||||
expect(scene.uniforms().uMouse.value.x).toBe(0);
|
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
|
||||||
expect(scene.uniforms().uMouse.value.y).toBe(0);
|
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
|
|
||||||
scene.frame(10, 1 / 20);
|
scene.frame(0);
|
||||||
|
scene.frame(9000);
|
||||||
|
|
||||||
const { x, y } = scene.uniforms().uMouse.value;
|
const { x, y } = scene.uniforms().uMouse.value;
|
||||||
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Field: quando pedir o próximo quadro", () => {
|
describe("createField: quando pedir o próximo quadro", () => {
|
||||||
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
||||||
const scene = setup({ visible: false });
|
const scene = setup({ visible: false });
|
||||||
|
|
||||||
const invalidate = scene.frame(0, 1 / 60);
|
expect(scene.frame()).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(invalidate).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
||||||
const scene = setup({ visible: true });
|
const scene = setup({ visible: true });
|
||||||
scene.settle(200);
|
scene.settle(200);
|
||||||
|
|
||||||
const invalidate = scene.frame(500, 1 / 60);
|
expect(scene.frame()).toBe(0);
|
||||||
|
|
||||||
expect(invalidate).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: everything settled and the hero is not visible -> no frame", () => {
|
it("path: everything settled and the hero is not visible -> no frame", () => {
|
||||||
const scene = setup({ visible: false });
|
const scene = setup({ visible: false });
|
||||||
scene.settle(200);
|
scene.settle(200);
|
||||||
|
|
||||||
const invalidate = scene.frame(500, 1 / 60);
|
expect(scene.frame()).toBe(0);
|
||||||
|
|
||||||
expect(invalidate).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
||||||
const scene = setup({ visible: true });
|
const scene = setup({ visible: true });
|
||||||
scene.settle(200);
|
scene.settle(200);
|
||||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
const invalidate = scene.frame(600.1, 1 / 60);
|
expect(scene.frame()).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(invalidate).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||||
const scene = setup({ visible: false });
|
const scene = setup({ visible: false });
|
||||||
scene.settle(200);
|
scene.settle(200);
|
||||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
|
|
||||||
const invalidate = scene.frame(600.1, 1 / 60);
|
expect(scene.frame()).toBe(0);
|
||||||
|
|
||||||
expect(invalidate).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||||
@@ -489,25 +535,160 @@ describe("Field: quando pedir o próximo quadro", () => {
|
|||||||
scene.settle(200);
|
scene.settle(200);
|
||||||
scene.progressRef.current = 0.5;
|
scene.progressRef.current = 0.5;
|
||||||
|
|
||||||
const invalidate = scene.frame(500, 1 / 60);
|
expect(scene.frame()).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(invalidate).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: becoming visible later -> the new callback sees the new flag", () => {
|
it("path: becoming visible later -> the next frame sees the new flag", () => {
|
||||||
const scene = setup({ visible: false });
|
const scene = setup({ visible: false });
|
||||||
scene.settle(200);
|
scene.settle(200);
|
||||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
|
expect(scene.frame()).toBe(0);
|
||||||
|
|
||||||
scene.rerender(true);
|
scene.engine.setVisible(true);
|
||||||
|
|
||||||
expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
|
expect(scene.frame()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: several requests before a frame become ONE animation frame", () => {
|
||||||
|
const scene = setup({ visible: true });
|
||||||
|
scene.settle(200);
|
||||||
|
frameRequests.mockClear();
|
||||||
|
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
|
||||||
|
scene.engine.setVisible(false);
|
||||||
|
scene.engine.setVisible(true);
|
||||||
|
scene.engine.setVisible(false);
|
||||||
|
scene.engine.setVisible(true);
|
||||||
|
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||||
|
function rig(): Rig {
|
||||||
|
const scene = setup();
|
||||||
|
scene.frame();
|
||||||
|
const material = mockMaterials[mockMaterials.length - 1];
|
||||||
|
return {
|
||||||
|
group: scene.group,
|
||||||
|
camera: scene.camera,
|
||||||
|
uniforms: material.uniforms,
|
||||||
|
layout: {
|
||||||
|
wide: true,
|
||||||
|
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
|
||||||
|
spreadMax: 5.5,
|
||||||
|
rest: 9,
|
||||||
|
scale: 1000,
|
||||||
|
camera: [
|
||||||
|
[0, 9],
|
||||||
|
[1, -1],
|
||||||
|
],
|
||||||
|
} as unknown as Rig["layout"],
|
||||||
|
state: {
|
||||||
|
progress: 0,
|
||||||
|
pointer: { set() {}, x: 0, y: 0 } as never,
|
||||||
|
lastMove: -10,
|
||||||
|
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
|
||||||
|
tilt: { lerp() {}, x: 0, y: 0 } as never,
|
||||||
|
frames: 10,
|
||||||
|
},
|
||||||
|
helpers: {} as Rig["helpers"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (overrides: Partial<Tick> = {}): Tick => ({
|
||||||
|
p: 0,
|
||||||
|
face: 0,
|
||||||
|
time: 1,
|
||||||
|
dt: 1 / 60,
|
||||||
|
cursorActive: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needsFrame: settling wins over everything, interaction only counts on screen", () => {
|
||||||
|
const r = rig();
|
||||||
|
r.uniforms.uIntro.value = 1;
|
||||||
|
r.uniforms.uMouseAmt.value = 0.4;
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(false);
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, true)).toBe(true);
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, false)).toBe(false);
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0.5, 0.4, false, false)).toBe(true);
|
||||||
|
r.uniforms.uIntro.value = 0.5;
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => {
|
||||||
|
const r = rig();
|
||||||
|
r.uniforms.uIntro.value = 1;
|
||||||
|
r.state.frames = 1;
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
||||||
|
const r = rig();
|
||||||
|
r.helpers = {
|
||||||
|
target: { set: () => ({}), x: 0, y: 0 },
|
||||||
|
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
|
||||||
|
plane: { set: () => ({ applyMatrix4() {} }) },
|
||||||
|
up: {},
|
||||||
|
hit: {},
|
||||||
|
} as never;
|
||||||
|
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
|
||||||
|
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
|
||||||
|
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
|
||||||
|
const r = rig();
|
||||||
|
updateRig(r, tick({ face: 0 }));
|
||||||
|
const resting = r.group.rotation.x;
|
||||||
|
updateRig(r, tick({ face: 1 }));
|
||||||
|
expect(resting).toBeCloseTo(0.42, 9);
|
||||||
|
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
|
||||||
|
// de frente, o balanço lateral some
|
||||||
|
expect(r.group.rotation.y).toBeCloseTo(0, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needsFrame: the cursor strength alone still far from its target asks for a frame, but only on screen", () => {
|
||||||
|
const r = rig();
|
||||||
|
r.uniforms.uIntro.value = 1;
|
||||||
|
r.uniforms.uMouseAmt.value = 0;
|
||||||
|
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(true);
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, false)).toBe(false);
|
||||||
|
// dentro da tolerância de 0,01 já está assentado
|
||||||
|
r.uniforms.uMouseAmt.value = 0.795;
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needsFrame: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
||||||
|
const r = rig();
|
||||||
|
r.uniforms.uIntro.value = 1;
|
||||||
|
r.uniforms.uMouseAmt.value = 0.4;
|
||||||
|
r.state.progress = 0.5;
|
||||||
|
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0.50039, 0.4, false, false)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(needsFrame(r.uniforms, r.state, 0.5005, 0.4, false, false)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
||||||
|
const r = rig();
|
||||||
|
|
||||||
|
updateRig(r, tick({ p: -0.5 }));
|
||||||
|
expect(r.camera.position.z).toBe(9);
|
||||||
|
|
||||||
|
updateRig(r, tick({ p: 0.5 }));
|
||||||
|
expect(r.camera.position.z).toBeCloseTo(4, 9);
|
||||||
|
|
||||||
|
updateRig(r, tick({ p: 2 }));
|
||||||
|
expect(r.camera.position.z).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
|
||||||
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
||||||
prepareField();
|
prepareField();
|
||||||
expect(mockGeometries).toHaveLength(1);
|
expect(mockGeometries).toHaveLength(1);
|
||||||
@@ -531,40 +712,42 @@ describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
|||||||
expect(mockGeometries).toHaveLength(2);
|
expect(mockGeometries).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: no vector is created per frame, not even with the mouse driving the cursor over the layer", () => {
|
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
scene.frame(0);
|
||||||
|
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||||
pointerMove("mouse");
|
pointerMove("mouse");
|
||||||
scene.frame(5.1, 1 / 60);
|
scene.frame();
|
||||||
const before = mockCounts.vectors;
|
const before = mockCounts.vectors;
|
||||||
|
|
||||||
for (let i = 0; i < 60; i++) scene.frame(5.2 + i / 60, 1 / 60);
|
for (let i = 0; i < 60; i++) scene.frame();
|
||||||
|
|
||||||
|
// nenhum Vector3 novo: os auxiliares são criados uma vez só
|
||||||
expect(mockCounts.vectors).toBe(before);
|
expect(mockCounts.vectors).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Field: densidade, contexto WebGL e limpeza", () => {
|
describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
|
||||||
const pointCount = () => {
|
const pointCount = () => {
|
||||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||||
return geometry.getAttribute("position").count;
|
return geometry.getAttribute("position").count;
|
||||||
};
|
};
|
||||||
|
|
||||||
it("path: wide window and 4+ cores -> the dense field", () => {
|
it("path: wide window and 4+ cores -> the dense field", () => {
|
||||||
setWindowWidth(1200);
|
setWindowSize(1200);
|
||||||
setCores(8);
|
setCores(8);
|
||||||
setup();
|
setup();
|
||||||
const dense = pointCount();
|
const dense = pointCount();
|
||||||
|
|
||||||
mockGeometries.length = 0;
|
mockGeometries.length = 0;
|
||||||
setWindowWidth(600);
|
setWindowSize(600);
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
expect(dense).toBeGreaterThan(pointCount());
|
expect(dense).toBeGreaterThan(pointCount());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: wide window but 2 cores -> the light field", () => {
|
it("path: wide window but 2 cores -> the light field", () => {
|
||||||
setWindowWidth(1200);
|
setWindowSize(1200);
|
||||||
setCores(2);
|
setCores(2);
|
||||||
setup();
|
setup();
|
||||||
const light = pointCount();
|
const light = pointCount();
|
||||||
@@ -577,7 +760,7 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
||||||
setWindowWidth(1200);
|
setWindowSize(1200);
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const [label, cores] of [
|
for (const [label, cores] of [
|
||||||
["dense", 8],
|
["dense", 8],
|
||||||
@@ -599,15 +782,22 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
|||||||
const scene = setup();
|
const scene = setup();
|
||||||
const event = new Event("webglcontextlost", { cancelable: true });
|
const event = new Event("webglcontextlost", { cancelable: true });
|
||||||
|
|
||||||
act(() => {
|
scene.renderer.domElement.dispatchEvent(event);
|
||||||
scene.domElement.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
expect(event.defaultPrevented).toBe(true);
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: unmount -> geometry and material are disposed and the listeners go away", () => {
|
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
|
||||||
|
const scene = setup();
|
||||||
|
mockRenderThrows = true;
|
||||||
|
|
||||||
|
expect(() => scene.frame()).not.toThrow();
|
||||||
|
|
||||||
|
expect(scene.onError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||||
const material = mockMaterials[mockMaterials.length - 1];
|
const material = mockMaterials[mockMaterials.length - 1];
|
||||||
@@ -615,38 +805,60 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
|||||||
const disposeMaterial = jest.spyOn(material, "dispose");
|
const disposeMaterial = jest.spyOn(material, "dispose");
|
||||||
const event = new Event("webglcontextlost", { cancelable: true });
|
const event = new Event("webglcontextlost", { cancelable: true });
|
||||||
|
|
||||||
scene.unmount();
|
scene.engine.dispose();
|
||||||
scene.domElement.dispatchEvent(event);
|
scene.renderer.domElement.dispatchEvent(event);
|
||||||
|
pointerMove("mouse");
|
||||||
|
|
||||||
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
||||||
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scene.renderer.domElement.isConnected).toBe(false);
|
||||||
expect(event.defaultPrevented).toBe(false);
|
expect(event.defaultPrevented).toBe(false);
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
expect(scene.onError).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("SceneBoundary", () => {
|
it("path: a frame after dispose does nothing", () => {
|
||||||
it("path: a child fails while drawing -> nothing is rendered and the page is told once", () => {
|
|
||||||
mockState.throwOnUseThree = true;
|
|
||||||
const onError = jest.fn();
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<HeroFieldScene
|
|
||||||
progressRef={{ current: 0 }}
|
|
||||||
visible
|
|
||||||
onReady={() => {}}
|
|
||||||
onError={onError}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
|
||||||
expect(onError).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: no failure -> the scene is rendered and onError is not called", () => {
|
|
||||||
const scene = setup();
|
const scene = setup();
|
||||||
|
scene.engine.dispose();
|
||||||
|
scene.renderer.render.mockClear();
|
||||||
|
|
||||||
expect(scene.container.querySelector("group")).not.toBeNull();
|
scene.frame();
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
|
||||||
|
expect(scene.renderer.render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
|
||||||
|
const original = globalThis.ResizeObserver;
|
||||||
|
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
|
||||||
|
globalThis.ResizeObserver = undefined;
|
||||||
|
try {
|
||||||
|
const scene = setup({ width: 600, height: 400 });
|
||||||
|
scene.renderer.setSize.mockClear();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
|
||||||
|
} finally {
|
||||||
|
globalThis.ResizeObserver = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createField: a batida de 30 quadros por segundo", () => {
|
||||||
|
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
|
||||||
|
const interval = jest
|
||||||
|
.spyOn(window, "setInterval")
|
||||||
|
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
|
||||||
|
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||||
|
const { engine } = setup({ visible: false });
|
||||||
|
expect(interval).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
engine.setVisible(true);
|
||||||
|
engine.setVisible(true);
|
||||||
|
expect(interval).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
engine.setVisible(false);
|
||||||
|
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
|
||||||
|
engine.setVisible(true);
|
||||||
|
expect(interval).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,51 +1,241 @@
|
|||||||
import { render } from "@testing-library/react";
|
import { act, render } from "@testing-library/react";
|
||||||
import HeroFieldScene from "./HeroFieldScene";
|
import HeroFieldScene from "./HeroFieldScene";
|
||||||
|
|
||||||
type CanvasProps = {
|
/*
|
||||||
dpr?: [number, number];
|
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
||||||
frameloop?: string;
|
* liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê.
|
||||||
gl?: Record<string, unknown>;
|
*/
|
||||||
};
|
|
||||||
const mockCanvas: CanvasProps[] = [];
|
|
||||||
|
|
||||||
jest.mock("@react-three/fiber", () => ({
|
type RendererOptions = {
|
||||||
Canvas: (props: CanvasProps) => {
|
alpha?: boolean;
|
||||||
mockCanvas.push(props);
|
antialias?: boolean;
|
||||||
return null;
|
powerPreference?: string;
|
||||||
},
|
};
|
||||||
useFrame: jest.fn(),
|
const mockRenderers: MockRenderer[] = [];
|
||||||
useThree: jest.fn(),
|
let mockFailToCreate = false;
|
||||||
|
|
||||||
|
class MockRenderer {
|
||||||
|
domElement = document.createElement("canvas");
|
||||||
|
pixelRatio = 1;
|
||||||
|
options: RendererOptions;
|
||||||
|
render = jest.fn();
|
||||||
|
dispose = jest.fn();
|
||||||
|
forceContextLoss = jest.fn();
|
||||||
|
setSize = jest.fn();
|
||||||
|
setPixelRatio = (value: number) => {
|
||||||
|
this.pixelRatio = value;
|
||||||
|
};
|
||||||
|
getPixelRatio = () => this.pixelRatio;
|
||||||
|
constructor(options: RendererOptions) {
|
||||||
|
if (mockFailToCreate) throw new Error("sem WebGL");
|
||||||
|
this.options = options;
|
||||||
|
mockRenderers.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
|
||||||
|
jest.mock("three", () => ({
|
||||||
|
...jest.requireActual("three"),
|
||||||
|
WebGLRenderer: jest.fn(
|
||||||
|
(options: RendererOptions) => new MockRenderer(options),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const props = () => ({
|
||||||
|
progressRef: { current: 0 },
|
||||||
|
visible: false,
|
||||||
|
onReady: jest.fn(),
|
||||||
|
onError: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function setDevicePixelRatio(value: number) {
|
||||||
|
Object.defineProperty(window, "devicePixelRatio", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockRenderers.length = 0;
|
||||||
|
mockFailToCreate = false;
|
||||||
|
setDevicePixelRatio(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe("HeroFieldScene (ambient 3D)", () => {
|
describe("HeroFieldScene (ambient 3D)", () => {
|
||||||
beforeEach(() => {
|
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => {
|
||||||
mockCanvas.length = 0;
|
const { container } = render(<HeroFieldScene {...props()} />);
|
||||||
|
|
||||||
|
expect(mockRenderers).toHaveLength(1);
|
||||||
|
expect(mockRenderers[0].options).toMatchObject({
|
||||||
|
alpha: true,
|
||||||
|
antialias: false,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
const canvas = container.querySelector("canvas");
|
||||||
|
expect(canvas).toBe(mockRenderers[0].domElement);
|
||||||
|
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws on demand, never on a fixed 60 fps loop, and caps the pixel ratio at 1.5", () => {
|
it.each([
|
||||||
render(
|
[0.5, 1],
|
||||||
<HeroFieldScene
|
[1, 1],
|
||||||
progressRef={{ current: 0 }}
|
[1.25, 1.25],
|
||||||
visible
|
[2, 1.5],
|
||||||
onReady={() => {}}
|
[3, 1.5],
|
||||||
onError={() => {}}
|
])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => {
|
||||||
/>,
|
setDevicePixelRatio(device);
|
||||||
|
render(<HeroFieldScene {...props()} />);
|
||||||
|
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => {
|
||||||
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
render(<HeroFieldScene {...props()} />);
|
||||||
|
expect(frames).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
const settled = frames.mock.calls.length;
|
||||||
|
expect(settled).toBeGreaterThan(2);
|
||||||
|
expect(settled).toBeLessThan(5000 / 16);
|
||||||
|
|
||||||
|
// hero fora de cena e tudo assentado: nenhum quadro a mais
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
expect(frames.mock.calls.length).toBe(settled);
|
||||||
|
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the page it is ready after the second frame", () => {
|
||||||
|
const scene = props();
|
||||||
|
render(<HeroFieldScene {...scene} />);
|
||||||
|
expect(scene.onReady).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => {
|
||||||
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
const scene = props();
|
||||||
|
const { rerender } = render(<HeroFieldScene {...scene} visible={false} />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(6000);
|
||||||
|
});
|
||||||
|
const hidden = frames.mock.calls.length;
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
|
||||||
|
rerender(<HeroFieldScene {...scene} visible />);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
|
||||||
|
});
|
||||||
|
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(980);
|
||||||
|
});
|
||||||
|
const shown = frames.mock.calls.length - hidden;
|
||||||
|
expect(shown).toBeGreaterThan(20);
|
||||||
|
expect(shown).toBeLessThanOrEqual(34);
|
||||||
|
|
||||||
|
rerender(<HeroFieldScene {...scene} visible={false} />);
|
||||||
|
const stopped = frames.mock.calls.length;
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
expect(frames.mock.calls.length).toBe(stopped);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the context, the canvas and the timers when it unmounts", () => {
|
||||||
|
const scene = props();
|
||||||
|
const { container, unmount } = render(
|
||||||
|
<HeroFieldScene {...scene} visible />,
|
||||||
);
|
);
|
||||||
const props = mockCanvas.at(-1);
|
expect(container.querySelector("canvas")).not.toBeNull();
|
||||||
expect(props?.frameloop).toBe("demand");
|
|
||||||
expect(props?.dpr).toEqual([1, 1.5]);
|
unmount();
|
||||||
expect(props?.gl).toMatchObject({ alpha: true, antialias: false });
|
|
||||||
|
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => {
|
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => {
|
||||||
render(
|
const first = props();
|
||||||
|
const { rerender } = render(<HeroFieldScene {...first} />);
|
||||||
|
const second = props();
|
||||||
|
rerender(
|
||||||
<HeroFieldScene
|
<HeroFieldScene
|
||||||
progressRef={{ current: 0 }}
|
progressRef={first.progressRef}
|
||||||
visible={false}
|
visible={false}
|
||||||
onReady={() => {}}
|
onReady={second.onReady}
|
||||||
onError={() => {}}
|
onError={second.onError}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(mockCanvas.at(-1)?.frameloop).toBe("demand");
|
expect(mockRenderers).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.onReady).not.toHaveBeenCalled();
|
||||||
|
expect(second.onReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => {
|
||||||
|
mockFailToCreate = true;
|
||||||
|
const scene = props();
|
||||||
|
|
||||||
|
const { container } = render(<HeroFieldScene {...scene} />);
|
||||||
|
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.querySelector("canvas")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, 1], // aparelho que não informa a proporção: 1
|
||||||
|
[0.5, 1], // nunca abaixo de 1
|
||||||
|
[1.25, 1.25],
|
||||||
|
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
|
||||||
|
])(
|
||||||
|
"draws at a pixel ratio of %p -> the renderer gets %p",
|
||||||
|
(device, expected) => {
|
||||||
|
setDevicePixelRatio(device);
|
||||||
|
|
||||||
|
render(<HeroFieldScene {...props()} />);
|
||||||
|
|
||||||
|
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => {
|
||||||
|
const scene = props();
|
||||||
|
render(<HeroFieldScene {...scene} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
mockRenderers[0].domElement.dispatchEvent(
|
||||||
|
new Event("webglcontextlost", { cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
import { type RefObject, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Component,
|
AdditiveBlending,
|
||||||
type ReactNode,
|
BufferAttribute,
|
||||||
type RefObject,
|
BufferGeometry,
|
||||||
useEffect,
|
type Camera,
|
||||||
useMemo,
|
Float32BufferAttribute,
|
||||||
useRef,
|
Group,
|
||||||
} from "react";
|
MathUtils,
|
||||||
import * as THREE from "three";
|
PerspectiveCamera,
|
||||||
import FramePulse from "./FramePulse";
|
Plane,
|
||||||
|
Points,
|
||||||
|
Raycaster,
|
||||||
|
Scene,
|
||||||
|
ShaderMaterial,
|
||||||
|
Sphere,
|
||||||
|
Vector2,
|
||||||
|
Vector3,
|
||||||
|
WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
|
import { startFramePulse } from "./FramePulse";
|
||||||
|
|
||||||
export type HeroFieldSceneProps = {
|
export type HeroFieldSceneProps = {
|
||||||
progressRef: RefObject<number>;
|
progressRef: RefObject<number>;
|
||||||
@@ -30,9 +40,10 @@ export type HeroFieldSceneProps = {
|
|||||||
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
|
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
|
||||||
*
|
*
|
||||||
* É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
|
* É 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
|
* molduras da direita. O Three.js é usado direto (sem React Three Fiber, que levava junto o reconciliador do React
|
||||||
* e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
* só para uma malha e um shader). O desenho é sob demanda: a cena pede um quadro a 30 por segundo (FramePulse, um
|
||||||
* sem cursor nem rolagem (FramePulse), não desenha nada.
|
* setInterval) e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
||||||
|
* sem cursor nem rolagem, não desenha nada.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||||
@@ -259,19 +270,16 @@ function buildField(dense: boolean) {
|
|||||||
n * 4,
|
n * 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new BufferGeometry();
|
||||||
geometry.setAttribute(
|
geometry.setAttribute("position", new Float32BufferAttribute(f.base, 3));
|
||||||
"position",
|
geometry.setAttribute("aTo", new Float32BufferAttribute(f.to, 3));
|
||||||
new THREE.Float32BufferAttribute(f.base, 3),
|
geometry.setAttribute("aMove", new Float32BufferAttribute(f.move, 1));
|
||||||
);
|
geometry.setAttribute("aTone", new Float32BufferAttribute(f.tone, 1));
|
||||||
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
|
geometry.setAttribute("aSize", new Float32BufferAttribute(f.size, 1));
|
||||||
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
|
geometry.setAttribute("aAlpha", new Float32BufferAttribute(f.alpha, 1));
|
||||||
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
|
geometry.setAttribute("aScatter", new BufferAttribute(scatter, 3));
|
||||||
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
|
geometry.setAttribute("aRand", new BufferAttribute(rand, 4));
|
||||||
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
|
geometry.boundingSphere = new Sphere(new Vector3(), 12);
|
||||||
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
|
|
||||||
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
|
|
||||||
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
|
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +382,9 @@ const FRAME = 5.2;
|
|||||||
/*
|
/*
|
||||||
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
|
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
|
||||||
* atravessa, enquadra os dados e fica ali (o mergulho não acaba no escuro: Projetos sobe sobre a última
|
* 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
|
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) entram um pouco antes de a
|
||||||
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
|
* câmera chegar a cada camada e saem um pouco depois: tela 0,18–0,49; integrações 0,51–0,75; dados 0,79 em diante
|
||||||
|
* (a última fica até Projetos subir e cobri-la).
|
||||||
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
|
* 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.
|
* camadas se afastam mais para a câmera caber entre elas.
|
||||||
*/
|
*/
|
||||||
@@ -398,7 +407,7 @@ function cameraFrames(rest: number, spread: number, frame: number) {
|
|||||||
const isDense = () =>
|
const isDense = () =>
|
||||||
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
|
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
|
||||||
|
|
||||||
let prepared: THREE.BufferGeometry | null = null;
|
let prepared: BufferGeometry | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
|
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
|
||||||
@@ -416,7 +425,7 @@ function takeField() {
|
|||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Uniforms = THREE.ShaderMaterial["uniforms"];
|
type Uniforms = ShaderMaterial["uniforms"];
|
||||||
|
|
||||||
/** Enquadramento da câmera para o tamanho atual da tela: só muda quando a tela muda, não a cada quadro. */
|
/** Enquadramento da câmera para o tamanho atual da tela: só muda quando a tela muda, não a cada quadro. */
|
||||||
function frameLayout(
|
function frameLayout(
|
||||||
@@ -426,7 +435,7 @@ function frameLayout(
|
|||||||
pixelRatio: number,
|
pixelRatio: number,
|
||||||
) {
|
) {
|
||||||
const aspect = width / height;
|
const aspect = width / height;
|
||||||
const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2));
|
const tanHalf = Math.tan(MathUtils.degToRad(fov / 2));
|
||||||
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
|
// 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 frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
|
||||||
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
|
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
|
||||||
@@ -466,120 +475,140 @@ function updateUniforms(
|
|||||||
|
|
||||||
type FieldState = {
|
type FieldState = {
|
||||||
progress: number;
|
progress: number;
|
||||||
pointer: THREE.Vector2;
|
pointer: Vector2;
|
||||||
lastMove: number;
|
lastMove: number;
|
||||||
mouse: THREE.Vector2;
|
mouse: Vector2;
|
||||||
tilt: THREE.Vector2;
|
tilt: Vector2;
|
||||||
frames: number;
|
frames: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
|
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
|
||||||
type Helpers = {
|
type Helpers = {
|
||||||
ray: THREE.Raycaster;
|
ray: Raycaster;
|
||||||
plane: THREE.Plane;
|
plane: Plane;
|
||||||
up: THREE.Vector3;
|
up: Vector3;
|
||||||
hit: THREE.Vector3;
|
hit: Vector3;
|
||||||
zero: THREE.Vector2;
|
zero: Vector2;
|
||||||
target: THREE.Vector2;
|
target: Vector2;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** O mouse se mexeu há pouco? Sem ele (toque, ou mouse parado), um cursor fantasma percorre a tela. */
|
/** O mouse se mexeu há pouco? Sem ele (toque, ou mouse parado), um cursor fantasma percorre a tela. */
|
||||||
const cursorIsActive = (s: FieldState, time: number) =>
|
const cursorIsActive = (s: FieldState, time: number) =>
|
||||||
s.lastMove > 0 && time - s.lastMove < 2.5;
|
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. */
|
/** Tudo o que um quadro lê e move: o grupo, a câmera, os uniforms, o enquadramento atual, o estado e os auxiliares. */
|
||||||
function updateRig(
|
export type Rig = {
|
||||||
g: THREE.Group,
|
group: Group;
|
||||||
camera: THREE.Camera,
|
camera: Camera;
|
||||||
u: Uniforms,
|
uniforms: Uniforms;
|
||||||
layout: Layout,
|
layout: Layout;
|
||||||
s: FieldState,
|
state: FieldState;
|
||||||
h: Helpers,
|
helpers: Helpers;
|
||||||
p: number,
|
};
|
||||||
face: number,
|
|
||||||
time: number,
|
/** O que muda de um quadro para o outro: progresso, quanto as camadas já estão de frente, relógio, passo e cursor. */
|
||||||
dt: number,
|
export type Tick = {
|
||||||
cursorActive: boolean,
|
p: number;
|
||||||
) {
|
face: number;
|
||||||
s.tilt.lerp(cursorActive ? s.pointer : h.zero, 1 - Math.exp(-dt * 3));
|
time: number;
|
||||||
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
|
dt: number;
|
||||||
g.rotation.set(
|
cursorActive: boolean;
|
||||||
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),
|
/** A cena se inclina devagar para o cursor (ou volta ao centro, sem cursor). */
|
||||||
0,
|
function updateTilt({ state, helpers }: Rig, { dt, cursorActive }: Tick) {
|
||||||
|
state.tilt.lerp(
|
||||||
|
cursorActive ? state.pointer : helpers.zero,
|
||||||
|
1 - Math.exp(-dt * 3),
|
||||||
);
|
);
|
||||||
// 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. */
|
/** Em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas. */
|
||||||
function cursorOnLayer(
|
function updateRotation({ group, state }: Rig, { face, time }: Tick) {
|
||||||
g: THREE.Group,
|
group.rotation.set(
|
||||||
camera: THREE.Camera,
|
0.42 + (Math.PI / 2 - 0.42) * face - state.tilt.y * 0.08,
|
||||||
s: FieldState,
|
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
|
||||||
h: Helpers,
|
state.tilt.x * 0.15 * (1 - face),
|
||||||
spread: number,
|
0,
|
||||||
) {
|
);
|
||||||
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);
|
* Em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras da
|
||||||
h.target.set(h.hit.x, h.hit.z);
|
* direita); no celular, mais abaixo, atrás dos botões.
|
||||||
|
*/
|
||||||
|
function updatePosition({ group, layout }: Rig, { p }: Tick) {
|
||||||
|
const settle = 1 - smooth(0, 0.28, p);
|
||||||
|
const down = layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38;
|
||||||
|
group.position.set(0, down * settle, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camada que a lente enquadra: a próxima à frente da câmera (as de trás desfocam como em uma lente). */
|
||||||
|
function updateFocus({ camera, uniforms }: Rig) {
|
||||||
|
const spread = uniforms.uSpread.value;
|
||||||
|
const behind = camera.position.z - 0.2;
|
||||||
|
let ahead = -spread;
|
||||||
|
if (spread < behind) ahead = spread;
|
||||||
|
else if (0 < behind) ahead = 0;
|
||||||
|
uniforms.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
export function updateRig(rig: Rig, tick: Tick) {
|
||||||
|
updateTilt(rig, tick);
|
||||||
|
updateRotation(rig, tick);
|
||||||
|
updatePosition(rig, tick);
|
||||||
|
rig.camera.position.z = keyframes(rig.layout.camera, tick.p);
|
||||||
|
updateFocus(rig);
|
||||||
|
rig.group.updateMatrixWorld();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em helpers.target. Falso se o raio não toca a camada. */
|
||||||
|
function cursorOnLayer({ group, camera, state, helpers }: Rig, spread: number) {
|
||||||
|
helpers.ray.setFromCamera(state.pointer, camera);
|
||||||
|
helpers.plane.set(helpers.up, -spread).applyMatrix4(group.matrixWorld);
|
||||||
|
if (!helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) return false;
|
||||||
|
group.worldToLocal(helpers.hit);
|
||||||
|
helpers.target.set(helpers.hit.x, helpers.hit.z);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
|
/** Leva a onda do cursor ao mouse (na camada) ou, sem mouse, a um cursor fantasma que percorre a tela. */
|
||||||
function updatePointer(
|
function followCursor(rig: Rig, { time, dt, cursorActive }: Tick) {
|
||||||
g: THREE.Group,
|
const { state, helpers, uniforms } = rig;
|
||||||
camera: THREE.Camera,
|
|
||||||
u: Uniforms,
|
|
||||||
s: FieldState,
|
|
||||||
h: Helpers,
|
|
||||||
p: number,
|
|
||||||
time: number,
|
|
||||||
dt: number,
|
|
||||||
cursorActive: boolean,
|
|
||||||
) {
|
|
||||||
if (cursorActive) {
|
if (cursorActive) {
|
||||||
if (cursorOnLayer(g, camera, s, h, u.uSpread.value))
|
if (cursorOnLayer(rig, uniforms.uSpread.value))
|
||||||
s.mouse.lerp(h.target, 1 - Math.exp(-dt * 10));
|
state.mouse.lerp(helpers.target, 1 - Math.exp(-dt * 10));
|
||||||
} else {
|
return;
|
||||||
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);
|
state.mouse.lerp(
|
||||||
const targetAmt = (cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
helpers.target.set(
|
||||||
u.uMouseAmt.value +=
|
Math.sin(time * 0.31) * 1.5,
|
||||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
Math.sin(time * 0.23 + 1.3) * 1.1,
|
||||||
|
),
|
||||||
|
1 - Math.exp(-dt * 1.5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
|
||||||
|
export function updatePointer(rig: Rig, tick: Tick) {
|
||||||
|
const { uniforms, state } = rig;
|
||||||
|
followCursor(rig, tick);
|
||||||
|
uniforms.uMouse.value.copy(state.mouse);
|
||||||
|
const targetAmt =
|
||||||
|
(tick.cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, tick.p));
|
||||||
|
uniforms.uMouseAmt.value +=
|
||||||
|
(targetAmt - uniforms.uMouseAmt.value) * (1 - Math.exp(-tick.dt * 4));
|
||||||
return targetAmt;
|
return targetAmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
|
* 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
|
* rolagem); o quadro pede o 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
|
* 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.
|
* à vista.
|
||||||
*/
|
*/
|
||||||
function needsFrame(
|
export function needsFrame(
|
||||||
u: Uniforms,
|
u: Uniforms,
|
||||||
s: FieldState,
|
s: FieldState,
|
||||||
target: number,
|
target: number,
|
||||||
@@ -593,176 +622,339 @@ function needsFrame(
|
|||||||
return settling || (visible && interacting);
|
return settling || (visible && interacting);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
|
||||||
|
function createMaterial() {
|
||||||
|
return new ShaderMaterial({
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: AdditiveBlending,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uIntro: { value: 0 },
|
||||||
|
uSpread: { value: 1.05 },
|
||||||
|
uScroll: { value: 0 },
|
||||||
|
uScale: { value: 1 },
|
||||||
|
uFocus: { value: 9 },
|
||||||
|
uFade: { value: 1 },
|
||||||
|
uMouse: { value: new Vector2(9, 9) },
|
||||||
|
uMouseAmt: { value: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createHelpers = (): Helpers => ({
|
||||||
|
ray: new Raycaster(),
|
||||||
|
plane: new Plane(),
|
||||||
|
up: new Vector3(0, 1, 0),
|
||||||
|
hit: new Vector3(),
|
||||||
|
zero: new Vector2(),
|
||||||
|
target: new Vector2(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createState = (): FieldState => ({
|
||||||
|
progress: 0,
|
||||||
|
pointer: new Vector2(),
|
||||||
|
lastMove: -10,
|
||||||
|
mouse: new Vector2(),
|
||||||
|
tilt: new Vector2(),
|
||||||
|
frames: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A resolução de desenho: a do aparelho, de 1 a 1,5 (cada ponto custa fragmentos, e o fundo é ambiente). */
|
||||||
|
const pixelRatio = () =>
|
||||||
|
Math.min(1.5, Math.max(1, window.devicePixelRatio || 1));
|
||||||
|
|
||||||
|
export type FieldCallbacks = {
|
||||||
|
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
|
||||||
|
progressRef: RefObject<number>;
|
||||||
|
/** Dois quadros já foram desenhados: o fundo estático pode sair. */
|
||||||
|
onReady: () => void;
|
||||||
|
/** Sem WebGL, contexto perdido ou erro ao desenhar: o hero volta ao fundo estático. */
|
||||||
|
onError: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldEngine = {
|
||||||
|
/** Hero à vista (e aba visível): liga a batida de 30 quadros por segundo; fora disso, nenhum quadro é pedido. */
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
/** O quadro que o requestAnimationFrame chama, com o relógio (ms) dado. Os testes o chamam direto. */
|
||||||
|
renderAt: (now: number) => void;
|
||||||
|
/** Solta o contexto WebGL, os ouvintes e tudo o que a cena criou. */
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. */
|
||||||
|
function createRenderer(host: HTMLElement) {
|
||||||
|
const renderer = new WebGLRenderer({
|
||||||
|
alpha: true,
|
||||||
|
antialias: false,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(pixelRatio());
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
canvas.setAttribute("aria-hidden", "true");
|
||||||
|
canvas.style.cssText = "display:block;width:100%;height:100%";
|
||||||
|
host.append(canvas);
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cena em si: a câmera, o grupo com a nuvem de pontos e o material do shader. */
|
||||||
|
type World = {
|
||||||
|
scene: Scene;
|
||||||
|
camera: PerspectiveCamera;
|
||||||
|
group: Group;
|
||||||
|
geometry: BufferGeometry;
|
||||||
|
material: ShaderMaterial;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildWorld(): World {
|
||||||
|
const camera = new PerspectiveCamera(35, 1, 0.05, 120);
|
||||||
|
camera.position.set(0, 0, 9);
|
||||||
|
const geometry = takeField();
|
||||||
|
const material = createMaterial();
|
||||||
|
const points = new Points(geometry, material);
|
||||||
|
points.frustumCulled = false;
|
||||||
|
const group = new Group();
|
||||||
|
group.add(points);
|
||||||
|
const scene = new Scene();
|
||||||
|
scene.add(group);
|
||||||
|
return { scene, camera, group, geometry, material };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O que o quadro lê de fora: o progresso da rolagem, se o hero está à vista e o relógio. */
|
||||||
|
type StepInput = {
|
||||||
|
target: number;
|
||||||
|
visible: boolean;
|
||||||
|
time: number;
|
||||||
|
delta: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Um quadro: rolagem amortecida, uniforms, câmera e cursor. Diz se foi o segundo quadro (a página pode tirar o fundo
|
||||||
|
* estático) e se algo ainda muda (então o próximo quadro é pedido de imediato).
|
||||||
|
*/
|
||||||
|
function stepField(
|
||||||
|
world: World,
|
||||||
|
state: FieldState,
|
||||||
|
helpers: Helpers,
|
||||||
|
layout: Layout,
|
||||||
|
{ target, visible, time, delta }: StepInput,
|
||||||
|
) {
|
||||||
|
const dt = Math.min(delta, 1 / 20);
|
||||||
|
const u = world.material.uniforms;
|
||||||
|
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||||
|
state.progress += (target - state.progress) * (1 - Math.exp(-dt * 5));
|
||||||
|
const p = state.progress;
|
||||||
|
const cursorActive = cursorIsActive(state, time);
|
||||||
|
const face = updateUniforms(u, layout, p, time, dt);
|
||||||
|
const rig: Rig = {
|
||||||
|
group: world.group,
|
||||||
|
camera: world.camera,
|
||||||
|
uniforms: u,
|
||||||
|
layout,
|
||||||
|
state,
|
||||||
|
helpers,
|
||||||
|
};
|
||||||
|
const tick: Tick = { p, face, time, dt, cursorActive };
|
||||||
|
updateRig(rig, tick);
|
||||||
|
const amount = updatePointer(rig, tick);
|
||||||
|
state.frames += 1;
|
||||||
|
return {
|
||||||
|
ready: state.frames === 2,
|
||||||
|
again: needsFrame(u, state, target, amount, cursorActive, visible),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Põe o renderizador e a câmera no tamanho do `host` (nunca zero) e devolve o enquadramento novo. */
|
||||||
|
function fitToHost(
|
||||||
|
renderer: WebGLRenderer,
|
||||||
|
camera: PerspectiveCamera,
|
||||||
|
host: HTMLElement,
|
||||||
|
) {
|
||||||
|
const width = Math.max(1, host.clientWidth);
|
||||||
|
const height = Math.max(1, host.clientHeight);
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
return frameLayout(width, height, camera.fov, renderer.getPixelRatio());
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldListeners = {
|
||||||
|
host: HTMLElement;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
resize: () => void;
|
||||||
|
/** O mouse (não o toque) se mexeu: posição de -1 a 1 na janela. */
|
||||||
|
onPointer: (x: number, y: number) => void;
|
||||||
|
/** O contexto WebGL se perdeu. */
|
||||||
|
onLost: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tamanho do host, mouse e perda do contexto. Devolve a função que solta os ouvintes. */
|
||||||
|
function listenToField({
|
||||||
|
host,
|
||||||
|
canvas,
|
||||||
|
resize,
|
||||||
|
onPointer,
|
||||||
|
onLost,
|
||||||
|
}: FieldListeners) {
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== "mouse") return;
|
||||||
|
onPointer(
|
||||||
|
(event.clientX / window.innerWidth) * 2 - 1,
|
||||||
|
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const lost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onLost();
|
||||||
|
};
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||||
|
observer?.observe(host);
|
||||||
|
if (!observer) window.addEventListener("resize", resize);
|
||||||
|
window.addEventListener("pointermove", onMove, { passive: true });
|
||||||
|
canvas.addEventListener("webglcontextlost", lost);
|
||||||
|
return () => {
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
canvas.removeEventListener("webglcontextlost", lost);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cena sem React: cria o renderizador dentro de `host` e desenha sob demanda. `invalidate` pede um quadro no
|
||||||
|
* próximo requestAnimationFrame (vários pedidos viram um); cada quadro atualiza os uniforms, a câmera e o cursor e
|
||||||
|
* pede o seguinte só se algo ainda muda. Lança se o aparelho não cria um contexto WebGL.
|
||||||
|
*/
|
||||||
|
export function createField(
|
||||||
|
host: HTMLElement,
|
||||||
|
{ progressRef, onReady, onError }: FieldCallbacks,
|
||||||
|
): FieldEngine {
|
||||||
|
const renderer = createRenderer(host);
|
||||||
|
const world = buildWorld();
|
||||||
|
const state = createState();
|
||||||
|
const helpers = createHelpers();
|
||||||
|
const started = performance.now();
|
||||||
|
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
|
||||||
|
let visible = false;
|
||||||
|
let alive = true;
|
||||||
|
let raf = 0;
|
||||||
|
let last = 0;
|
||||||
|
let stopPulse: (() => void) | undefined;
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
if (!raf && alive) raf = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
const fail = () => {
|
||||||
|
if (alive) onError();
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(now: number) {
|
||||||
|
raf = 0;
|
||||||
|
if (!alive) return;
|
||||||
|
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||||
|
last = now;
|
||||||
|
try {
|
||||||
|
const step = stepField(world, state, helpers, layout, {
|
||||||
|
target: progressRef.current ?? 0,
|
||||||
|
visible,
|
||||||
|
time: (now - started) / 1000,
|
||||||
|
delta,
|
||||||
|
});
|
||||||
|
if (step.ready) onReady();
|
||||||
|
if (step.again) invalidate();
|
||||||
|
renderer.render(world.scene, world.camera);
|
||||||
|
} catch {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
layout = fitToHost(renderer, world.camera, host);
|
||||||
|
invalidate();
|
||||||
|
};
|
||||||
|
const stopListening = listenToField({
|
||||||
|
host,
|
||||||
|
canvas: renderer.domElement,
|
||||||
|
resize,
|
||||||
|
onPointer: (x, y) => {
|
||||||
|
state.pointer.set(x, y);
|
||||||
|
// na mesma base do relógio do quadro (segundos desde que a cena nasceu)
|
||||||
|
state.lastMove = (performance.now() - started) / 1000;
|
||||||
|
},
|
||||||
|
onLost: fail,
|
||||||
|
});
|
||||||
|
resize();
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderAt: render,
|
||||||
|
setVisible(next) {
|
||||||
|
visible = next;
|
||||||
|
if (next) stopPulse ??= startFramePulse(invalidate);
|
||||||
|
else {
|
||||||
|
stopPulse?.();
|
||||||
|
stopPulse = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
alive = false;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
stopPulse?.();
|
||||||
|
stopListening();
|
||||||
|
world.geometry.dispose();
|
||||||
|
world.material.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.forceContextLoss();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroFieldScene({
|
||||||
progressRef,
|
progressRef,
|
||||||
visible,
|
visible,
|
||||||
onReady,
|
onReady,
|
||||||
onError,
|
onError,
|
||||||
}: HeroFieldSceneProps) {
|
}: HeroFieldSceneProps) {
|
||||||
const { gl, camera, size } = useThree();
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const group = useRef<THREE.Group>(null);
|
const engine = useRef<FieldEngine | null>(null);
|
||||||
const geometry = useMemo(takeField, []);
|
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
|
||||||
const material = useMemo(
|
const callbacks = useRef({ onReady, onError });
|
||||||
() =>
|
useEffect(() => {
|
||||||
new THREE.ShaderMaterial({
|
callbacks.current = { onReady, onError };
|
||||||
vertexShader,
|
|
||||||
fragmentShader,
|
|
||||||
transparent: true,
|
|
||||||
depthWrite: false,
|
|
||||||
blending: THREE.AdditiveBlending,
|
|
||||||
uniforms: {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uIntro: { value: 0 },
|
|
||||||
uSpread: { value: 1.05 },
|
|
||||||
uScroll: { value: 0 },
|
|
||||||
uScale: { value: 1 },
|
|
||||||
uFocus: { value: 9 },
|
|
||||||
uFade: { value: 1 },
|
|
||||||
uMouse: { value: new THREE.Vector2(9, 9) },
|
|
||||||
uMouseAmt: { value: 0 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
mouse: new THREE.Vector2(),
|
|
||||||
tilt: new THREE.Vector2(),
|
|
||||||
frames: 0,
|
|
||||||
});
|
});
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (event: PointerEvent) => {
|
const host = hostRef.current;
|
||||||
if (event.pointerType !== "mouse") return;
|
if (!host) return;
|
||||||
const s = state.current;
|
try {
|
||||||
s.pointer.set(
|
engine.current = createField(host, {
|
||||||
(event.clientX / window.innerWidth) * 2 - 1,
|
progressRef,
|
||||||
-(event.clientY / window.innerHeight) * 2 + 1,
|
onReady: () => callbacks.current.onReady(),
|
||||||
);
|
onError: () => callbacks.current.onError(),
|
||||||
s.lastMove = performance.now() / 1000;
|
});
|
||||||
};
|
} catch {
|
||||||
const onLost = (event: Event) => {
|
// sem WebGL (ou o contexto não abriu): o hero fica com o fundo estático
|
||||||
event.preventDefault();
|
callbacks.current.onError();
|
||||||
onError();
|
return;
|
||||||
};
|
}
|
||||||
window.addEventListener("pointermove", onMove, { passive: true });
|
|
||||||
gl.domElement.addEventListener("webglcontextlost", onLost);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pointermove", onMove);
|
engine.current?.dispose();
|
||||||
gl.domElement.removeEventListener("webglcontextlost", onLost);
|
engine.current = null;
|
||||||
geometry.dispose();
|
|
||||||
material.dispose();
|
|
||||||
};
|
};
|
||||||
}, [gl, geometry, material, onError]);
|
}, [progressRef]);
|
||||||
|
|
||||||
useFrame((frame, delta) => {
|
useEffect(() => {
|
||||||
const g = group.current;
|
engine.current?.setVisible(visible);
|
||||||
if (!g) return;
|
}, [visible]);
|
||||||
const dt = Math.min(delta, 1 / 20);
|
|
||||||
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 += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
|
||||||
const p = s.progress;
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
const amount = updatePointer(
|
|
||||||
g,
|
|
||||||
camera,
|
|
||||||
u,
|
|
||||||
s,
|
|
||||||
helpers,
|
|
||||||
p,
|
|
||||||
time,
|
|
||||||
dt,
|
|
||||||
cursorActive,
|
|
||||||
);
|
|
||||||
if (++s.frames === 2) onReady();
|
|
||||||
|
|
||||||
if (needsFrame(u, s, target, amount, cursorActive, visible))
|
|
||||||
frame.invalidate();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<div
|
||||||
<points geometry={geometry} material={material} frustumCulled={false} />
|
ref={hostRef}
|
||||||
</group>
|
aria-hidden="true"
|
||||||
);
|
style={{ width: "100%", height: "100%" }}
|
||||||
}
|
/>
|
||||||
|
|
||||||
class SceneBoundary extends Component<
|
|
||||||
{ children: ReactNode; onError: () => void },
|
|
||||||
{ failed: boolean }
|
|
||||||
> {
|
|
||||||
state = { failed: false };
|
|
||||||
static getDerivedStateFromError() {
|
|
||||||
return { failed: true };
|
|
||||||
}
|
|
||||||
componentDidCatch() {
|
|
||||||
this.props.onError();
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
return this.state.failed ? null : this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
|
||||||
return (
|
|
||||||
<SceneBoundary onError={props.onError}>
|
|
||||||
<Canvas
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
|
||||||
dpr={[1, 1.5]}
|
|
||||||
frameloop="demand"
|
|
||||||
gl={{
|
|
||||||
alpha: true,
|
|
||||||
antialias: false,
|
|
||||||
powerPreference: "high-performance",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FramePulse active={props.visible} />
|
|
||||||
<Field {...props} />
|
|
||||||
</Canvas>
|
|
||||||
</SceneBoundary>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { elementForHash, samePageHashTarget } from "./fragment-nav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de elementForHash e de samePageHashTarget (complemento de fragment-nav.test.tsx):
|
||||||
|
* hash vazio, só "#", id com percent-encoding, hash malformado, id que não existe, e o clique que o `<a>` de um
|
||||||
|
* SVG recebe (não é um HTMLAnchorElement, então não é âncora de página).
|
||||||
|
*/
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("elementForHash", () => {
|
||||||
|
it.each([
|
||||||
|
["an empty hash", ""],
|
||||||
|
["a lone #", "#"],
|
||||||
|
["an id that does not exist", "#nada"],
|
||||||
|
["a malformed percent-encoding", "#%E0%A4%A"],
|
||||||
|
])("path: %s -> null", (_label, hash) => {
|
||||||
|
document.body.innerHTML = `<section id="alvo"></section>`;
|
||||||
|
|
||||||
|
expect(elementForHash(hash)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: an id that exists -> that element", () => {
|
||||||
|
document.body.innerHTML = `<section id="alvo"></section>`;
|
||||||
|
|
||||||
|
expect(elementForHash("#alvo")?.id).toBe("alvo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: an id written with percent-encoding -> decoded before the lookup", () => {
|
||||||
|
document.body.innerHTML = `<section id="seção"></section>`;
|
||||||
|
|
||||||
|
expect(elementForHash("#se%C3%A7%C3%A3o")?.id).toBe("seção");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("samePageHashTarget: o que não é âncora de página", () => {
|
||||||
|
it("path: the closest link is an SVG <a> -> null, even with an href to this page", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<svg><a href="/#alvo"><text id="rotulo">ir</text></a></svg>
|
||||||
|
<section id="alvo"></section>`;
|
||||||
|
const event = new MouseEvent("click", { bubbles: true });
|
||||||
|
Object.defineProperty(event, "target", {
|
||||||
|
value: document.getElementById("rotulo"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(samePageHashTarget(event)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
FRAGMENT_NAV_BOOT_SCRIPT,
|
||||||
|
focusFragment,
|
||||||
|
isPlainPrimaryClick,
|
||||||
|
onFragmentClick,
|
||||||
|
samePageHashTarget,
|
||||||
|
setScrollRestoration,
|
||||||
|
} from "./fragment-nav";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Âncoras da home num lugar só: a leitura do clique (quem é um link de âncora da mesma página), o único ouvinte
|
||||||
|
* de clique e o único dono de history.scrollRestoration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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("samePageHashTarget (rules shared with the arrival code)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
function clickOn(markup: string) {
|
||||||
|
document.body.innerHTML = `${markup}<section id="alvo"></section>`;
|
||||||
|
const target = document.querySelector("a") as Element;
|
||||||
|
const event = new MouseEvent("click", { bubbles: true });
|
||||||
|
Object.defineProperty(event, "target", { value: target });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses a link that downloads a file", () => {
|
||||||
|
expect(
|
||||||
|
samePageHashTarget(clickOn(`<a href="/#alvo" download>x</a>`)),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a link to the same path with another query string", () => {
|
||||||
|
window.history.replaceState(null, "", "/?a=1");
|
||||||
|
expect(
|
||||||
|
samePageHashTarget(clickOn(`<a href="/?a=2#alvo">x</a>`)),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
samePageHashTarget(clickOn(`<a href="/?a=1#alvo">x</a>`))?.hash,
|
||||||
|
).toBe("#alvo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onFragmentClick", () => {
|
||||||
|
let add: jest.SpyInstance;
|
||||||
|
let remove: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
add = jest.spyOn(document, "addEventListener");
|
||||||
|
remove = jest.spyOn(document, "removeEventListener");
|
||||||
|
document.body.innerHTML = `<a href="/#alvo">ir</a><a href="/outra">fora</a><section id="alvo"></section>`;
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const clicks = () => add.mock.calls.filter(([type]) => type === "click");
|
||||||
|
const unclicks = () => remove.mock.calls.filter(([type]) => type === "click");
|
||||||
|
const link = (name: string) => screen.getByRole("link", { name });
|
||||||
|
|
||||||
|
it("installs ONE document click listener no matter how many subscribe, and removes it with the last", () => {
|
||||||
|
const first = jest.fn();
|
||||||
|
const second = jest.fn();
|
||||||
|
const stopFirst = onFragmentClick(first);
|
||||||
|
const stopSecond = onFragmentClick(second);
|
||||||
|
expect(clicks()).toHaveLength(1);
|
||||||
|
expect(clicks()[0][2]).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(link("ir"));
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
stopFirst();
|
||||||
|
expect(unclicks()).toHaveLength(0);
|
||||||
|
fireEvent.click(link("ir"));
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
stopSecond();
|
||||||
|
expect(unclicks()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands every subscriber the same interpreted hit, in subscription order", () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const stops = [
|
||||||
|
onFragmentClick((hit) => order.push(`a:${hit.hash}:${hit.target.id}`)),
|
||||||
|
onFragmentClick((hit) => order.push(`b:${hit.hash}`)),
|
||||||
|
];
|
||||||
|
fireEvent.click(link("ir"));
|
||||||
|
expect(order).toEqual(["a:#alvo:alvo", "b:#alvo"]);
|
||||||
|
for (const stop of stops) stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores links to other pages, modified clicks and clicks somebody already cancelled", () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
const stop = onFragmentClick(handler);
|
||||||
|
fireEvent.click(link("fora"));
|
||||||
|
fireEvent.click(link("ir"), { ctrlKey: true });
|
||||||
|
fireEvent.click(link("ir"), { button: 1 });
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a subscriber cancel the navigation (preventDefault) without the others being skipped", () => {
|
||||||
|
const second = jest.fn();
|
||||||
|
const stops = [
|
||||||
|
onFragmentClick((_hit, event) => event.preventDefault()),
|
||||||
|
onFragmentClick(second),
|
||||||
|
];
|
||||||
|
expect(fireEvent.click(link("ir"))).toBe(false);
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
for (const stop of stops) stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates a subscriber that leaves while the click is being delivered", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const stopFirst = onFragmentClick(() => {
|
||||||
|
calls.push("first");
|
||||||
|
stopFirst();
|
||||||
|
});
|
||||||
|
const stopSecond = onFragmentClick(() => calls.push("second"));
|
||||||
|
fireEvent.click(link("ir"));
|
||||||
|
expect(calls).toEqual(["first", "second"]);
|
||||||
|
stopSecond();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("focusFragment", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the destination without scrolling and gives it a tabindex only when it has none", () => {
|
||||||
|
document.body.innerHTML = `<section id="a"></section><section id="b" tabindex="0"></section>`;
|
||||||
|
const a = document.getElementById("a") as HTMLElement;
|
||||||
|
const b = document.getElementById("b") as HTMLElement;
|
||||||
|
const focus = jest.spyOn(a, "focus");
|
||||||
|
|
||||||
|
focusFragment(a);
|
||||||
|
focusFragment(b);
|
||||||
|
|
||||||
|
expect(a).toHaveAttribute("tabindex", "-1");
|
||||||
|
expect(b).toHaveAttribute("tabindex", "0");
|
||||||
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
expect(b).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("history.scrollRestoration ownership", () => {
|
||||||
|
const run = () => new Function(FRAGMENT_NAV_BOOT_SCRIPT)();
|
||||||
|
// o jsdom não tem performance.getEntriesByType: o teste o define e o tira no fim
|
||||||
|
const navigationType = (type: string) =>
|
||||||
|
Object.defineProperty(performance, "getEntriesByType", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => [{ type }],
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Reflect.deleteProperty(performance, "getEntriesByType");
|
||||||
|
window.history.scrollRestoration = "auto";
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setScrollRestoration is the runtime writer", () => {
|
||||||
|
setScrollRestoration("manual");
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
setScrollRestoration("auto");
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the boot script (run before hydration) goes manual on a fresh arrival at / and back to auto on pagehide", () => {
|
||||||
|
navigationType("navigate");
|
||||||
|
run();
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
window.dispatchEvent(new Event("pagehide"));
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the boot script leaves a history traversal to the browser", () => {
|
||||||
|
navigationType("back_forward");
|
||||||
|
run();
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the boot script drops the stale #fragment on a reload", () => {
|
||||||
|
window.history.replaceState(null, "", "/#contato");
|
||||||
|
navigationType("reload");
|
||||||
|
run();
|
||||||
|
expect(window.location.hash).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the boot script does nothing outside the home", () => {
|
||||||
|
window.history.replaceState(null, "", "/cases");
|
||||||
|
navigationType("navigate");
|
||||||
|
run();
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Âncoras da home (links #id para a mesma página), num lugar só.
|
||||||
|
*
|
||||||
|
* - Um único ouvinte de clique no documento (capturando, antes de qualquer menu remover o link ativado). Quem quer
|
||||||
|
* reagir a um clique num link de âncora se inscreve com `onFragmentClick`: a rolagem suave (useCinematicScroll) e a
|
||||||
|
* chegada por fragmento (useHomeFragmentNavigation) recebem o mesmo clique já interpretado, sem cada uma ler o
|
||||||
|
* evento de novo.
|
||||||
|
* - Um único dono de `history.scrollRestoration`: o script que roda antes da hidratação (`FRAGMENT_NAV_BOOT_SCRIPT`,
|
||||||
|
* que app/layout.tsx põe no <head>) e `setScrollRestoration`, que os ganchos usam. Nenhum outro código escreve nele.
|
||||||
|
*
|
||||||
|
* Sem "use client": o layout (servidor) importa só a constante do script; o resto roda no navegador.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 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 e não baixa arquivo; `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) || link.hasAttribute("download"))
|
||||||
|
return null;
|
||||||
|
return !link.target || link.target === "_self" ? link : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O `#id` de um link para esta mesma página (mesmo caminho e mesma busca); `null` se leva a outro lugar 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 &&
|
||||||
|
url.search === location.search;
|
||||||
|
return same && url.hash ? url.hash : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O elemento que um `#id` aponta (aceita percent-encoding); `null` se não existe ou o hash é malformado. */
|
||||||
|
export function elementForHash(hash: string) {
|
||||||
|
if (!hash) return null;
|
||||||
|
try {
|
||||||
|
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FragmentHit = { target: HTMLElement; hash: string };
|
||||||
|
|
||||||
|
/** 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): FragmentHit | null {
|
||||||
|
const link = sameTabLink(event);
|
||||||
|
const hash = link && hashOnThisPage(link);
|
||||||
|
const target = hash ? elementForHash(hash) : null;
|
||||||
|
return hash && target ? { target, hash } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FragmentClickHandler = (
|
||||||
|
hit: FragmentHit,
|
||||||
|
event: MouseEvent,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const handlers = new Set<FragmentClickHandler>();
|
||||||
|
|
||||||
|
/** O único ouvinte: um clique simples num link de âncora chega a cada inscrito, na ordem em que se inscreveram. */
|
||||||
|
function onDocumentClick(event: MouseEvent) {
|
||||||
|
if (!isPlainPrimaryClick(event)) return;
|
||||||
|
const hit = samePageHashTarget(event);
|
||||||
|
if (!hit) return;
|
||||||
|
for (const handler of [...handlers]) handler(hit, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inscreve `handler` nos cliques em links de âncora da mesma página. O ouvinte do documento existe só enquanto houver
|
||||||
|
* inscritos. Devolve a função que cancela a inscrição.
|
||||||
|
*/
|
||||||
|
export function onFragmentClick(handler: FragmentClickHandler) {
|
||||||
|
if (handlers.size === 0)
|
||||||
|
document.addEventListener("click", onDocumentClick, true);
|
||||||
|
handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
if (handlers.size === 0)
|
||||||
|
document.removeEventListener("click", onDocumentClick, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dá o foco ao destino de uma âncora (um alvo sem tabindex ganha -1), sem rolar a página. */
|
||||||
|
export function focusFragment(target: HTMLElement) {
|
||||||
|
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Única escrita em `history.scrollRestoration` fora do script de abertura: "manual" na chegada, "auto" depois. */
|
||||||
|
export function setScrollRestoration(mode: "manual" | "auto") {
|
||||||
|
window.history.scrollRestoration = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roda no <head>, antes da hidratação e da restauração nativa de rolagem: numa chegada nova à home a rolagem fica
|
||||||
|
* "manual" (a home posiciona a página depois de montar o layout; num recarregamento o fragmento antigo é tirado da
|
||||||
|
* URL), e ao sair da página volta a "auto". Voltar e avançar no histórico seguem com a restauração do navegador.
|
||||||
|
*/
|
||||||
|
export const FRAGMENT_NAV_BOOT_SCRIPT = `
|
||||||
|
if (location.pathname === "/") {
|
||||||
|
const navigation = performance.getEntriesByType("navigation")[0];
|
||||||
|
if (navigation?.type !== "back_forward") {
|
||||||
|
history.scrollRestoration = "manual";
|
||||||
|
if (navigation?.type === "reload" && location.hash) {
|
||||||
|
history.replaceState(history.state, "", location.pathname + location.search);
|
||||||
|
}
|
||||||
|
addEventListener("pagehide", () => {
|
||||||
|
history.scrollRestoration = "auto";
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
}`;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { HERO_TAIL_SCREENS } from "./HeroField";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* O mergulho do hero é uma conta que vive em dois lugares: o CSS (altura do hero e margem de Projetos) e o
|
||||||
|
* HeroField (de onde o progresso --hero-p sai). Se um mudar sem o outro, o progresso acaba antes ou depois de
|
||||||
|
* Projetos começar a subir e volta o tempo morto (ou a tela vazia) no fim do mergulho.
|
||||||
|
*/
|
||||||
|
const css = readFileSync(join(__dirname, "../../globals.css"), "utf8");
|
||||||
|
|
||||||
|
function diveBlock() {
|
||||||
|
const start = css.indexOf(".hero[data-dive] {");
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
return css.slice(start, start + 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("hero dive (globals.css <-> HeroField)", () => {
|
||||||
|
it("the hero is HERO_TAIL_SCREENS screens taller than the dive", () => {
|
||||||
|
const height = /height: calc\((\d+)svh \+ var\(--hero-dive\)\)/.exec(
|
||||||
|
diveBlock(),
|
||||||
|
);
|
||||||
|
expect(height).not.toBeNull();
|
||||||
|
expect(Number(height?.[1]) / 100).toBe(HERO_TAIL_SCREENS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Projects rises over the stage by the tail minus the pinned stage (one screen)", () => {
|
||||||
|
const margin = /margin-top: -(\d+)svh/.exec(diveBlock());
|
||||||
|
expect(margin).not.toBeNull();
|
||||||
|
expect(Number(margin?.[1]) / 100).toBeCloseTo(HERO_TAIL_SCREENS - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the last caption stays on stage until Projects covers it (its end is past the end of the dive)", () => {
|
||||||
|
const last =
|
||||||
|
/li:nth-child\(3\) \{\s*--from: ([\d.]+);\s*--to: ([\d.]+);/.exec(
|
||||||
|
diveBlock(),
|
||||||
|
);
|
||||||
|
expect(last).not.toBeNull();
|
||||||
|
expect(Number(last?.[2])).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each caption starts before the previous one is gone far enough that the gap between them is short", () => {
|
||||||
|
const ranges = [
|
||||||
|
...diveBlock().matchAll(
|
||||||
|
/li:nth-child\((\d)\) \{\s*--from: ([\d.]+);\s*--to: ([\d.]+);/g,
|
||||||
|
),
|
||||||
|
].map((match) => [Number(match[2]), Number(match[3])]);
|
||||||
|
expect(ranges).toHaveLength(3);
|
||||||
|
for (let i = 1; i < ranges.length; i++) {
|
||||||
|
// sem texto entre uma legenda e a seguinte: no máximo 0,1 de progresso (~100 px de rolagem)
|
||||||
|
expect(ranges[i][0] - ranges[i - 1][1]).toBeLessThanOrEqual(0.1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
anchorDuration,
|
anchorDuration,
|
||||||
anchorTop,
|
anchorTop,
|
||||||
IDLE_SECONDS,
|
IDLE_SECONDS,
|
||||||
isPlainPrimaryClick,
|
|
||||||
JUMP_SCREENS,
|
JUMP_SCREENS,
|
||||||
MAX_ANCHOR_SECONDS,
|
MAX_ANCHOR_SECONDS,
|
||||||
samePageHashTarget,
|
|
||||||
useCinematicScroll,
|
useCinematicScroll,
|
||||||
} from "./useCinematicScroll";
|
} from "./useCinematicScroll";
|
||||||
|
|
||||||
@@ -183,102 +181,6 @@ describe("anchor travel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
describe("anchorTop", () => {
|
||||||
const scenes = () => {
|
const scenes = () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
@@ -467,6 +369,24 @@ describe("useCinematicScroll", () => {
|
|||||||
expect(lenis.raf).toHaveBeenCalled();
|
expect(lenis.raf).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("eases the anchor travel in and out: slow at both ends, exactly half way at the middle, from 0 to 1", async () => {
|
||||||
|
await mount();
|
||||||
|
const lenis = mockLenis[0];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||||
|
});
|
||||||
|
const { easing } = lenis.scrollTo.mock.calls[0][1] as {
|
||||||
|
easing: (t: number) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(easing(0)).toBe(0);
|
||||||
|
expect(easing(0.25)).toBeCloseTo(0.0625, 9);
|
||||||
|
expect(easing(0.5)).toBeCloseTo(0.5, 9);
|
||||||
|
expect(easing(0.75)).toBeCloseTo(0.9375, 9);
|
||||||
|
expect(easing(1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
|
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
|
||||||
mockMedia(["(pointer: coarse)"]);
|
mockMedia(["(pointer: coarse)"]);
|
||||||
await mount();
|
await mount();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { type RefObject, useEffect } from "react";
|
import { type RefObject, useEffect } from "react";
|
||||||
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||||
|
import { focusFragment, onFragmentClick } from "./fragment-nav";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação:
|
* Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação:
|
||||||
@@ -13,7 +14,8 @@ import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
|||||||
* cena; nada aqui depende de uma seção específica existir.
|
* 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
|
* 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
|
* 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.
|
* destino em vez de saltar (o clique vem de fragment-nav). No toque a rolagem nativa corre em outra thread:
|
||||||
|
* nem o Lenis é baixado.
|
||||||
*
|
*
|
||||||
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
|
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
|
||||||
*/
|
*/
|
||||||
@@ -49,50 +51,6 @@ export function anchorDuration(distance: number, viewport: number) {
|
|||||||
const easeInOutCubic = (t: number) =>
|
const easeInOutCubic = (t: number) =>
|
||||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||||
|
|
||||||
/** 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
|
* 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.
|
* interno desconta o cabeçalho fixo, sem passar do topo da cena.
|
||||||
@@ -181,25 +139,21 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino
|
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino. O clique
|
||||||
const onClick = (event: MouseEvent) => {
|
// chega já interpretado por fragment-nav (o único ouvinte de clique das âncoras).
|
||||||
if (!isPlainPrimaryClick(event)) return;
|
const stopClicks = onFragmentClick((hit, event) => {
|
||||||
const hit = samePageHashTarget(event);
|
|
||||||
if (!hit) return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (location.hash !== hit.hash) history.pushState(null, "", hit.hash);
|
if (location.hash !== hit.hash) history.pushState(null, "", hit.hash);
|
||||||
scrollToTop(
|
scrollToTop(
|
||||||
anchorTop(hit.target, Array.from(main.children) as HTMLElement[]),
|
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
|
// 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;
|
focusFragment(hit.target);
|
||||||
hit.target.focus({ preventScroll: true });
|
});
|
||||||
};
|
|
||||||
document.addEventListener("click", onClick, true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
document.removeEventListener("click", onClick, true);
|
stopClicks();
|
||||||
smooth?.destroy();
|
smooth?.destroy();
|
||||||
};
|
};
|
||||||
}, [mainRef]);
|
}, [mainRef]);
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
resetHomeArrivalState,
|
||||||
|
useHomeFragmentNavigation,
|
||||||
|
} from "./useHomeFragmentNavigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base da chegada à home (complemento de CinematicHome.test.tsx): como o navegador chegou
|
||||||
|
* (nova, volta/avança, sem a API de navegação), o destino do fragmento (existe, não existe, ficou velho), as teclas
|
||||||
|
* e gestos que cancelam a chegada, o clique em âncora antes e depois do layout pronto, o hashchange e a limpeza.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let layoutReady: () => void = () => {};
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
layoutReady = useHomeFragmentNavigation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a href="/#about">Sobre</a>
|
||||||
|
<section id="about" aria-label="Sobre">
|
||||||
|
<button type="button">Ler</button>
|
||||||
|
</section>
|
||||||
|
<section id="contact" aria-label="Contato" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const about = () => screen.getByRole("region", { name: "Sobre" });
|
||||||
|
const contact = () => screen.getByRole("region", { name: "Contato" });
|
||||||
|
const ready = () => act(() => layoutReady());
|
||||||
|
/** O jsdom não tem performance.getEntriesByType (é o caso "navegador sem a API"); aqui ela passa a existir. */
|
||||||
|
function navigationEntries(type?: string) {
|
||||||
|
Object.defineProperty(performance, "getEntriesByType", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockReturnValue(type ? [{ type }] : []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let scrollTo: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHomeArrivalState();
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
window.history.scrollRestoration = "auto";
|
||||||
|
scrollTo = jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
Reflect.deleteProperty(performance, "getEntriesByType");
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
window.history.scrollRestoration = "auto";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHomeFragmentNavigation: como o navegador chegou", () => {
|
||||||
|
it("path: a fresh arrival -> scroll restoration is manual until the layout is ready, then automatic again", () => {
|
||||||
|
navigationEntries("navigate");
|
||||||
|
render(<Home />);
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: back/forward traversal with a fragment in the URL -> the browser keeps its own position: no manual mode, no scroll, no focus", () => {
|
||||||
|
navigationEntries("back_forward");
|
||||||
|
window.history.replaceState(null, "", "/#contact");
|
||||||
|
render(<Home />);
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
expect(contact()).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no navigation entry in the timeline -> treated as a fresh arrival", () => {
|
||||||
|
navigationEntries();
|
||||||
|
render(<Home />);
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a browser without getEntriesByType (the jsdom default) -> treated as a fresh arrival", () => {
|
||||||
|
expect(performance.getEntriesByType).toBeUndefined();
|
||||||
|
render(<Home />);
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHomeFragmentNavigation: o destino do fragmento", () => {
|
||||||
|
/** Mede o destino e devolve o elemento (capturado antes de getComputedStyle ser trocado). */
|
||||||
|
function measure(top: number, padding: string, margin: string) {
|
||||||
|
Object.defineProperty(window, "scrollY", {
|
||||||
|
configurable: true,
|
||||||
|
value: 100,
|
||||||
|
});
|
||||||
|
const target = about();
|
||||||
|
target.getBoundingClientRect = () => ({ top }) as DOMRect;
|
||||||
|
jest.spyOn(window, "getComputedStyle").mockImplementation(
|
||||||
|
(element) =>
|
||||||
|
({
|
||||||
|
scrollPaddingTop: element === document.documentElement ? padding : "",
|
||||||
|
scrollMarginTop: element === target ? margin : "",
|
||||||
|
}) as unknown as CSSStyleDeclaration,
|
||||||
|
);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Reflect.deleteProperty(window, "scrollY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: fragment that exists -> waits for the layout, then focuses it and scrolls under the fixed header and its own margin", () => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
const target = measure(500, "80px", "10px");
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
expect(target).not.toHaveFocus();
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(target).toHaveFocus();
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 100 + 500 - 80 - 10,
|
||||||
|
left: 0,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no readable header offset or margin -> both count as zero", () => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
measure(500, "", "");
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||||
|
top: 600,
|
||||||
|
left: 0,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a target far above the current position -> never scrolls to a negative position", () => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
measure(-900, "80px", "0px");
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: fragment of an element that does not exist -> nothing is focused or scrolled, and nothing throws", () => {
|
||||||
|
window.history.replaceState(null, "", "/#nao-existe");
|
||||||
|
render(<Home />);
|
||||||
|
|
||||||
|
expect(() => ready()).not.toThrow();
|
||||||
|
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
expect(document.body).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the URL changed without a hashchange event before the layout was ready -> the old fragment is not applied", () => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
window.history.replaceState(null, "", "/#contact");
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(about()).not.toHaveFocus();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHomeFragmentNavigation: o visitante assume a rolagem", () => {
|
||||||
|
it.each([
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
" ",
|
||||||
|
"Tab",
|
||||||
|
])(
|
||||||
|
"path: the %j key -> the pending arrival is dropped and the browser takes back its scroll restoration",
|
||||||
|
(key) => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key });
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(about()).not.toHaveFocus();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["a", "Enter", "Escape", "Shift"])(
|
||||||
|
"path: the %j key (not a scroll key) -> the pending arrival still happens",
|
||||||
|
(key) => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key });
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(about()).toHaveFocus();
|
||||||
|
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["touchmove", () => fireEvent.touchMove(window)],
|
||||||
|
["pointerdown", () => fireEvent.pointerDown(window)],
|
||||||
|
["wheel", () => fireEvent.wheel(window, { deltaY: 40 })],
|
||||||
|
])("path: %s -> the pending arrival is dropped", (_gesture, gesture) => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
render(<Home />);
|
||||||
|
|
||||||
|
gesture();
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(about()).not.toHaveFocus();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHomeFragmentNavigation: cliques e hashchange", () => {
|
||||||
|
it("path: click on an anchor before the layout is ready -> the destination gets the focus now and the scroll waits for the layout", () => {
|
||||||
|
render(<Home />);
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Sobre" }));
|
||||||
|
// o jsdom não segue a âncora sozinho: o navegador deixaria a URL já no fragmento
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
expect(about()).toHaveFocus();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ready();
|
||||||
|
|
||||||
|
// a âncora vira a chegada pendente (não a rolagem ao topo)
|
||||||
|
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: click on an anchor after the layout is ready -> the focus moves, no arrival scroll is left pending", () => {
|
||||||
|
render(<Home />);
|
||||||
|
ready();
|
||||||
|
scrollTo.mockClear();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Sobre" }));
|
||||||
|
ready();
|
||||||
|
|
||||||
|
expect(about()).toHaveFocus();
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: hashchange to an element that exists, after the layout is ready -> it gets the focus", () => {
|
||||||
|
render(<Home />);
|
||||||
|
ready();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.history.replaceState(null, "", "/#contact");
|
||||||
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(contact()).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: hashchange to an element that does not exist -> nothing happens", () => {
|
||||||
|
render(<Home />);
|
||||||
|
ready();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.history.replaceState(null, "", "/#nao-existe");
|
||||||
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: hashchange before the layout is ready -> no focus yet (the arrival is not resolved early)", () => {
|
||||||
|
render(<Home />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.history.replaceState(null, "", "/#contact");
|
||||||
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(contact()).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHomeFragmentNavigation: desmontagem", () => {
|
||||||
|
it("path: unmount -> scroll restoration goes back to auto and the listeners are gone", () => {
|
||||||
|
window.history.replaceState(null, "", "/#about");
|
||||||
|
const view = render(<Home />);
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
|
||||||
|
window.history.scrollRestoration = "manual";
|
||||||
|
fireEvent.wheel(window, { deltaY: 40 });
|
||||||
|
expect(window.history.scrollRestoration).toBe("manual");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the ready callback called after unmount -> nothing to resolve, no error, restoration stays automatic", () => {
|
||||||
|
const view = render(<Home />);
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
expect(() => act(() => layoutReady())).not.toThrow();
|
||||||
|
|
||||||
|
expect(window.history.scrollRestoration).toBe("auto");
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
elementForHash,
|
||||||
|
type FragmentHit,
|
||||||
|
focusFragment,
|
||||||
|
onFragmentClick,
|
||||||
|
setScrollRestoration,
|
||||||
|
} from "./fragment-nav";
|
||||||
|
|
||||||
function fragmentTarget(hash: string) {
|
/** Teclas de rolagem: quem as usa já escolheu para onde ir, e a chegada por fragmento deixa de corrigir. */
|
||||||
if (!hash) return null;
|
const SCROLL_KEYS = new Set([
|
||||||
try {
|
"ArrowUp",
|
||||||
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
"ArrowDown",
|
||||||
} catch {
|
"PageUp",
|
||||||
return null;
|
"PageDown",
|
||||||
}
|
"Home",
|
||||||
}
|
"End",
|
||||||
|
" ",
|
||||||
function focusFragment(target: HTMLElement) {
|
"Tab",
|
||||||
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
]);
|
||||||
target.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A document owns one outstanding homepage arrival. Later mounts are same-document
|
* A document owns one outstanding homepage arrival. Later mounts are same-document
|
||||||
@@ -27,135 +32,113 @@ export function resetHomeArrivalState() {
|
|||||||
arrivalSettled = false;
|
arrivalSettled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rola a página até o destino do fragmento, descontando o cabeçalho fixo (scroll-padding) e a margem do próprio alvo. */
|
||||||
|
function scrollToFragment(target: HTMLElement) {
|
||||||
|
const padding =
|
||||||
|
Number.parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||||
|
) || 0;
|
||||||
|
const margin =
|
||||||
|
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
|
||||||
|
window.scrollTo({
|
||||||
|
top: Math.max(
|
||||||
|
0,
|
||||||
|
window.scrollY + target.getBoundingClientRect().top - padding - margin,
|
||||||
|
),
|
||||||
|
left: 0,
|
||||||
|
behavior: "instant",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chegada (uma por documento): quer o topo, quando a URL não traz fragmento, ou o destino do fragmento, depois
|
||||||
|
* que o layout da home está pronto. Corrige só uma chegada pendente; links comuns e histórico seguem nativos.
|
||||||
|
*/
|
||||||
|
function createArrival(isReady: () => boolean) {
|
||||||
|
const navigation = performance.getEntriesByType?.("navigation")[0] as
|
||||||
|
| PerformanceNavigationTiming
|
||||||
|
| undefined;
|
||||||
|
// A history restoration owns its saved position, not the URL's old fragment.
|
||||||
|
const restoration = navigation?.type === "back_forward";
|
||||||
|
const arrival = !restoration && !arrivalSettled;
|
||||||
|
let pending = restoration ? "" : window.location.hash;
|
||||||
|
let pendingTop = arrival && !window.location.hash;
|
||||||
|
if (arrival) setScrollRestoration("manual");
|
||||||
|
|
||||||
|
const goToTop = () => {
|
||||||
|
pendingTop = false;
|
||||||
|
if (!window.location.hash)
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Aplica o que ficou pendente, se o layout já está pronto. */
|
||||||
|
resolve() {
|
||||||
|
if (!isReady()) return;
|
||||||
|
if (pendingTop) goToTop();
|
||||||
|
if (!pending) return;
|
||||||
|
const hash = pending;
|
||||||
|
pending = "";
|
||||||
|
if (hash !== window.location.hash) return;
|
||||||
|
const target = elementForHash(hash);
|
||||||
|
if (!target) return;
|
||||||
|
focusFragment(target);
|
||||||
|
scrollToFragment(target);
|
||||||
|
},
|
||||||
|
/** O visitante assumiu a rolagem: nada mais é corrigido, e a restauração do navegador volta. */
|
||||||
|
cancel() {
|
||||||
|
pending = "";
|
||||||
|
pendingTop = false;
|
||||||
|
setScrollRestoration("auto");
|
||||||
|
},
|
||||||
|
onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (SCROLL_KEYS.has(event.key)) this.cancel();
|
||||||
|
},
|
||||||
|
/** Clique num link de âncora (o ouvinte é de fragment-nav): sem cancelar a navegação, o foco vai ao destino. */
|
||||||
|
onFragmentClick({ target, hash }: FragmentHit) {
|
||||||
|
pendingTop = false;
|
||||||
|
pending = isReady() ? "" : hash;
|
||||||
|
// Capture before a mobile menu removes its activated link.
|
||||||
|
focusFragment(target);
|
||||||
|
},
|
||||||
|
onHashChange() {
|
||||||
|
pendingTop = false;
|
||||||
|
if (pending && pending !== window.location.hash) this.cancel();
|
||||||
|
if (!isReady()) return;
|
||||||
|
const target = elementForHash(window.location.hash);
|
||||||
|
if (target) focusFragment(target);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
|
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
|
||||||
export function useHomeFragmentNavigation() {
|
export function useHomeFragmentNavigation() {
|
||||||
const ready = useRef(false);
|
const ready = useRef(false);
|
||||||
const resolveArrival = useRef<(() => void) | null>(null);
|
const resolveArrival = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const navigation = performance.getEntriesByType?.("navigation")[0] as
|
const arrival = createArrival(() => ready.current);
|
||||||
| PerformanceNavigationTiming
|
const cancel = () => arrival.cancel();
|
||||||
| undefined;
|
const onKeyDown = (event: KeyboardEvent) => arrival.onKeyDown(event);
|
||||||
// A history restoration owns its saved position, not the URL's old fragment.
|
const onHashChange = () => arrival.onHashChange();
|
||||||
const restoration = navigation?.type === "back_forward";
|
|
||||||
const arrival = !restoration && !arrivalSettled;
|
|
||||||
let pending = restoration ? "" : window.location.hash;
|
|
||||||
let pendingTop = arrival && !window.location.hash;
|
|
||||||
if (arrival) window.history.scrollRestoration = "manual";
|
|
||||||
|
|
||||||
const resolve = () => {
|
resolveArrival.current = () => arrival.resolve();
|
||||||
if (!ready.current) return;
|
arrival.resolve();
|
||||||
if (pendingTop) {
|
const stopClicks = onFragmentClick((hit) => arrival.onFragmentClick(hit));
|
||||||
pendingTop = false;
|
|
||||||
if (!window.location.hash)
|
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
|
||||||
}
|
|
||||||
if (!pending) return;
|
|
||||||
const hash = pending;
|
|
||||||
pending = "";
|
|
||||||
if (hash !== window.location.hash) return;
|
|
||||||
const target = fragmentTarget(hash);
|
|
||||||
if (!target) return;
|
|
||||||
focusFragment(target);
|
|
||||||
const padding =
|
|
||||||
Number.parseFloat(
|
|
||||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
|
||||||
) || 0;
|
|
||||||
const margin =
|
|
||||||
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
|
|
||||||
window.scrollTo({
|
|
||||||
top: Math.max(
|
|
||||||
0,
|
|
||||||
window.scrollY +
|
|
||||||
target.getBoundingClientRect().top -
|
|
||||||
padding -
|
|
||||||
margin,
|
|
||||||
),
|
|
||||||
left: 0,
|
|
||||||
behavior: "instant",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelArrival = () => {
|
|
||||||
pending = "";
|
|
||||||
pendingTop = false;
|
|
||||||
window.history.scrollRestoration = "auto";
|
|
||||||
};
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
"ArrowUp",
|
|
||||||
"ArrowDown",
|
|
||||||
"PageUp",
|
|
||||||
"PageDown",
|
|
||||||
"Home",
|
|
||||||
"End",
|
|
||||||
" ",
|
|
||||||
"Tab",
|
|
||||||
].includes(event.key)
|
|
||||||
) {
|
|
||||||
cancelArrival();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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.hasAttribute("download") ||
|
|
||||||
(link.target && link.target !== "_self")
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const url = new URL(link.href);
|
|
||||||
if (
|
|
||||||
url.origin !== window.location.origin ||
|
|
||||||
url.pathname !== window.location.pathname ||
|
|
||||||
url.search !== window.location.search
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const target = fragmentTarget(url.hash);
|
|
||||||
if (!target) return;
|
|
||||||
pendingTop = false;
|
|
||||||
pending = ready.current ? "" : url.hash;
|
|
||||||
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
|
|
||||||
focusFragment(target);
|
|
||||||
};
|
|
||||||
const onHashChange = () => {
|
|
||||||
pendingTop = false;
|
|
||||||
if (pending && pending !== window.location.hash) cancelArrival();
|
|
||||||
if (!ready.current) return;
|
|
||||||
const target = fragmentTarget(window.location.hash);
|
|
||||||
if (target) focusFragment(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
resolveArrival.current = resolve;
|
|
||||||
resolve();
|
|
||||||
document.addEventListener("click", onClick, true);
|
|
||||||
window.addEventListener("hashchange", onHashChange);
|
window.addEventListener("hashchange", onHashChange);
|
||||||
window.addEventListener("wheel", cancelArrival, { passive: true });
|
window.addEventListener("wheel", cancel, { passive: true });
|
||||||
window.addEventListener("touchmove", cancelArrival, { passive: true });
|
window.addEventListener("touchmove", cancel, { passive: true });
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
window.addEventListener("pointerdown", cancelArrival, { passive: true });
|
window.addEventListener("pointerdown", cancel, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
resolveArrival.current = null;
|
resolveArrival.current = null;
|
||||||
window.history.scrollRestoration = "auto";
|
setScrollRestoration("auto");
|
||||||
document.removeEventListener("click", onClick, true);
|
stopClicks();
|
||||||
window.removeEventListener("hashchange", onHashChange);
|
window.removeEventListener("hashchange", onHashChange);
|
||||||
window.removeEventListener("wheel", cancelArrival);
|
window.removeEventListener("wheel", cancel);
|
||||||
window.removeEventListener("touchmove", cancelArrival);
|
window.removeEventListener("touchmove", cancel);
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
window.removeEventListener("pointerdown", cancelArrival);
|
window.removeEventListener("pointerdown", cancel);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -163,6 +146,6 @@ export function useHomeFragmentNavigation() {
|
|||||||
ready.current = true;
|
ready.current = true;
|
||||||
resolveArrival.current?.();
|
resolveArrival.current?.();
|
||||||
arrivalSettled = true;
|
arrivalSettled = true;
|
||||||
window.history.scrollRestoration = "auto";
|
setScrollRestoration("auto");
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
chapterFraction,
|
||||||
|
chapterIndexAt,
|
||||||
|
getScrubSource,
|
||||||
|
getShowcaseAssets,
|
||||||
|
} from "@/lib/project-showcase";
|
||||||
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
|
import { PhoneFrame } from "./PhoneFrame";
|
||||||
|
import { ProjectActions } from "./ProjectActions";
|
||||||
|
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||||
|
import {
|
||||||
|
CAPTION_OUT_MS,
|
||||||
|
easeToward,
|
||||||
|
locateInReel,
|
||||||
|
REEL_HOLD,
|
||||||
|
reelOffset,
|
||||||
|
videoProgress,
|
||||||
|
} from "./showcase-math";
|
||||||
|
import { useRovingTabindex } from "./useRovingTabindex";
|
||||||
|
import { useSaveData } from "./useShowcaseMedia";
|
||||||
|
import "./pinned-reel.css";
|
||||||
|
|
||||||
|
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
||||||
|
let headerCache = { width: -1, height: 60 };
|
||||||
|
const headerHeight = () => {
|
||||||
|
if (headerCache.width !== window.innerWidth)
|
||||||
|
headerCache = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height:
|
||||||
|
Number.parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue(
|
||||||
|
"--site-header-height",
|
||||||
|
),
|
||||||
|
) || 60,
|
||||||
|
};
|
||||||
|
return headerCache.height;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
const target = Math.min(
|
||||||
|
video.duration - 0.001,
|
||||||
|
Math.max(0, fraction * video.duration),
|
||||||
|
);
|
||||||
|
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
|
||||||
|
video.currentTime = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
|
||||||
|
function prime(video: HTMLVideoElement) {
|
||||||
|
if (video.dataset.primed) return;
|
||||||
|
video.dataset.primed = "true";
|
||||||
|
video
|
||||||
|
.play()
|
||||||
|
.then(() => video.pause())
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopScroll = () => {};
|
||||||
|
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
|
||||||
|
function scrollToSmoothly(top: number) {
|
||||||
|
stopScroll();
|
||||||
|
const from = window.scrollY;
|
||||||
|
const distance = top - from;
|
||||||
|
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
|
||||||
|
const started = performance.now();
|
||||||
|
let id = 0;
|
||||||
|
const stop = () => {
|
||||||
|
cancelAnimationFrame(id);
|
||||||
|
window.removeEventListener("wheel", stop);
|
||||||
|
window.removeEventListener("touchstart", stop);
|
||||||
|
};
|
||||||
|
stopScroll = stop;
|
||||||
|
window.addEventListener("wheel", stop, { passive: true });
|
||||||
|
window.addEventListener("touchstart", stop, { passive: true });
|
||||||
|
const step = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - started) / duration);
|
||||||
|
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||||
|
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
|
||||||
|
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
|
||||||
|
if (t < 1) id = requestAnimationFrame(step);
|
||||||
|
else stop();
|
||||||
|
};
|
||||||
|
id = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
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; 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.
|
||||||
|
*/
|
||||||
|
export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||||
|
const openProject = useShowcaseOpen();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [chapter, setChapter] = useState(0);
|
||||||
|
const [near, setNear] = useState(false);
|
||||||
|
const [settled, setSettled] = useState(0);
|
||||||
|
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 bars = useRef<Array<HTMLElement | null>>([]);
|
||||||
|
const live = useRef({
|
||||||
|
target: [] as number[],
|
||||||
|
smooth: [] as number[],
|
||||||
|
active: 0,
|
||||||
|
chapter: 0,
|
||||||
|
raf: 0,
|
||||||
|
last: 0,
|
||||||
|
near: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const weights = useMemo(
|
||||||
|
() => items.map((item) => item.config.weight),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
|
||||||
|
// só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); e só depois que a rolagem parou ali (passar
|
||||||
|
// voando por um projeto no clique do índice não pode baixar todos os do caminho)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setSettled(active), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
|
||||||
|
useEffect(() => {
|
||||||
|
const list = indexRef.current;
|
||||||
|
const button = list?.children[active]?.querySelector("button");
|
||||||
|
if (
|
||||||
|
!list ||
|
||||||
|
!button ||
|
||||||
|
list.scrollWidth <= list.clientWidth ||
|
||||||
|
typeof list.scrollTo !== "function"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
list.scrollTo({
|
||||||
|
left: Math.max(0, button.offsetLeft - 16),
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
const isAttached = useCallback(
|
||||||
|
(index: number) => near && !saveData && index === settled,
|
||||||
|
[near, saveData, settled],
|
||||||
|
);
|
||||||
|
// o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto
|
||||||
|
const showsPoster = (index: number) => near && Math.abs(index - active) <= 1;
|
||||||
|
|
||||||
|
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
||||||
|
useEffect(() => {
|
||||||
|
items.forEach((_, index) => {
|
||||||
|
if (isAttached(index)) return;
|
||||||
|
const video = desktopVideos.current[index];
|
||||||
|
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||||
|
video.load();
|
||||||
|
});
|
||||||
|
}, [items, isAttached]);
|
||||||
|
|
||||||
|
const frame = useCallback(
|
||||||
|
(now: number) => {
|
||||||
|
const state = live.current;
|
||||||
|
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
||||||
|
state.last = now;
|
||||||
|
let moving = false;
|
||||||
|
items.forEach((_, index) => {
|
||||||
|
if (Math.abs(index - state.active) > 1) return;
|
||||||
|
const target = state.target[index] ?? 0;
|
||||||
|
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
||||||
|
state.smooth[index] = next;
|
||||||
|
if (Math.abs(next - target) > 0.0004) moving = true;
|
||||||
|
seekVideo(desktopVideos.current[index], next);
|
||||||
|
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
||||||
|
});
|
||||||
|
const current = items[state.active];
|
||||||
|
if (current) {
|
||||||
|
const index = chapterIndexAt(
|
||||||
|
current.config,
|
||||||
|
state.smooth[state.active] ?? 0,
|
||||||
|
);
|
||||||
|
if (index !== state.chapter) {
|
||||||
|
state.chapter = index;
|
||||||
|
setChapter(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.raf = moving ? requestAnimationFrame(frame) : 0;
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||||
|
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 state = live.current;
|
||||||
|
state.active = index;
|
||||||
|
state.chapter = chapterIndexAt(
|
||||||
|
items[index].config,
|
||||||
|
state.smooth[index] ?? 0,
|
||||||
|
);
|
||||||
|
setActive(index);
|
||||||
|
setChapter(state.chapter);
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
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?.offsetHeight || !pin) return;
|
||||||
|
const state = live.current;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [changeActive, frame, items.length, weights]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let pending = 0;
|
||||||
|
const schedule = () => {
|
||||||
|
if (!pending)
|
||||||
|
pending = requestAnimationFrame(() => {
|
||||||
|
pending = 0;
|
||||||
|
measure();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
window.addEventListener("scroll", schedule, { passive: true });
|
||||||
|
window.addEventListener("resize", schedule);
|
||||||
|
const state = live.current;
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", schedule);
|
||||||
|
window.removeEventListener("resize", schedule);
|
||||||
|
cancelAnimationFrame(pending);
|
||||||
|
cancelAnimationFrame(state.raf);
|
||||||
|
state.raf = 0;
|
||||||
|
};
|
||||||
|
}, [measure]);
|
||||||
|
|
||||||
|
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||||
|
const scrollToReel = (index: number, local: number) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const pin = pinRef.current;
|
||||||
|
if (!container || !pin) return;
|
||||||
|
const scrollable = container.offsetHeight - pin.offsetHeight;
|
||||||
|
const top = container.getBoundingClientRect().top + window.scrollY;
|
||||||
|
// 2px além: garante que a conta caia dentro do trecho
|
||||||
|
scrollToSmoothly(
|
||||||
|
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goTo = (index: number) => scrollToReel(index, 0);
|
||||||
|
const goToChapter = (index: number, chapterIndex: number) => {
|
||||||
|
const fraction = chapterFraction(
|
||||||
|
items[index].config,
|
||||||
|
chapterIndex,
|
||||||
|
"desktop",
|
||||||
|
);
|
||||||
|
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = items[active];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="ds-reel"
|
||||||
|
aria-label="Vitrine animada dos projetos"
|
||||||
|
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<h2 className="sr-only">Projetos de clientes</h2>
|
||||||
|
<div className="ds-reel-pin" ref={pinRef}>
|
||||||
|
<div className="ds-reel-grid">
|
||||||
|
<ReelIndex
|
||||||
|
items={items}
|
||||||
|
active={active}
|
||||||
|
chapter={chapter}
|
||||||
|
listRef={indexRef}
|
||||||
|
bars={bars}
|
||||||
|
onProject={goTo}
|
||||||
|
onChapter={goToChapter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="ds-reel-stage">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ReelCaption
|
||||||
|
items={items}
|
||||||
|
active={active}
|
||||||
|
chapter={chapter}
|
||||||
|
onExpand={openProject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{current
|
||||||
|
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. É UMA parada
|
||||||
|
* no Tab (roving tabindex): as setas e Home/End movem o foco entre os projetos e os trechos, Enter e espaço
|
||||||
|
* escolhem. Sem isto eram um Tab por projeto e por trecho (7) antes de chegar aos botões da legenda.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}) {
|
||||||
|
const { groupProps, itemProps } = useRovingTabindex(
|
||||||
|
`p:${active}`,
|
||||||
|
// os trechos são sempre os do projeto em cena: os de um projeto que já saiu de cena deixam de ser a parada
|
||||||
|
(key) => key.startsWith("p:") || key.startsWith(`c:${active}:`),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="ds-reel-index"
|
||||||
|
aria-label="Projetos da vitrine"
|
||||||
|
{...groupProps}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span className="sr-only">
|
||||||
|
Use as setas para escolher o projeto e o trecho.
|
||||||
|
</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)}
|
||||||
|
{...itemProps(`p:${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)}
|
||||||
|
{...itemProps(`c:${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vitrine fixa (código, vídeos e o CSS dela) só é baixada onde ela cabe: no celular, com movimento reduzido e em
|
||||||
|
* janelas baixas, a lista é tudo o que há, e nada disso viaja. Fica num módulo próprio para os testes poderem
|
||||||
|
* trocá-lo por um componente síncrono.
|
||||||
|
*/
|
||||||
|
export const PinnedReelLazy = lazy(() => import("./PinnedReel"));
|
||||||
@@ -36,7 +36,8 @@ export function ProjectActions({
|
|||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M9.5 2.5h4v4M6.5 13.5h-4v-4M13.5 2.5 9 7M2.5 13.5 7 9" />
|
<path d="M9.5 2.5h4v4M6.5 13.5h-4v-4M13.5 2.5 9 7M2.5 13.5 7 9" />
|
||||||
</svg>
|
</svg>
|
||||||
Ampliar vídeo
|
{/* no celular o botão é só o ícone (CSS): o rótulo continua para o leitor de tela */}
|
||||||
|
<span className="ds-reel-expand-label">Ampliar vídeo</span>
|
||||||
<span className="sr-only"> de {project.title}</span>
|
<span className="sr-only"> de {project.title}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
||||||
|
|
||||||
let reducedMotion = false;
|
let reducedMotion = false;
|
||||||
|
// a janela ampliada chega sob demanda (React.lazy): aqui entra já, para o teste não esperar
|
||||||
|
jest.mock("./ProjectLightboxLazy", () => ({
|
||||||
|
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
useReducedMotion: () => reducedMotion,
|
useReducedMotion: () => reducedMotion,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
import { PhoneFrame } from "./PhoneFrame";
|
import { PhoneFrame } from "./PhoneFrame";
|
||||||
import type { ShowcaseItem } from "./ShowcaseProvider";
|
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||||
|
import "./lightbox.css";
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const whole = Math.max(0, Math.floor(seconds));
|
const whole = Math.max(0, Math.floor(seconds));
|
||||||
@@ -300,7 +301,6 @@ export function ProjectLightbox({
|
|||||||
<button
|
<button
|
||||||
ref={closeRef}
|
ref={closeRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="ds-lb-close"
|
|
||||||
onClick={() => dialogRef.current?.close()}
|
onClick={() => dialogRef.current?.close()}
|
||||||
aria-label="Fechar"
|
aria-label="Fechar"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A janela ampliada (e o CSS dela) só é baixada na primeira vez que alguém amplia um projeto. Fica num módulo
|
||||||
|
* próprio para os testes poderem trocá-lo por um componente síncrono.
|
||||||
|
*/
|
||||||
|
export const ProjectLightboxLazy = lazy(() =>
|
||||||
|
import("./ProjectLightbox").then((module) => ({
|
||||||
|
default: module.ProjectLightbox,
|
||||||
|
})),
|
||||||
|
);
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
import { ProjectList } from "./ProjectList";
|
import { carouselIndex, ProjectList } from "./ProjectList";
|
||||||
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
|
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
|
||||||
|
jest.mock("./PinnedReelLazy", () => ({
|
||||||
|
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
|
||||||
|
}));
|
||||||
|
jest.mock("./ProjectLightboxLazy", () => ({
|
||||||
|
// o ProjectLightbox deste teste é o dublê logo abaixo
|
||||||
|
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
|
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
|
||||||
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente" e o botão de ampliar.
|
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente" e o botão de ampliar.
|
||||||
@@ -171,3 +180,150 @@ describe("ProjectList", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ProjectList: carrossel do celular", () => {
|
||||||
|
/** Dá ao carrossel as medidas que o jsdom não tem: largura de cada cartão, passo e rolagem. */
|
||||||
|
function lay(
|
||||||
|
list: HTMLElement,
|
||||||
|
{ scrollLeft = 0, card = 300, gap = 16 } = {},
|
||||||
|
) {
|
||||||
|
const cards = Array.from(list.children) as HTMLElement[];
|
||||||
|
Object.defineProperty(list, "clientWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: 360,
|
||||||
|
});
|
||||||
|
Object.defineProperty(list, "scrollWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: cards.length * (card + gap),
|
||||||
|
});
|
||||||
|
list.scrollLeft = scrollLeft;
|
||||||
|
cards.forEach((element, index) => {
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({ left: index * (card + gap) - scrollLeft }) as DOMRect;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (container: HTMLElement) =>
|
||||||
|
container.querySelector(".ds-reel-items") as HTMLElement;
|
||||||
|
const count = (container: HTMLElement) =>
|
||||||
|
container.querySelector(".ds-reel-count");
|
||||||
|
|
||||||
|
it("path: on the phone, shows '1/N' and one dot per project (decorative: the list already says how many there are)", () => {
|
||||||
|
const { container } = renderList({ device: "mobile" });
|
||||||
|
|
||||||
|
const counter = count(container) as HTMLElement;
|
||||||
|
expect(counter).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(counter.textContent).toBe(`1/${items.length}`);
|
||||||
|
expect(counter.querySelectorAll("i")).toHaveLength(items.length);
|
||||||
|
expect(counter.querySelectorAll("i[data-on]")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no counter on the desktop grid, before mount, or with one project only", () => {
|
||||||
|
expect(count(renderList({ device: "desktop" }).container)).toBeNull();
|
||||||
|
expect(count(renderList({ device: null }).container)).toBeNull();
|
||||||
|
expect(
|
||||||
|
count(
|
||||||
|
renderList({ device: "mobile", items: items.slice(0, 1) }).container,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: scrolling the strip moves the counter and the dot (one animation frame at a time)", () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container } = renderList({ device: "mobile" });
|
||||||
|
const strip = list(container);
|
||||||
|
lay(strip, { scrollLeft: 316 * 2 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
strip.dispatchEvent(new Event("scroll"));
|
||||||
|
strip.dispatchEvent(new Event("scroll"));
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((count(container) as HTMLElement).textContent).toBe(
|
||||||
|
`3/${items.length}`,
|
||||||
|
);
|
||||||
|
const dots = [...(count(container) as HTMLElement).querySelectorAll("i")];
|
||||||
|
expect(dots.findIndex((dot) => dot.hasAttribute("data-on"))).toBe(2);
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the strip scrolled to its end -> the last project, even if its start never reaches the edge", () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { container } = renderList({ device: "mobile" });
|
||||||
|
const strip = list(container);
|
||||||
|
lay(strip);
|
||||||
|
// fim da rolagem: scrollLeft + clientWidth = scrollWidth
|
||||||
|
strip.scrollLeft = strip.scrollWidth - strip.clientWidth;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
strip.dispatchEvent(new Event("scroll"));
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((count(container) as HTMLElement).textContent).toBe(
|
||||||
|
`${items.length}/${items.length}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carouselIndex: 0 without horizontal scroll, never past the ends, and tolerant of a degenerate stride", () => {
|
||||||
|
const strip = document.createElement("ul");
|
||||||
|
for (let i = 0; i < 4; i++) strip.append(document.createElement("li"));
|
||||||
|
// sem rolagem para o lado (grade das telas maiores)
|
||||||
|
Object.defineProperty(strip, "scrollWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: 300,
|
||||||
|
});
|
||||||
|
Object.defineProperty(strip, "clientWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: 300,
|
||||||
|
});
|
||||||
|
expect(carouselIndex(strip)).toBe(0);
|
||||||
|
|
||||||
|
lay(strip, { scrollLeft: 316 });
|
||||||
|
expect(carouselIndex(strip)).toBe(1);
|
||||||
|
lay(strip, { scrollLeft: 316 * 10 });
|
||||||
|
expect(carouselIndex(strip)).toBe(3);
|
||||||
|
// cartões sobrepostos (passo zero): não divide por zero
|
||||||
|
for (const child of Array.from(strip.children))
|
||||||
|
(child as HTMLElement).getBoundingClientRect = () =>
|
||||||
|
({ left: 0 }) as DOMRect;
|
||||||
|
strip.scrollLeft = 10;
|
||||||
|
Object.defineProperty(strip, "scrollWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: 2000,
|
||||||
|
});
|
||||||
|
Object.defineProperty(strip, "clientWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: 360,
|
||||||
|
});
|
||||||
|
expect(carouselIndex(strip)).toBe(0);
|
||||||
|
// um cartão só
|
||||||
|
const single = document.createElement("ul");
|
||||||
|
single.append(document.createElement("li"));
|
||||||
|
expect(carouselIndex(single)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: 'Ampliar vídeo' keeps its accessible name (visually hidden on the phone, by CSS) and 'Ver case' is the first action", () => {
|
||||||
|
const { container } = renderList({ device: "mobile" });
|
||||||
|
|
||||||
|
const first = container.querySelector(".ds-reel-actions") as HTMLElement;
|
||||||
|
const names = [...first.querySelectorAll("a, button")].map((element) =>
|
||||||
|
element.textContent?.replace(/\s+/g, " ").trim(),
|
||||||
|
);
|
||||||
|
expect(names[0]).toMatch(/^Ver case/);
|
||||||
|
expect(
|
||||||
|
within(first).getByRole("button", { name: /^Ampliar vídeo de / }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(first.querySelector(".ds-reel-expand-label")?.textContent).toBe(
|
||||||
|
"Ampliar vídeo",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,69 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type CSSProperties, useId, useRef } from "react";
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type RefObject,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
import { PhoneFrame } from "./PhoneFrame";
|
import { PhoneFrame } from "./PhoneFrame";
|
||||||
import { ProjectActions } from "./ProjectActions";
|
import { ProjectActions } from "./ProjectActions";
|
||||||
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||||
import { useNearViewport } from "./useShowcaseMedia";
|
import { useNearViewport } from "./useShowcaseMedia";
|
||||||
|
import "./reel-list.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qual cartão do carrossel está à frente: o índice do cartão cujo início está mais perto do começo da faixa (no fim da
|
||||||
|
* rolagem, o último, que talvez não alcance o começo). Só lê a posição da rolagem; sem rolagem horizontal fica em 0.
|
||||||
|
*/
|
||||||
|
export function carouselIndex(list: HTMLElement) {
|
||||||
|
const cards = Array.from(list.children) as HTMLElement[];
|
||||||
|
if (cards.length < 2 || list.scrollWidth <= list.clientWidth + 1) return 0;
|
||||||
|
if (list.scrollLeft + list.clientWidth >= list.scrollWidth - 2)
|
||||||
|
return cards.length - 1;
|
||||||
|
const stride =
|
||||||
|
cards[1].getBoundingClientRect().left -
|
||||||
|
cards[0].getBoundingClientRect().left;
|
||||||
|
if (stride <= 0) return 0;
|
||||||
|
return Math.min(
|
||||||
|
cards.length - 1,
|
||||||
|
Math.max(0, Math.round(list.scrollLeft / stride)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acompanha a rolagem horizontal da faixa e devolve o índice do cartão à frente (em um quadro de animação por vez). */
|
||||||
|
function useCarouselIndex(
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const list = ref.current;
|
||||||
|
if (!enabled || !list) return;
|
||||||
|
let frame = 0;
|
||||||
|
const read = () => {
|
||||||
|
frame = 0;
|
||||||
|
setIndex(carouselIndex(list));
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(read);
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
list.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
window.addEventListener("resize", onScroll);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
list.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
|
};
|
||||||
|
}, [ref, enabled]);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
function ListItem({
|
function ListItem({
|
||||||
item,
|
item,
|
||||||
@@ -85,6 +141,10 @@ export function ProjectList({
|
|||||||
}) {
|
}) {
|
||||||
const open = useShowcaseOpen();
|
const open = useShowcaseOpen();
|
||||||
const headingId = useId();
|
const headingId = useId();
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
// a contagem só existe no celular (o carrossel por toque); a grade das telas maiores não rola para o lado
|
||||||
|
const carousel = device === "mobile";
|
||||||
|
const current = useCarouselIndex(listRef, carousel);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="ds-reel-list"
|
className="ds-reel-list"
|
||||||
@@ -94,7 +154,7 @@ export function ProjectList({
|
|||||||
<h2 id={headingId} className="sr-only">
|
<h2 id={headingId} className="sr-only">
|
||||||
Projetos de clientes
|
Projetos de clientes
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="ds-reel-items">
|
<ul className="ds-reel-items" ref={listRef}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={item.config.id}
|
key={item.config.id}
|
||||||
@@ -104,6 +164,20 @@ export function ProjectList({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{carousel && items.length > 1 && (
|
||||||
|
// quem lê com leitor de tela já tem a lista (com o número de itens): a contagem é só visual
|
||||||
|
<p className="ds-reel-count" aria-hidden="true">
|
||||||
|
<span className="ds-reel-dots">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<i
|
||||||
|
key={item.config.id}
|
||||||
|
data-on={index === current ? "" : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{current + 1}/{items.length}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,14 @@ import { ShowcaseProvider } from "./ShowcaseProvider";
|
|||||||
import { reelOffset } from "./showcase-math";
|
import { reelOffset } from "./showcase-math";
|
||||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||||
|
|
||||||
|
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
|
||||||
|
jest.mock("./PinnedReelLazy", () => ({
|
||||||
|
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
|
||||||
|
}));
|
||||||
|
jest.mock("./ProjectLightboxLazy", () => ({
|
||||||
|
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de ProjectReel (complemento de ProjectReel.test.tsx):
|
* Testes de caminhos-base de ProjectReel (complemento de ProjectReel.test.tsx):
|
||||||
* - qual versão da vitrine entra (fixa só com largura e altura de desktop e sem movimento reduzido; lista em
|
* - qual versão da vitrine entra (fixa só com largura e altura de desktop e sem movimento reduzido; lista em
|
||||||
@@ -741,3 +749,46 @@ describe("ProjectReel: rolagem suave até um projeto", () => {
|
|||||||
return reelOffset(WEIGHTS, index, 0) * scrollable;
|
return reelOffset(WEIGHTS, index, 0) * scrollable;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ProjectReel: altura do cabeçalho descontada na rolagem até um projeto", () => {
|
||||||
|
function pressAndLand(name: string) {
|
||||||
|
(window.scrollTo as jest.Mock).mockClear();
|
||||||
|
const view = renderReel();
|
||||||
|
fireEvent.click(
|
||||||
|
within(
|
||||||
|
screen.getByRole("navigation", { name: "Projetos da vitrine" }),
|
||||||
|
).getByRole("button", { name }),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||||
|
view.unmount();
|
||||||
|
return calls[calls.length - 1][0].top as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidth(width: number) {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
configurable: true,
|
||||||
|
value: width,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.style.removeProperty("--site-header-height");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no --site-header-height -> 60 px; set later at the same window width -> still the cached value; a new width -> read again", () => {
|
||||||
|
setWidth(1111);
|
||||||
|
const withoutVariable = pressAndLand("SC Plus Aquicultura");
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty("--site-header-height", "80px");
|
||||||
|
const sameWidth = pressAndLand("SC Plus Aquicultura");
|
||||||
|
expect(sameWidth).toBe(withoutVariable);
|
||||||
|
|
||||||
|
setWidth(1112);
|
||||||
|
const newWidth = pressAndLand("SC Plus Aquicultura");
|
||||||
|
// o cabeçalho de 80 px deixa 20 px a menos de rolagem do que o de 60 px
|
||||||
|
expect(newWidth).toBe(withoutVariable - 20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,11 +5,19 @@ import { renderToString } from "react-dom/server";
|
|||||||
import { MOBILE_QUERY } from "@/lib/device";
|
import { MOBILE_QUERY } from "@/lib/device";
|
||||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
import { getShowcaseConfig } from "@/lib/project-showcase";
|
import { getShowcaseConfig } from "@/lib/project-showcase";
|
||||||
import { CAPTION_OUT_MS, ProjectReel } from "./ProjectReel";
|
import { ProjectReel } from "./ProjectReel";
|
||||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
import { reelOffset, videoProgress } from "./showcase-math";
|
import { CAPTION_OUT_MS, reelOffset, videoProgress } from "./showcase-math";
|
||||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||||
|
|
||||||
|
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
|
||||||
|
jest.mock("./PinnedReelLazy", () => ({
|
||||||
|
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
|
||||||
|
}));
|
||||||
|
jest.mock("./ProjectLightboxLazy", () => ({
|
||||||
|
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
||||||
|
}));
|
||||||
|
|
||||||
let reducedMotion = false;
|
let reducedMotion = false;
|
||||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
useReducedMotion: () => reducedMotion,
|
useReducedMotion: () => reducedMotion,
|
||||||
@@ -699,13 +707,94 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
|||||||
for (const element of caption().querySelectorAll("*"))
|
for (const element of caption().querySelectorAll("*"))
|
||||||
expect((element as HTMLElement).style.transform).toBe("");
|
expect((element as HTMLElement).style.transform).toBe("");
|
||||||
|
|
||||||
const css = fs.readFileSync(path.join(__dirname, "showcase.css"), "utf8");
|
const css = ["reel-list.css", "pinned-reel.css"]
|
||||||
|
.map((file) => fs.readFileSync(path.join(__dirname, file), "utf8"))
|
||||||
|
.join("\n");
|
||||||
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||||
expect(rule).toContain("opacity");
|
expect(rule).toContain("opacity");
|
||||||
expect(rule).not.toMatch(/transform|translate/);
|
expect(rule).not.toMatch(/transform|translate/);
|
||||||
expect(rule).toContain("0.12s");
|
|
||||||
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||||
expect(layer).toContain("opacity");
|
expect(layer).toContain("opacity");
|
||||||
expect(layer).not.toMatch(/transform|translate/);
|
expect(layer).not.toMatch(/transform|translate/);
|
||||||
|
|
||||||
|
// comportamento: a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
|
||||||
|
expect(caption()).toHaveAttribute("data-leaving");
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||||
|
});
|
||||||
|
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||||
|
expect(caption().getAttribute("style")).toBeNull();
|
||||||
|
for (const element of caption().querySelectorAll("*"))
|
||||||
|
expect((element as HTMLElement).style.transform).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectReel: teclado do índice (roving tabindex)", () => {
|
||||||
|
const index = () =>
|
||||||
|
screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||||
|
const stops = () =>
|
||||||
|
within(index())
|
||||||
|
.getAllByRole("button")
|
||||||
|
.filter((button) => button.getAttribute("tabindex") === "0");
|
||||||
|
const focusOn = (element: HTMLElement) =>
|
||||||
|
act(() => {
|
||||||
|
element.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => {
|
||||||
|
renderReel();
|
||||||
|
const buttons = within(index()).getAllByRole("button");
|
||||||
|
// 5 projetos e os 3 trechos do projeto em cena: 8 botões, uma parada
|
||||||
|
expect(buttons).toHaveLength(5 + 3);
|
||||||
|
expect(stops()).toHaveLength(1);
|
||||||
|
expect(stops()[0]).toHaveAttribute("aria-current", "true");
|
||||||
|
expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks projects and the chapters of the current one with the arrows, in the order they appear", () => {
|
||||||
|
renderReel();
|
||||||
|
const names = () =>
|
||||||
|
within(index())
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((button) => button.textContent?.replace(/\s+/g, " ").trim());
|
||||||
|
focusOn(stops()[0]);
|
||||||
|
const seen: Array<string | null | undefined> = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
fireEvent.keyDown(document.activeElement as Element, {
|
||||||
|
key: "ArrowDown",
|
||||||
|
});
|
||||||
|
seen.push(
|
||||||
|
document.activeElement?.textContent?.replace(/\s+/g, " ").trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// depois do projeto em cena vêm os trechos dele, e então os outros projetos
|
||||||
|
expect(seen.slice(0, 3)).toEqual(names().slice(1, 4));
|
||||||
|
// as setas não passam da última parada
|
||||||
|
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||||
|
const last = document.activeElement;
|
||||||
|
fireEvent.keyDown(last as Element, { key: "ArrowDown" });
|
||||||
|
expect(document.activeElement).toBe(last);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the tab stop to where the focus went, and Enter picks the project like a click", () => {
|
||||||
|
renderReel();
|
||||||
|
focusOn(stops()[0]);
|
||||||
|
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||||
|
const target = document.activeElement as HTMLElement;
|
||||||
|
expect(stops()).toEqual([target]);
|
||||||
|
// o botão do último projeto leva a rolagem até ele (o teclado ativa o clique nativo do botão)
|
||||||
|
fireEvent.click(target);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the caption actions as their own tab stops (Ver case, Ampliar vídeo, Abrir site)", () => {
|
||||||
|
renderReel();
|
||||||
|
const actions = [...caption().querySelectorAll("a, button")].filter(
|
||||||
|
(element) => element.getAttribute("tabindex") !== "-1",
|
||||||
|
);
|
||||||
|
expect(actions.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,109 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { type CSSProperties, Suspense } from "react";
|
||||||
type CSSProperties,
|
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import {
|
import { PinnedReelLazy } from "./PinnedReelLazy";
|
||||||
chapterFraction,
|
|
||||||
chapterIndexAt,
|
|
||||||
getScrubSource,
|
|
||||||
getShowcaseAssets,
|
|
||||||
} from "@/lib/project-showcase";
|
|
||||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
|
||||||
import { PhoneFrame } from "./PhoneFrame";
|
|
||||||
import { ProjectActions } from "./ProjectActions";
|
|
||||||
import { ProjectList } from "./ProjectList";
|
import { ProjectList } from "./ProjectList";
|
||||||
import {
|
import { type ShowcaseItem, useShowcaseItems } from "./ShowcaseProvider";
|
||||||
type ShowcaseItem,
|
import { usePinnedViewport, useViewportDevice } from "./useShowcaseMedia";
|
||||||
useShowcaseItems,
|
import "./reel-list.css";
|
||||||
useShowcaseOpen,
|
|
||||||
} from "./ShowcaseProvider";
|
|
||||||
import {
|
|
||||||
easeToward,
|
|
||||||
locateInReel,
|
|
||||||
REEL_HOLD,
|
|
||||||
reelOffset,
|
|
||||||
videoProgress,
|
|
||||||
} from "./showcase-math";
|
|
||||||
import {
|
|
||||||
usePinnedViewport,
|
|
||||||
useSaveData,
|
|
||||||
useViewportDevice,
|
|
||||||
} from "./useShowcaseMedia";
|
|
||||||
|
|
||||||
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
/**
|
||||||
let headerCache = { width: -1, height: 60 };
|
* O bloco que segura o lugar da vitrine fixa, com a mesma altura dela: a página não muda de tamanho quando ela
|
||||||
const headerHeight = () => {
|
* aparece (o salto para #contact, por exemplo, cai no lugar certo). O CSS só o mostra onde a vitrine fixa vai existir.
|
||||||
if (headerCache.width !== window.innerWidth)
|
*/
|
||||||
headerCache = {
|
function ReelPlaceholder({ items }: { items: ShowcaseItem[] }) {
|
||||||
width: window.innerWidth,
|
return (
|
||||||
height:
|
<div
|
||||||
Number.parseFloat(
|
className="ds-reel ds-reel-placeholder"
|
||||||
getComputedStyle(document.documentElement).getPropertyValue(
|
aria-hidden="true"
|
||||||
"--site-header-height",
|
style={
|
||||||
|
{
|
||||||
|
"--reel-sum": items.reduce(
|
||||||
|
(sum, item) => sum + item.config.weight,
|
||||||
|
0,
|
||||||
),
|
),
|
||||||
) || 60,
|
} as CSSProperties
|
||||||
};
|
}
|
||||||
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;
|
|
||||||
const target = Math.min(
|
|
||||||
video.duration - 0.001,
|
|
||||||
Math.max(0, fraction * video.duration),
|
|
||||||
);
|
);
|
||||||
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
|
|
||||||
video.currentTime = target;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
|
|
||||||
function prime(video: HTMLVideoElement) {
|
|
||||||
if (video.dataset.primed) return;
|
|
||||||
video.dataset.primed = "true";
|
|
||||||
video
|
|
||||||
.play()
|
|
||||||
.then(() => video.pause())
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let stopScroll = () => {};
|
|
||||||
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
|
|
||||||
function scrollToSmoothly(top: number) {
|
|
||||||
stopScroll();
|
|
||||||
const from = window.scrollY;
|
|
||||||
const distance = top - from;
|
|
||||||
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
|
|
||||||
const started = performance.now();
|
|
||||||
let id = 0;
|
|
||||||
const stop = () => {
|
|
||||||
cancelAnimationFrame(id);
|
|
||||||
window.removeEventListener("wheel", stop);
|
|
||||||
window.removeEventListener("touchstart", stop);
|
|
||||||
};
|
|
||||||
stopScroll = stop;
|
|
||||||
window.addEventListener("wheel", stop, { passive: true });
|
|
||||||
window.addEventListener("touchstart", stop, { passive: true });
|
|
||||||
const step = (now: number) => {
|
|
||||||
const t = Math.min(1, (now - started) / duration);
|
|
||||||
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
||||||
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
|
|
||||||
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
|
|
||||||
if (t < 1) id = requestAnimationFrame(step);
|
|
||||||
else stop();
|
|
||||||
};
|
|
||||||
id = requestAnimationFrame(step);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,8 +35,8 @@ function scrollToSmoothly(top: number) {
|
|||||||
* cena presa (position: sticky) durante a rolagem de todos os projetos. No celular, com prefers-reduced-motion
|
* cena presa (position: sticky) durante a rolagem de todos os projetos. No celular, com prefers-reduced-motion
|
||||||
* e em janelas baixas, nada fica preso: é a lista de projetos (carrossel por toque no celular), só com pôsteres.
|
* e em janelas baixas, nada fica preso: é a lista de projetos (carrossel por toque no celular), só com pôsteres.
|
||||||
* O servidor entrega a lista (texto e links); o navegador troca pela vitrine fixa quando ela cabe, e até lá
|
* O servidor entrega a lista (texto e links); o navegador troca pela vitrine fixa quando ela cabe, e até lá
|
||||||
* um bloco da mesma altura segura o lugar dela. Funciona sozinha dentro de `ShowcaseProvider`: não depende
|
* (e enquanto o pacote dela chega) um bloco da mesma altura segura o lugar. Funciona sozinha dentro de
|
||||||
* dos cards da grade.
|
* `ShowcaseProvider`: não depende dos cards da grade.
|
||||||
*/
|
*/
|
||||||
export function ProjectReel() {
|
export function ProjectReel() {
|
||||||
const items = useShowcaseItems();
|
const items = useShowcaseItems();
|
||||||
@@ -124,534 +47,17 @@ export function ProjectReel() {
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const mounted = wide !== null && device !== null;
|
const mounted = wide !== null && device !== null;
|
||||||
if (mounted && wide && !reducedMotion) return <PinnedReel items={items} />;
|
if (mounted && wide && !reducedMotion)
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ReelPlaceholder items={items} />}>
|
||||||
|
<PinnedReelLazy items={items} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProjectList items={items} device={device} pending={!mounted} />
|
<ProjectList items={items} device={device} pending={!mounted} />
|
||||||
{!mounted && (
|
{!mounted && <ReelPlaceholder items={items} />}
|
||||||
// antes de montar, um bloco da mesma altura segura o lugar da vitrine fixa: a página não muda de
|
|
||||||
// tamanho quando ela aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS só o
|
|
||||||
// mostra onde a vitrine fixa vai existir (e a lista, nesses casos, fica escondida)
|
|
||||||
<div
|
|
||||||
className="ds-reel ds-reel-placeholder"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--reel-sum": items.reduce(
|
|
||||||
(sum, item) => sum + item.config.weight,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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; 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.
|
|
||||||
*/
|
|
||||||
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
|
||||||
const openProject = useShowcaseOpen();
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [chapter, setChapter] = useState(0);
|
|
||||||
const [near, setNear] = useState(false);
|
|
||||||
const [settled, setSettled] = useState(0);
|
|
||||||
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 bars = useRef<Array<HTMLElement | null>>([]);
|
|
||||||
const live = useRef({
|
|
||||||
target: [] as number[],
|
|
||||||
smooth: [] as number[],
|
|
||||||
active: 0,
|
|
||||||
chapter: 0,
|
|
||||||
raf: 0,
|
|
||||||
last: 0,
|
|
||||||
near: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const weights = useMemo(
|
|
||||||
() => items.map((item) => item.config.weight),
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
|
||||||
|
|
||||||
// só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); e só depois que a rolagem parou ali (passar
|
|
||||||
// voando por um projeto no clique do índice não pode baixar todos os do caminho)
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setSettled(active), 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
|
|
||||||
useEffect(() => {
|
|
||||||
const list = indexRef.current;
|
|
||||||
const button = list?.children[active]?.querySelector("button");
|
|
||||||
if (
|
|
||||||
!list ||
|
|
||||||
!button ||
|
|
||||||
list.scrollWidth <= list.clientWidth ||
|
|
||||||
typeof list.scrollTo !== "function"
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
list.scrollTo({
|
|
||||||
left: Math.max(0, button.offsetLeft - 16),
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
const isAttached = useCallback(
|
|
||||||
(index: number) => near && !saveData && index === settled,
|
|
||||||
[near, saveData, settled],
|
|
||||||
);
|
|
||||||
// o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto
|
|
||||||
const showsPoster = (index: number) => near && Math.abs(index - active) <= 1;
|
|
||||||
|
|
||||||
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
|
||||||
useEffect(() => {
|
|
||||||
items.forEach((_, index) => {
|
|
||||||
if (isAttached(index)) return;
|
|
||||||
const video = desktopVideos.current[index];
|
|
||||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
|
||||||
video.load();
|
|
||||||
});
|
|
||||||
}, [items, isAttached]);
|
|
||||||
|
|
||||||
const frame = useCallback(
|
|
||||||
(now: number) => {
|
|
||||||
const state = live.current;
|
|
||||||
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
|
||||||
state.last = now;
|
|
||||||
let moving = false;
|
|
||||||
items.forEach((_, index) => {
|
|
||||||
if (Math.abs(index - state.active) > 1) return;
|
|
||||||
const target = state.target[index] ?? 0;
|
|
||||||
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
|
||||||
state.smooth[index] = next;
|
|
||||||
if (Math.abs(next - target) > 0.0004) moving = true;
|
|
||||||
seekVideo(desktopVideos.current[index], next);
|
|
||||||
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
|
||||||
});
|
|
||||||
const current = items[state.active];
|
|
||||||
if (current) {
|
|
||||||
const index = chapterIndexAt(
|
|
||||||
current.config,
|
|
||||||
state.smooth[state.active] ?? 0,
|
|
||||||
);
|
|
||||||
if (index !== state.chapter) {
|
|
||||||
state.chapter = index;
|
|
||||||
setChapter(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.raf = moving ? requestAnimationFrame(frame) : 0;
|
|
||||||
},
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
|
|
||||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
|
||||||
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 state = live.current;
|
|
||||||
state.active = index;
|
|
||||||
state.chapter = chapterIndexAt(
|
|
||||||
items[index].config,
|
|
||||||
state.smooth[index] ?? 0,
|
|
||||||
);
|
|
||||||
setActive(index);
|
|
||||||
setChapter(state.chapter);
|
|
||||||
},
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
|
|
||||||
const measure = useCallback(() => {
|
|
||||||
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?.offsetHeight || !pin) return;
|
|
||||||
const state = live.current;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [changeActive, frame, items.length, weights]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let pending = 0;
|
|
||||||
const schedule = () => {
|
|
||||||
if (!pending)
|
|
||||||
pending = requestAnimationFrame(() => {
|
|
||||||
pending = 0;
|
|
||||||
measure();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
schedule();
|
|
||||||
window.addEventListener("scroll", schedule, { passive: true });
|
|
||||||
window.addEventListener("resize", schedule);
|
|
||||||
const state = live.current;
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", schedule);
|
|
||||||
window.removeEventListener("resize", schedule);
|
|
||||||
cancelAnimationFrame(pending);
|
|
||||||
cancelAnimationFrame(state.raf);
|
|
||||||
state.raf = 0;
|
|
||||||
};
|
|
||||||
}, [measure]);
|
|
||||||
|
|
||||||
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
|
||||||
const scrollToReel = (index: number, local: number) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
const pin = pinRef.current;
|
|
||||||
if (!container || !pin) return;
|
|
||||||
const scrollable = container.offsetHeight - pin.offsetHeight;
|
|
||||||
const top = container.getBoundingClientRect().top + window.scrollY;
|
|
||||||
// 2px além: garante que a conta caia dentro do trecho
|
|
||||||
scrollToSmoothly(
|
|
||||||
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goTo = (index: number) => scrollToReel(index, 0);
|
|
||||||
const goToChapter = (index: number, chapterIndex: number) => {
|
|
||||||
const fraction = chapterFraction(
|
|
||||||
items[index].config,
|
|
||||||
chapterIndex,
|
|
||||||
"desktop",
|
|
||||||
);
|
|
||||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
|
||||||
};
|
|
||||||
|
|
||||||
const current = items[active];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="ds-reel"
|
|
||||||
aria-label="Vitrine animada dos projetos"
|
|
||||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<h2 className="sr-only">Projetos de clientes</h2>
|
|
||||||
<div className="ds-reel-pin" ref={pinRef}>
|
|
||||||
<div className="ds-reel-grid">
|
|
||||||
<ReelIndex
|
|
||||||
items={items}
|
|
||||||
active={active}
|
|
||||||
chapter={chapter}
|
|
||||||
listRef={indexRef}
|
|
||||||
bars={bars}
|
|
||||||
onProject={goTo}
|
|
||||||
onChapter={goToChapter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="ds-reel-stage">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<ReelCaption
|
|
||||||
items={items}
|
|
||||||
active={active}
|
|
||||||
chapter={chapter}
|
|
||||||
onExpand={openProject}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="sr-only" aria-live="polite">
|
|
||||||
{current
|
|
||||||
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
useShowcaseOpen,
|
useShowcaseOpen,
|
||||||
} from "./ShowcaseProvider";
|
} from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
// a janela ampliada chega sob demanda (React.lazy): aqui entra já (o dublê logo abaixo), para o teste não esperar
|
||||||
|
jest.mock("./ProjectLightboxLazy", () => ({
|
||||||
|
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx). O provedor só guarda a
|
* 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
|
* lista da vitrine e qual projeto está ampliado: aqui, a janela aberta quando a lista muda, a troca de projeto
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
useShowcaseOpen,
|
useShowcaseOpen,
|
||||||
} from "./ShowcaseProvider";
|
} from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
// a janela ampliada chega sob demanda (React.lazy): aqui entra já (o dublê logo abaixo), para o teste não esperar
|
||||||
|
jest.mock("./ProjectLightboxLazy", () => ({
|
||||||
|
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
|
||||||
|
}));
|
||||||
|
|
||||||
/** A janela ampliada é testada em ProjectLightbox.test.tsx; aqui ela é um dublê que só mostra o índice e navega. */
|
/** A janela ampliada é testada em ProjectLightbox.test.tsx; aqui ela é um dublê que só mostra o índice e navega. */
|
||||||
jest.mock("./ProjectLightbox", () => ({
|
jest.mock("./ProjectLightbox", () => ({
|
||||||
ProjectLightbox: ({
|
ProjectLightbox: ({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||||
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
|
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
|
||||||
import { ProjectLightbox } from "./ProjectLightbox";
|
import { ProjectLightboxLazy } from "./ProjectLightboxLazy";
|
||||||
|
|
||||||
export interface ShowcaseItem {
|
export interface ShowcaseItem {
|
||||||
config: ShowcaseConfig;
|
config: ShowcaseConfig;
|
||||||
@@ -71,12 +72,14 @@ export function ShowcaseProvider({
|
|||||||
<ApiContext.Provider value={api}>
|
<ApiContext.Provider value={api}>
|
||||||
{children}
|
{children}
|
||||||
{openIndex >= 0 && (
|
{openIndex >= 0 && (
|
||||||
<ProjectLightbox
|
<Suspense fallback={null}>
|
||||||
items={items}
|
<ProjectLightboxLazy
|
||||||
index={openIndex}
|
items={items}
|
||||||
onClose={() => setOpenId(null)}
|
index={openIndex}
|
||||||
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
onClose={() => setOpenId(null)}
|
||||||
/>
|
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</ApiContext.Provider>
|
</ApiContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MOBILE_QUERY } from "@/lib/device";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import { ProjectReel } from "./ProjectReel";
|
||||||
|
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
||||||
|
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A vitrine fixa e a janela ampliada (código e CSS) chegam sob demanda: o módulo só é carregado quando alguém precisa
|
||||||
|
* dele. Aqui os módulos pesados são trocados por dublês que contam quantas vezes foram carregados, e o React.lazy é o
|
||||||
|
* de verdade (os outros testes de showcase o trocam por componentes síncronos). A fábrica de cada dublê roda uma vez
|
||||||
|
* por arquivo, na primeira importação: os testes seguem esta ordem (celular, janela ampliada, desktop) e a contagem
|
||||||
|
* acumula de um para o outro.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockLoads = { pinned: 0, lightbox: 0 };
|
||||||
|
|
||||||
|
jest.mock("./PinnedReel", () => {
|
||||||
|
mockLoads.pinned += 1;
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ items }: { items: unknown[] }) => (
|
||||||
|
<div data-testid="pinned-reel">{items.length} projetos fixos</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
jest.mock("./ProjectLightbox", () => {
|
||||||
|
mockLoads.lightbox += 1;
|
||||||
|
return {
|
||||||
|
ProjectLightbox: ({ index }: { index: number }) => (
|
||||||
|
<div role="dialog" aria-label="ampliado">
|
||||||
|
aberto:{index}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
function mockViewport({ phone }: { phone: boolean }) {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches:
|
||||||
|
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
function Opener() {
|
||||||
|
const open = useShowcaseOpen();
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => open("sev-modaintima")}>
|
||||||
|
ampliar
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("carregamento sob demanda", () => {
|
||||||
|
it("1. on a phone nothing heavy is loaded: the list is all there is, and the window waits for a click", async () => {
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectReel />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLoads).toEqual({ pinned: 0, lightbox: 0 });
|
||||||
|
expect(screen.queryByTestId("pinned-reel")).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: "Projetos de clientes" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("2. the enlarged window is loaded when a project is enlarged, and only then", async () => {
|
||||||
|
mockViewport({ phone: true });
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<Opener />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
expect(mockLoads.lightbox).toBe(0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "ampliar" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("dialog", { name: "ampliado" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockLoads.lightbox).toBe(1);
|
||||||
|
expect(mockLoads.pinned).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3. the pinned reel is loaded where it fits, holding the place with a block of the same height until it arrives", async () => {
|
||||||
|
mockViewport({ phone: false });
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectReel />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// enquanto o pacote chega: o bloco reservado (com a conta da altura), não uma página que encolhe
|
||||||
|
const hold = container.querySelector(".ds-reel-placeholder");
|
||||||
|
expect(hold).not.toBeNull();
|
||||||
|
expect(hold).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect((hold as HTMLElement).style.getPropertyValue("--reel-sum")).not.toBe(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("pinned-reel")).toBeNull();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("pinned-reel")).toBeInTheDocument();
|
||||||
|
expect(mockLoads.pinned).toBe(1);
|
||||||
|
expect(container.querySelector(".ds-reel-placeholder")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4. the page code never imports the heavy modules by name: only the *Lazy files do, with import()", () => {
|
||||||
|
const fs = require("node:fs") as typeof import("node:fs");
|
||||||
|
const path = require("node:path") as typeof import("node:path");
|
||||||
|
const read = (file: string) =>
|
||||||
|
fs.readFileSync(path.join(__dirname, file), "utf8");
|
||||||
|
|
||||||
|
expect(read("ShowcaseProvider.tsx")).not.toMatch(
|
||||||
|
/from "\.\/ProjectLightbox"/,
|
||||||
|
);
|
||||||
|
expect(read("ProjectReel.tsx")).not.toMatch(/from "\.\/PinnedReel"/);
|
||||||
|
expect(read("ProjectLightboxLazy.tsx")).toMatch(
|
||||||
|
/import\("\.\/ProjectLightbox"\)/,
|
||||||
|
);
|
||||||
|
expect(read("PinnedReelLazy.tsx")).toMatch(/import\("\.\/PinnedReel"\)/);
|
||||||
|
// e o CSS de cada um viaja com o componente dele, não com a página
|
||||||
|
expect(read("PinnedReel.tsx")).toMatch(/import "\.\/pinned-reel\.css"/);
|
||||||
|
expect(read("ProjectLightbox.tsx")).toMatch(/import "\.\/lightbox\.css"/);
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(__dirname, "../../layout.tsx"), "utf8"),
|
||||||
|
).not.toMatch(/pinned-reel\.css|lightbox\.css|reel-list\.css/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/*
|
||||||
|
* Vídeo ampliado (ProjectLightbox): janela modal com a troca desktop/celular, capítulos e controles próprios.
|
||||||
|
* Carrega sob demanda, na primeira vez que alguém amplia um projeto.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html.ds-lb-open {
|
||||||
|
overflow: hidden;
|
||||||
|
/* a barra de rolagem clássica some junto: devolve o espaço dela para a página não pular */
|
||||||
|
padding-right: var(--ds-scrollbar, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb {
|
||||||
|
--accent: var(--slash);
|
||||||
|
color-scheme: dark;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(12px, 3vw, 32px);
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(5, 5, 5, 0.88);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
&[open] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: ds-lb-in 0.28s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ds-lb-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ds-lb[open] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
height: min(100%, 900px);
|
||||||
|
min-height: 0;
|
||||||
|
padding: clamp(14px, 2.4vw, 24px);
|
||||||
|
overflow: auto;
|
||||||
|
background: #0c0c0c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 0 120px -30px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-3) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-heading {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(20px, 2.6vw, 30px);
|
||||||
|
font-weight: var(--fw-card);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* o palco mede a si mesmo: o aparelho cresce até o limite da largura ou da altura que sobrar */
|
||||||
|
.ds-lb-stage {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-frame {
|
||||||
|
width: min(100cqw, calc((100cqh - 30px) * 1.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-screen {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0a0a center top / cover no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-phone {
|
||||||
|
height: min(100cqh, calc(100cqw * 866 / 390));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-play {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-nav button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-time {
|
||||||
|
flex: none;
|
||||||
|
min-width: 72px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-range {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::-webkit-slider-runnable-track {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: -5.5px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--slash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-chapters {
|
||||||
|
display: flex;
|
||||||
|
flex: none; /* é filha de coluna flex: sem isto a fileira rolável encolhe a quase nada em celular baixo */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
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) {
|
||||||
|
.ds-lb-chapters button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2) var(--sp-3);
|
||||||
|
|
||||||
|
& .ds-link.ds-link-muted {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ds-link.ds-link-text {
|
||||||
|
color: rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.ds-lb {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-panel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-chapters {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-chapters::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-time {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* telas muito estreitas: nome, versão e setas não cabem na mesma linha; o alternador de versão ocupa uma só */
|
||||||
|
@media (max-width: 389px) {
|
||||||
|
.ds-lb-panel {
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-heading {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-toggle {
|
||||||
|
order: 3;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-toggle button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* celular deitado e janela de desktop curta: vídeo grande ao lado, controles em coluna (empilhado, o vídeo ficava com 200px de altura) */
|
||||||
|
@media ((max-height: 520px) and (min-width: 640px)) or (
|
||||||
|
(max-height: 700px) and (min-width: 900px)
|
||||||
|
) {
|
||||||
|
.ds-lb {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"head stage"
|
||||||
|
"ctrl stage"
|
||||||
|
"chap stage"
|
||||||
|
"links stage";
|
||||||
|
gap: var(--sp-3) var(--sp-4);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-head {
|
||||||
|
grid-area: head;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-controls {
|
||||||
|
grid-area: ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-chapters {
|
||||||
|
grid-area: chap;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-chapters::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-links {
|
||||||
|
grid-area: links;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lb-stage {
|
||||||
|
grid-area: stage;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────── Foco ─────────────────────────── */
|
||||||
|
|
||||||
|
/* Lima, por dentro, nos botões que ficam dentro de contêineres que cortam o que passa da borda; por fora, nos soltos.
|
||||||
|
Sem o halo do anel duplo global (globals.css): ele é para fundos claros. */
|
||||||
|
:is(.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
/*
|
||||||
|
* Vitrine animada (ProjectReel mostra a fixa quando ela cabe): o site de cada projeto rola dentro de um notebook (e de
|
||||||
|
* um celular ao lado) conforme a página rola. Carrega sob demanda, junto com PinnedReel; o casco `.ds-reel`, a legenda
|
||||||
|
* e os botões vêm de reel-list.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ds-reel-pin {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--site-header-height);
|
||||||
|
height: var(--pin-h);
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: var(--sp-4);
|
||||||
|
height: 100%;
|
||||||
|
padding-block: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.ds-reel-grid {
|
||||||
|
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: var(--sp-10);
|
||||||
|
padding-block: var(--sp-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lista de projetos: faixa rolável na horizontal em telas pequenas, coluna no desktop */
|
||||||
|
.ds-reel-index {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
& > ol {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ol::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-current="true"] {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-hint {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 10px;
|
||||||
|
margin-bottom: var(--sp-5);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-hint-line {
|
||||||
|
width: 24px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-hint-sub {
|
||||||
|
width: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--slash);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* trechos do projeto em cena: só no desktop, onde sobra coluna; cada um leva ao ponto do vídeo */
|
||||||
|
.ds-reel-chapters {
|
||||||
|
display: none;
|
||||||
|
list-style: none;
|
||||||
|
margin: 6px 0 10px 16px;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
.ds-reel-chapters {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) and (pointer: coarse) {
|
||||||
|
.ds-reel-chapter-pick {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--slash);
|
||||||
|
transform: scaleX(var(--p, 0));
|
||||||
|
transform-origin: left;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-pick[aria-current="true"] .ds-reel-bar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.ds-reel-name {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.ds-reel-hint {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-index > ol {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-pick {
|
||||||
|
width: 100%;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-stage {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
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 */
|
||||||
|
.ds-reel-set {
|
||||||
|
--caption-h: 13.75rem; /* reserva da legenda: título, trecho atual e botões */
|
||||||
|
--gap: var(--sp-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
/* cabe na largura (com folga para o celular sobrepor) e na altura (legenda + barra de 30px + tela 16:10) */
|
||||||
|
width: min(90cqw, calc((100cqh - var(--caption-h) - var(--gap) - 30px) * 1.6));
|
||||||
|
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;
|
||||||
|
inset: -4% 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(
|
||||||
|
55% 60% at 50% 50%,
|
||||||
|
color-mix(in srgb, var(--accent) 42%, transparent),
|
||||||
|
transparent 72%
|
||||||
|
);
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-laptop {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-screen {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0a0a center top / cover no-repeat;
|
||||||
|
|
||||||
|
& > video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-phone {
|
||||||
|
position: absolute;
|
||||||
|
right: -5%;
|
||||||
|
bottom: -3%;
|
||||||
|
width: 22%;
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-chapter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
& strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-chapter-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-ticks {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
& li {
|
||||||
|
width: 18px;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
& li[data-state="done"] {
|
||||||
|
background: rgba(204, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
& li[data-state="now"] {
|
||||||
|
background: var(--slash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* desktop com pouca altura: a lista de projetos e os trechos têm de caber na altura do pin */
|
||||||
|
@media (min-width: 1024px) and (max-height: 700px) {
|
||||||
|
.ds-reel-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) and (max-height: 700px) and (pointer: fine) {
|
||||||
|
.ds-reel-pick {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-chapter-pick {
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* janela curta: a reserva da legenda encolhe e o notebook ganha tela */
|
||||||
|
@media (min-width: 640px) and (max-height: 700px) {
|
||||||
|
.ds-reel-set {
|
||||||
|
--caption-h: 10.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anel de foco do índice (sempre sobre fundo escuro): lima, por dentro, porque a faixa rolável e a lista de trechos
|
||||||
|
cortam o que passa da borda. Sem o halo do anel duplo global (globals.css): ele é para fundos claros. */
|
||||||
|
:is(.ds-reel-pick, .ds-reel-chapter-pick):focus-visible {
|
||||||
|
outline: 2px solid var(--slash);
|
||||||
|
outline-offset: -4px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/*
|
||||||
|
* Lista de projetos e o que a vitrine fixa divide com ela (legenda, título, botões), mais o casco `.ds-reel` e o
|
||||||
|
* bloco que segura o lugar da vitrine antes de o navegador montar a página. Carrega com ProjectReel e ProjectList
|
||||||
|
* (a home). A vitrine fixa (pinned-reel.css) e a janela ampliada (lightbox.css) vêm sob demanda, com seus
|
||||||
|
* componentes; as molduras de navegador e de celular, que o hero e os cases também usam, ficam em showcase.css
|
||||||
|
* (global). Cantos retos, rótulos curtos em mono (12 px no mínimo), texto de botão em sans 15 px, lima só em
|
||||||
|
* detalhes (nenhum botão preenchido de lima aqui: isso é da ação de contato) e a cor do site de cada projeto
|
||||||
|
* (--accent) só no brilho ao redor do vídeo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ─────────────────────────── Casco da vitrine fixa ─────────────────────────── */
|
||||||
|
|
||||||
|
.ds-reel {
|
||||||
|
--pin-h: calc(100svh - var(--site-header-height));
|
||||||
|
position: relative;
|
||||||
|
margin-top: var(--sp-16);
|
||||||
|
/* a cena fica presa durante (soma dos pesos) alturas de tela de rolagem */
|
||||||
|
height: calc(var(--pin-h) + var(--reel-sum, 1) * 100svh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tela baixa (celular deitado, janela de desktop curta): a vitrine fixa não cabe, e a lista de projetos entra no lugar */
|
||||||
|
@media (max-height: 560px) {
|
||||||
|
.ds-reel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* antes de o navegador montar a página, só onde a vitrine fixa vai existir: o bloco reservado aparece e a lista
|
||||||
|
(que o servidor entregou, com o texto e os links) fica escondida; sem JavaScript, a lista é quem aparece */
|
||||||
|
.ds-reel-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (min-height: 561px) and (
|
||||||
|
prefers-reduced-motion: no-preference
|
||||||
|
) and (scripting: enabled) {
|
||||||
|
.ds-reel-placeholder {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-list[data-pending] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────── Legenda e botões de um projeto ─────────────────────────── */
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(22px, 3vw, 38px);
|
||||||
|
font-weight: var(--fw-card);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-sub {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. */
|
||||||
|
.ds-reel-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2) var(--sp-3);
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
|
||||||
|
& .ds-link.ds-link-muted {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
& .ds-link.ds-link-muted:hover {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ds-link.ds-link-text {
|
||||||
|
color: rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-expand {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* janela curta: sem o subtítulo a legenda cabe em menos altura */
|
||||||
|
@media (min-width: 640px) and (max-height: 700px) {
|
||||||
|
.ds-reel-sub {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ds-reel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────── Lista de projetos ─────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sem rolagem presa: celular, movimento reduzido e janelas baixas. No celular é um carrossel por toque
|
||||||
|
* (rolagem horizontal com encaixe; o card seguinte aparece pela borda); acima de 768 px, uma grade.
|
||||||
|
*/
|
||||||
|
.ds-reel-list {
|
||||||
|
margin-top: var(--sp-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-items {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
|
||||||
|
gap: var(--sp-8) var(--sp-6);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item {
|
||||||
|
--accent: var(--slash);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item-media {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -4% 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(
|
||||||
|
55% 60% at 50% 50%,
|
||||||
|
color-mix(in srgb, var(--accent) 36%, transparent),
|
||||||
|
transparent 72%
|
||||||
|
);
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item-screen {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0a0a;
|
||||||
|
|
||||||
|
& > img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item-phone {
|
||||||
|
width: min(62%, 240px);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.ds-reel-list {
|
||||||
|
margin-top: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-items {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-padding-inline: var(--sp-5);
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* o card seguinte aparece pela borda: é o convite para deslizar */
|
||||||
|
margin-inline: calc(var(--sp-5) * -1);
|
||||||
|
padding: 2px var(--sp-5) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-items::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item {
|
||||||
|
flex: 0 0 min(82%, 340px);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
padding: var(--sp-5) var(--sp-4);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* um cartão mais baixo: sem subtítulo a legenda cabe e o celular da moldura ganha espaço; texto corrido em 15 px */
|
||||||
|
.ds-reel-item .ds-reel-sub {
|
||||||
|
font-size: 15px;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sem JavaScript o carrossel vira lista vertical: nada fica escondido atrás de uma rolagem lateral */
|
||||||
|
@media (scripting: none) {
|
||||||
|
.ds-reel-items {
|
||||||
|
display: grid;
|
||||||
|
overflow: visible;
|
||||||
|
margin-inline: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-item {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────── Carrossel do celular: contagem e botões ─────────────────────────── */
|
||||||
|
|
||||||
|
.ds-reel-count {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* "1/4" e pontos logo abaixo do carrossel: quantos há e em qual se está */
|
||||||
|
.ds-reel-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reel-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
& i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.24);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
& i[data-on] {
|
||||||
|
background: var(--slash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* botões alinhados em todos os cartões: "Ver case" em largura total, "Ampliar" só com o ícone (44 px, o alvo de
|
||||||
|
toque mínimo) ao lado, e "Abrir site" numa linha só dele */
|
||||||
|
.ds-reel-item .ds-reel-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--sp-2) 0;
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
|
||||||
|
& .ds-link.ds-link-muted:not(.ds-reel-expand) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ds-reel-expand {
|
||||||
|
width: 44px;
|
||||||
|
margin-left: var(--sp-2);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ds-link-text {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* o rótulo sai da vista mas não do leitor de tela (mesma técnica do .sr-only) */
|
||||||
|
.ds-reel-item .ds-reel-expand-label {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,3 +64,6 @@ export function easeToward(
|
|||||||
const factor = 1 - Math.exp(-Math.max(0, deltaSeconds) / tau);
|
const factor = 1 - Math.exp(-Math.max(0, deltaSeconds) / tau);
|
||||||
return current + (target - current) * factor;
|
return current + (target - current) * factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import { moveRovingFocus, useRovingTabindex } from "./useRovingTabindex";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de moveRovingFocus e useRovingTabindex (complemento de useRovingTabindex.test.tsx):
|
||||||
|
* cada tecla de gesto, cada modificador sozinho, grupo de um item só, o item atual escondido por CSS, um alvo que
|
||||||
|
* não é elemento, o foco em algo do grupo que não é item e a parada que deixa de valer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEYS = ["p:0", "p:1", "p:2"];
|
||||||
|
|
||||||
|
function Group({
|
||||||
|
count = 3,
|
||||||
|
validBelow = 99,
|
||||||
|
}: {
|
||||||
|
count?: number;
|
||||||
|
/** Chaves `p:<n>` com n a partir daqui deixam de valer (o item some do grupo). */
|
||||||
|
validBelow?: number;
|
||||||
|
}) {
|
||||||
|
const { groupProps, itemProps } = useRovingTabindex("p:0", (key) => {
|
||||||
|
const [, index] = key.split(":");
|
||||||
|
return Number(index) < validBelow;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<nav aria-label="grupo" {...groupProps}>
|
||||||
|
{KEYS.slice(0, count).map((key) => (
|
||||||
|
<button key={key} type="button" {...itemProps(key)}>
|
||||||
|
{`item ${key.slice(2)}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<a href="#fora">link comum</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = (index: number) =>
|
||||||
|
screen.getByRole("button", { name: `item ${index}` });
|
||||||
|
|
||||||
|
describe("moveRovingFocus: um gesto por tecla", () => {
|
||||||
|
it.each([
|
||||||
|
["ArrowDown", 2],
|
||||||
|
["ArrowRight", 2],
|
||||||
|
["ArrowUp", 0],
|
||||||
|
["ArrowLeft", 0],
|
||||||
|
["Home", 0],
|
||||||
|
["End", 2],
|
||||||
|
])(
|
||||||
|
"path: %s from the middle item -> focus on item %i, the default is cancelled",
|
||||||
|
(key, expected) => {
|
||||||
|
render(<Group />);
|
||||||
|
item(1).focus();
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(item(1), { key })).toBe(false);
|
||||||
|
|
||||||
|
expect(item(expected)).toHaveFocus();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["altKey", "ctrlKey", "metaKey", "shiftKey"] as const)(
|
||||||
|
"path: ArrowDown with %s alone -> left to the browser (not cancelled, focus stays)",
|
||||||
|
(modifier) => {
|
||||||
|
render(<Group />);
|
||||||
|
item(0).focus();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fireEvent.keyDown(item(0), { key: "ArrowDown", [modifier]: true }),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(item(0)).toHaveFocus();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("path: a group with one item only -> every gesture stays on it (and is still cancelled)", () => {
|
||||||
|
render(<Group count={1} />);
|
||||||
|
item(0).focus();
|
||||||
|
|
||||||
|
for (const key of ["ArrowDown", "ArrowUp", "Home", "End"]) {
|
||||||
|
expect(fireEvent.keyDown(item(0), { key })).toBe(false);
|
||||||
|
expect(item(0)).toHaveFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moveRovingFocus: quando o gesto não é do grupo", () => {
|
||||||
|
it("path: the current item is itself hidden by CSS -> it is not among the shown ones, so the key is left alone", () => {
|
||||||
|
const original = HTMLElement.prototype.checkVisibility;
|
||||||
|
HTMLElement.prototype.checkVisibility = function check(this: HTMLElement) {
|
||||||
|
return this.textContent !== "item 1";
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
render(<Group />);
|
||||||
|
item(1).focus();
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(item(1), { key: "ArrowDown" })).toBe(true);
|
||||||
|
|
||||||
|
expect(item(1)).toHaveFocus();
|
||||||
|
} finally {
|
||||||
|
HTMLElement.prototype.checkVisibility = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the browser has no checkVisibility -> every item counts as shown", () => {
|
||||||
|
const original = HTMLElement.prototype.checkVisibility;
|
||||||
|
Reflect.deleteProperty(HTMLElement.prototype, "checkVisibility");
|
||||||
|
try {
|
||||||
|
render(<Group />);
|
||||||
|
item(0).focus();
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(item(0), { key: "End" })).toBe(false);
|
||||||
|
|
||||||
|
expect(item(2)).toHaveFocus();
|
||||||
|
} finally {
|
||||||
|
HTMLElement.prototype.checkVisibility = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the key target is not an element (the document) -> nothing happens", () => {
|
||||||
|
const preventDefault = jest.fn();
|
||||||
|
const group = document.createElement("div");
|
||||||
|
|
||||||
|
moveRovingFocus({
|
||||||
|
key: "ArrowDown",
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
target: document,
|
||||||
|
currentTarget: group,
|
||||||
|
preventDefault,
|
||||||
|
} as unknown as KeyboardEvent<HTMLElement>);
|
||||||
|
|
||||||
|
expect(preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a key that is not a gesture (a letter) -> left alone", () => {
|
||||||
|
render(<Group />);
|
||||||
|
item(0).focus();
|
||||||
|
|
||||||
|
expect(fireEvent.keyDown(item(0), { key: "a" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useRovingTabindex: a parada do Tab", () => {
|
||||||
|
it("path: focus on something of the group that is not an item -> the tab stop does not move", () => {
|
||||||
|
render(<Group />);
|
||||||
|
act(() => item(2).focus());
|
||||||
|
expect(item(2)).toHaveAttribute("tabindex", "0");
|
||||||
|
|
||||||
|
act(() => screen.getByRole("link", { name: "link comum" }).focus());
|
||||||
|
|
||||||
|
expect(item(2)).toHaveAttribute("tabindex", "0");
|
||||||
|
expect(item(0)).toHaveAttribute("tabindex", "-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the remembered stop stops being valid (that item left the group) -> back to the fallback", () => {
|
||||||
|
const { rerender } = render(<Group />);
|
||||||
|
act(() => item(2).focus());
|
||||||
|
expect(item(2)).toHaveAttribute("tabindex", "0");
|
||||||
|
|
||||||
|
rerender(<Group count={2} validBelow={2} />);
|
||||||
|
|
||||||
|
expect(item(0)).toHaveAttribute("tabindex", "0");
|
||||||
|
expect(item(1)).toHaveAttribute("tabindex", "-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRovingTabindex } from "./useRovingTabindex";
|
||||||
|
|
||||||
|
function Group({ hidden = false }: { hidden?: boolean }) {
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const { groupProps, itemProps } = useRovingTabindex(`p:${current}`, (key) =>
|
||||||
|
key.startsWith("p:"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button">antes</button>
|
||||||
|
<nav aria-label="grupo" {...groupProps}>
|
||||||
|
{["a", "b", "c"].map((name, index) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrent(index)}
|
||||||
|
{...itemProps(`p:${index}`)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{hidden && (
|
||||||
|
<button type="button" {...itemProps("p:9")} hidden>
|
||||||
|
escondido
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<button type="button">depois</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabIndexes = () =>
|
||||||
|
["a", "b", "c"].map((name) =>
|
||||||
|
screen.getByRole("button", { name }).getAttribute("tabindex"),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("useRovingTabindex", () => {
|
||||||
|
it("makes the group ONE tab stop: only the current item has tabindex 0", () => {
|
||||||
|
render(<Group />);
|
||||||
|
expect(tabIndexes()).toEqual(["0", "-1", "-1"]);
|
||||||
|
// fora do grupo, os botões comuns seguem como estão
|
||||||
|
expect(screen.getByRole("button", { name: "antes" })).not.toHaveAttribute(
|
||||||
|
"tabindex",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the focus with the arrows (and Home/End), stopping at the ends", () => {
|
||||||
|
render(<Group />);
|
||||||
|
const [a, b, c] = ["a", "b", "c"].map((name) =>
|
||||||
|
screen.getByRole("button", { name }),
|
||||||
|
);
|
||||||
|
a.focus();
|
||||||
|
fireEvent.keyDown(a, { key: "ArrowDown" });
|
||||||
|
expect(b).toHaveFocus();
|
||||||
|
fireEvent.keyDown(b, { key: "ArrowRight" });
|
||||||
|
expect(c).toHaveFocus();
|
||||||
|
fireEvent.keyDown(c, { key: "ArrowDown" });
|
||||||
|
expect(c).toHaveFocus();
|
||||||
|
fireEvent.keyDown(c, { key: "ArrowUp" });
|
||||||
|
expect(b).toHaveFocus();
|
||||||
|
fireEvent.keyDown(b, { key: "ArrowLeft" });
|
||||||
|
expect(a).toHaveFocus();
|
||||||
|
fireEvent.keyDown(a, { key: "ArrowUp" });
|
||||||
|
expect(a).toHaveFocus();
|
||||||
|
fireEvent.keyDown(a, { key: "End" });
|
||||||
|
expect(c).toHaveFocus();
|
||||||
|
fireEvent.keyDown(c, { key: "Home" });
|
||||||
|
expect(a).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the tab stop where the focus last was, so Shift+Tab and Tab come back to the same item", () => {
|
||||||
|
render(<Group />);
|
||||||
|
const b = screen.getByRole("button", { name: "b" });
|
||||||
|
screen.getByRole("button", { name: "a" }).focus();
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "a" }), {
|
||||||
|
key: "ArrowDown",
|
||||||
|
});
|
||||||
|
expect(b).toHaveFocus();
|
||||||
|
expect(tabIndexes()).toEqual(["-1", "0", "-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the current item while nothing has been focused yet", () => {
|
||||||
|
const { rerender } = render(<Group />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "c" }));
|
||||||
|
rerender(<Group />);
|
||||||
|
expect(tabIndexes()).toEqual(["-1", "-1", "0"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Tab, Enter, space and any arrow with a modifier alone (no preventDefault)", () => {
|
||||||
|
render(<Group />);
|
||||||
|
const a = screen.getByRole("button", { name: "a" });
|
||||||
|
a.focus();
|
||||||
|
for (const init of [
|
||||||
|
{ key: "Tab" },
|
||||||
|
{ key: "Enter" },
|
||||||
|
{ key: " " },
|
||||||
|
{ key: "ArrowDown", altKey: true },
|
||||||
|
{ key: "ArrowDown", ctrlKey: true },
|
||||||
|
{ key: "ArrowDown", metaKey: true },
|
||||||
|
{ key: "ArrowDown", shiftKey: true },
|
||||||
|
])
|
||||||
|
expect(fireEvent.keyDown(a, init)).toBe(true);
|
||||||
|
expect(a).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels the page scroll of an arrow that moved the focus", () => {
|
||||||
|
render(<Group />);
|
||||||
|
const a = screen.getByRole("button", { name: "a" });
|
||||||
|
a.focus();
|
||||||
|
expect(fireEvent.keyDown(a, { key: "ArrowDown" })).toBe(false);
|
||||||
|
expect(
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "b" }), {
|
||||||
|
key: "Home",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips items that CSS hides (they cannot take the focus)", () => {
|
||||||
|
const original = HTMLElement.prototype.checkVisibility;
|
||||||
|
HTMLElement.prototype.checkVisibility = function check(this: HTMLElement) {
|
||||||
|
return !this.hidden;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
render(<Group hidden />);
|
||||||
|
const c = screen.getByRole("button", { name: "c" });
|
||||||
|
c.focus();
|
||||||
|
fireEvent.keyDown(c, { key: "ArrowDown" });
|
||||||
|
expect(c).toHaveFocus();
|
||||||
|
fireEvent.keyDown(c, { key: "End" });
|
||||||
|
expect(c).toHaveFocus();
|
||||||
|
} finally {
|
||||||
|
HTMLElement.prototype.checkVisibility = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores keys pressed on something that is not one of the items", () => {
|
||||||
|
render(<Group />);
|
||||||
|
const nav = screen.getByRole("navigation", { name: "grupo" });
|
||||||
|
expect(fireEvent.keyDown(nav, { key: "ArrowDown" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type FocusEvent, type KeyboardEvent, useState } from "react";
|
||||||
|
|
||||||
|
/** Atributo que marca os itens do grupo; o valor é a chave do item. */
|
||||||
|
const ITEM_ATTRIBUTE = "data-roving";
|
||||||
|
|
||||||
|
type Step = "next" | "previous" | "first" | "last";
|
||||||
|
|
||||||
|
const STEPS: Record<string, Step> = {
|
||||||
|
ArrowDown: "next",
|
||||||
|
ArrowRight: "next",
|
||||||
|
ArrowUp: "previous",
|
||||||
|
ArrowLeft: "previous",
|
||||||
|
Home: "first",
|
||||||
|
End: "last",
|
||||||
|
};
|
||||||
|
|
||||||
|
function targetIndex(step: Step, at: number, count: number) {
|
||||||
|
if (step === "first") return 0;
|
||||||
|
if (step === "last") return count - 1;
|
||||||
|
return Math.min(count - 1, Math.max(0, at + (step === "next" ? 1 : -1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O item aparece na tela? (um item dentro de um bloco escondido por CSS não recebe foco). */
|
||||||
|
const isShown = (element: HTMLElement) => element.checkVisibility?.() ?? true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teclado de um grupo de itens com roving tabindex: as setas (e Home/End) movem o foco entre os itens marcados com
|
||||||
|
* `data-roving`, na ordem do documento, sem passar das pontas. Quem não é um desses gestos segue o caminho normal.
|
||||||
|
*/
|
||||||
|
export function moveRovingFocus(event: KeyboardEvent<HTMLElement>) {
|
||||||
|
const step = STEPS[event.key];
|
||||||
|
if (!step || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||||
|
return;
|
||||||
|
const current =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest<HTMLElement>(`[${ITEM_ATTRIBUTE}]`)
|
||||||
|
: null;
|
||||||
|
if (!current) return;
|
||||||
|
const items = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLElement>(`[${ITEM_ATTRIBUTE}]`),
|
||||||
|
).filter(isShown);
|
||||||
|
const at = items.indexOf(current);
|
||||||
|
if (at < 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
items[targetIndex(step, at, items.length)]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Um grupo de controles que ocupa uma parada só no Tab (roving tabindex): só o item da vez tem tabindex 0 e os
|
||||||
|
* demais -1; as setas movem o foco dentro do grupo. A parada é o último item que recebeu foco, enquanto ele existir
|
||||||
|
* (`isValid`); senão, `fallback` (o item atual do grupo).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { groupProps, itemProps } = useRovingTabindex("p:0", (key) => key.startsWith("p:"));
|
||||||
|
* <nav {...groupProps}><button {...itemProps("p:0")}>…</button></nav>
|
||||||
|
*/
|
||||||
|
export function useRovingTabindex(
|
||||||
|
fallback: string,
|
||||||
|
isValid: (key: string) => boolean,
|
||||||
|
) {
|
||||||
|
const [stop, setStop] = useState<string | null>(null);
|
||||||
|
const tabStop = stop !== null && isValid(stop) ? stop : fallback;
|
||||||
|
return {
|
||||||
|
groupProps: {
|
||||||
|
onKeyDown: moveRovingFocus,
|
||||||
|
onFocus(event: FocusEvent<HTMLElement>) {
|
||||||
|
const key = event.target
|
||||||
|
.closest(`[${ITEM_ATTRIBUTE}]`)
|
||||||
|
?.getAttribute(ITEM_ATTRIBUTE);
|
||||||
|
if (key) setStop(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemProps: (key: string) => ({
|
||||||
|
[ITEM_ATTRIBUTE]: key,
|
||||||
|
tabIndex: key === tabStop ? 0 : -1,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@
|
|||||||
--shell: clamp(1240px, 76vw, 1760px);
|
--shell: clamp(1240px, 76vw, 1760px);
|
||||||
--fs-section-title: min(max(clamp(24px, 4vw, 44px), 3.05vw), 68px);
|
--fs-section-title: min(max(clamp(24px, 4vw, 44px), 3.05vw), 68px);
|
||||||
--fs-lead: min(max(15px, 1.05vw), 19px);
|
--fs-lead: min(max(15px, 1.05vw), 19px);
|
||||||
|
/* Piso do texto corrido em qualquer tela. Só os rótulos mono curtos (.ds-eyebrow, .ds-kicker, .ds-tag,
|
||||||
|
.ds-metric) descem a 12 px. */
|
||||||
|
--fs-body: 15px;
|
||||||
|
/* Dois pesos de título, e só: seção (h1 e h2) e cartão (h3 e menores). */
|
||||||
|
--fw-section: 700;
|
||||||
|
--fw-card: 600;
|
||||||
--font-display: var(--font-space-grotesk), sans-serif;
|
--font-display: var(--font-space-grotesk), sans-serif;
|
||||||
--font-mono: var(--font-geist-mono), monospace;
|
--font-mono: var(--font-geist-mono), monospace;
|
||||||
--font-body: var(--font-display);
|
--font-body: var(--font-display);
|
||||||
@@ -72,6 +78,20 @@
|
|||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* O preflight deixa os títulos herdarem o peso do corpo (400); aqui cada um nasce num dos dois pesos. Fica na
|
||||||
|
camada base: classes e utilitários de um componente ainda mandam. */
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-weight: var(--fw-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-weight: var(--fw-card);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -93,39 +113,6 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header-nav,
|
|
||||||
.site-header-cv,
|
|
||||||
.site-header-version {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header-menu-toggle,
|
|
||||||
.site-header-mobile-panel {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header-menu-toggle {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
flex: 0 0 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
|
||||||
.site-header-nav {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header-cv,
|
|
||||||
.site-header-version {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header-menu-toggle,
|
|
||||||
.site-header-mobile-panel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -157,6 +144,12 @@ html {
|
|||||||
box-shadow: 0 0 0 6px var(--slash);
|
box-shadow: 0 0 0 6px var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* O contêiner da página só recebe foco por código (o link "Pular" e a troca de página, em app/template.tsx): ele
|
||||||
|
não é um controle, então não desenha anel em volta do conteúdo inteiro. */
|
||||||
|
#main-content:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.film-grain {
|
.film-grain {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -195,7 +188,7 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
/* sans por padrão: a mono é só dos rótulos (.ds-eyebrow, .ds-kicker, .ds-tag...), que a pedem à parte */
|
/* sans por padrão: a mono é só dos rótulos (.ds-eyebrow, .ds-kicker, .ds-tag...), que a pedem à parte */
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 14px;
|
font-size: var(--fs-body);
|
||||||
line-height: 1.68;
|
line-height: 1.68;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,15 +205,7 @@ svg {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(
|
:where(.ds-eyebrow, .ds-kicker, .ds-tag, .ds-metric) {
|
||||||
.ds-eyebrow,
|
|
||||||
.ds-kicker,
|
|
||||||
.ds-tag,
|
|
||||||
.ds-metric,
|
|
||||||
.quality-code,
|
|
||||||
.quality-hint,
|
|
||||||
.quality-disclaimer
|
|
||||||
) {
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,24 +228,6 @@ textarea {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-grid {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(10, 10, 10, 0.03) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(10, 10, 10, 0.03) 1px, transparent 1px);
|
|
||||||
background-size: 48px 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-grid-light {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
|
||||||
background-size: 48px 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-main {
|
|
||||||
/* no top padding needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-section {
|
.ds-section {
|
||||||
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
|
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
@@ -348,7 +315,7 @@ textarea {
|
|||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: var(--fs-section-title);
|
font-size: var(--fs-section-title);
|
||||||
line-height: 1.02;
|
line-height: 1.02;
|
||||||
font-weight: 800;
|
font-weight: var(--fw-section);
|
||||||
letter-spacing: -0.035em;
|
letter-spacing: -0.035em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,51 +326,26 @@ textarea {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-grid-2,
|
.ds-grid-2 {
|
||||||
.ds-grid-4 {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--sp-6);
|
gap: var(--sp-6);
|
||||||
}
|
|
||||||
|
|
||||||
.ds-grid-2 {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Colunas automáticas: sem breakpoint. Cabem 2 até ~1360px de largura útil e 3 acima disso. */
|
.ds-card {
|
||||||
.ds-grid-fluid {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--sp-6);
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-grid-4 {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-card,
|
|
||||||
.ds-panel {
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.ds-card {
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
padding: clamp(20px, 3vw, 28px);
|
padding: clamp(20px, 3vw, 28px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-panel {
|
|
||||||
background: rgba(10, 10, 10, 0.03);
|
|
||||||
padding: var(--sp-5);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-card-title {
|
.ds-card-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: clamp(16px, 2.4vw, 30px);
|
font-size: clamp(16px, 2.4vw, 30px);
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
|
font-weight: var(--fw-card);
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
margin-top: var(--sp-4);
|
margin-top: var(--sp-4);
|
||||||
}
|
}
|
||||||
@@ -544,15 +486,26 @@ textarea {
|
|||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* O preflight do Tailwind zera o list-style de ul/ol: o marcador volta aqui. Na cor do texto (o lima sobre o
|
||||||
|
creme some); no fundo escuro, o lima. Uma lista numerada (list-decimal) ou outra marca vence por vir depois. */
|
||||||
.ds-list {
|
.ds-list {
|
||||||
margin-top: var(--sp-5);
|
margin-top: var(--sp-5);
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
list-style: disc outside;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ol.ds-list {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
.ds-list li::marker {
|
.ds-list li::marker {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-section-dark .ds-list li::marker {
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,8 +519,7 @@ textarea {
|
|||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-section-dark .ds-card,
|
.ds-section-dark .ds-card {
|
||||||
.ds-section-dark .ds-panel {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
@@ -606,135 +558,6 @@ textarea {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes marquee {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes marquee-reverse {
|
|
||||||
from {
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-text {
|
|
||||||
position: relative;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-viewport {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-track,
|
|
||||||
.scroll-velocity-group {
|
|
||||||
display: flex;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-group {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: clamp(3rem, 10vw, 8rem);
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-dot {
|
|
||||||
margin-inline: var(--sp-8);
|
|
||||||
color: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-marquee {
|
|
||||||
animation: marquee 40s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-marquee-reverse {
|
|
||||||
animation: marquee-reverse 45s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-text:hover .scroll-velocity-track,
|
|
||||||
.scroll-velocity-text:focus-within .scroll-velocity-track {
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-control {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--sp-2);
|
|
||||||
right: var(--sp-3);
|
|
||||||
z-index: 1;
|
|
||||||
min-height: 44px;
|
|
||||||
border: 1px solid rgba(244, 242, 237, 0.5);
|
|
||||||
background: var(--ink);
|
|
||||||
color: var(--paper);
|
|
||||||
padding-inline: var(--sp-3);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.scroll-velocity-text {
|
|
||||||
color: var(--paper);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-track {
|
|
||||||
animation: none;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-viewport {
|
|
||||||
overflow: visible;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-track,
|
|
||||||
.scroll-velocity-group {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-group {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-group[aria-hidden="true"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-item {
|
|
||||||
display: block;
|
|
||||||
padding: var(--sp-6);
|
|
||||||
font-size: clamp(2rem, 8vw, 4rem);
|
|
||||||
line-height: 1.1;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-velocity-dot {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -751,35 +574,17 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.site-header-nav,
|
|
||||||
.site-header-cv,
|
|
||||||
.site-header-version {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header-menu-toggle,
|
|
||||||
.site-header-mobile-panel {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-section-header {
|
.ds-section-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-grid-2,
|
.ds-grid-2 {
|
||||||
.ds-grid-4 {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.ds-section {
|
||||||
padding: clamp(52px, 13vw, 64px) 20px;
|
padding: clamp(52px, 13vw, 64px) 20px;
|
||||||
}
|
}
|
||||||
@@ -817,129 +622,6 @@ textarea {
|
|||||||
CINEMATIC STYLES - Awwwards Level
|
CINEMATIC STYLES - Awwwards Level
|
||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
/* Section divider animations */
|
|
||||||
@keyframes shimmerDivider {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulseDot {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: scale(2);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-gradient {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-gradient span {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(90deg, transparent, var(--slash), transparent);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-gradient span:last-child {
|
|
||||||
animation: shimmerDivider 3s ease-in-out infinite;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-pulse {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding-block: var(--sp-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-pulse span {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--slash);
|
|
||||||
animation: pulseDot 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-pulse span:nth-child(2) {
|
|
||||||
animation-delay: 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-pulse span:nth-child(3) {
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-pulse span:nth-child(4) {
|
|
||||||
animation-delay: 0.45s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-pulse span:nth-child(5) {
|
|
||||||
animation-delay: 0.6s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-waveform {
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
color: var(--slash);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-waveform svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-waveform path {
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 1;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-waveform path:nth-child(1) {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-waveform path:nth-child(2) {
|
|
||||||
opacity: 0.07;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider-waveform path:nth-child(3) {
|
|
||||||
opacity: 0.04;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.section-divider-gradient span:last-child,
|
|
||||||
.section-divider-pulse span {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero: brilho de fundo e entrada em CSS (so posicao; o texto ja nasce visivel para o LCP) */
|
/* Hero: brilho de fundo e entrada em CSS (so posicao; o texto ja nasce visivel para o LCP) */
|
||||||
.hero-glow {
|
.hero-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -970,6 +652,14 @@ textarea {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Abaixo de 1024 px o palco tem a altura do conteúdo: sem faixa vazia sob os botões e a faixa de clientes (o
|
||||||
|
mergulho, mais abaixo, só roda onde a tela é larga o bastante e dá ao palco 100svh por conta própria). */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.hero-stage {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Máscara dos fundos ambientes do hero (cena 3D e malha estática): transparente no miolo, onde ficam o título
|
/* Máscara dos fundos ambientes do hero (cena 3D e malha estática): transparente no miolo, onde ficam o título
|
||||||
e as molduras da direita, e opaca nas bordas e cantos. Com o mergulho (--hero-open 0 → 1) o miolo some e o
|
e as molduras da direita, e opaca nas bordas e cantos. Com o mergulho (--hero-open 0 → 1) o miolo some e o
|
||||||
fundo aparece inteiro, quando o texto e a coluna da direita já saíram de cena. */
|
fundo aparece inteiro, quando o texto e a coluna da direita já saíram de cena. */
|
||||||
@@ -1044,6 +734,10 @@ textarea {
|
|||||||
|
|
||||||
/* Entrada: as palavras do título já pintam no primeiro quadro (sem opacity:0, bom para o LCP) e sobem 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) */
|
(só deslocamento: desfocar texto grande a cada quadro obriga a redesenhá-lo) */
|
||||||
|
.hero-title {
|
||||||
|
font-weight: var(--fw-section);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-word {
|
.hero-word {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: hero-word 1.1s var(--ease-out) both;
|
animation: hero-word 1.1s var(--ease-out) both;
|
||||||
@@ -1117,15 +811,16 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
@media (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
||||||
/* O palco (100svh, preso) fica parado durante o mergulho e mais uma tela: nela Projetos sobe sobre ele, com
|
/* O palco (100svh, preso) fica parado durante o mergulho e por mais 70svh: nesse trecho 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
|
margin-top de -70svh. A cena acaba de frente para os dados, a última legenda fica até Projetos cobri-la, e
|
||||||
mergulho e a seção seguinte (HeroField.tsx usa a mesma conta: o progresso acaba quando Projetos começa a subir). */
|
nunca há 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, 1,7 tela antes do fim do hero). */
|
||||||
.hero[data-dive] {
|
.hero[data-dive] {
|
||||||
height: calc(200svh + var(--hero-dive));
|
height: calc(170svh + var(--hero-dive));
|
||||||
|
|
||||||
& + .ds-section {
|
& + .ds-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: -100svh;
|
margin-top: -70svh;
|
||||||
box-shadow: 0 -32px 64px -24px rgba(0, 0, 0, 0.8);
|
box-shadow: 0 -32px 64px -24px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1139,11 +834,13 @@ textarea {
|
|||||||
padding: 0 var(--sp-6) 11svh;
|
padding: 0 var(--sp-6) 11svh;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
|
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena. O bloco entra
|
||||||
|
enquanto o texto do hero sai e nunca sai sozinho: ao fim do mergulho a legenda dos dados fica até Projetos
|
||||||
|
subir e cobri-la (nenhuma janela de meia tela sem texto). */
|
||||||
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
|
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
|
||||||
opacity: clamp(
|
opacity: clamp(
|
||||||
0,
|
0,
|
||||||
min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14),
|
min((var(--hero-p) - 0.08) * 14, (1.1 - var(--hero-p)) * 14),
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1152,7 +849,9 @@ textarea {
|
|||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
max-width: 26ch;
|
max-width: 26ch;
|
||||||
color: #f4f2ed;
|
color: #f4f2ed;
|
||||||
font: 600 clamp(24px, 2.8vw + 12px, 48px) / 1.12 var(--font-display);
|
font:
|
||||||
|
var(--fw-card) clamp(24px, 2.8vw + 12px, 48px) / 1.12
|
||||||
|
var(--font-display);
|
||||||
letter-spacing: -0.035em;
|
letter-spacing: -0.035em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
|
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
|
||||||
@@ -1169,20 +868,22 @@ textarea {
|
|||||||
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* mesmos intervalos em que a câmera enquadra cada camada (cameraFrames em HeroFieldScene.tsx) */
|
/* Intervalos em que a câmera enquadra cada camada (cameraFrames em HeroFieldScene.tsx), com folga: cada
|
||||||
|
legenda entra um pouco antes de a câmera chegar e sai um pouco depois de ela partir, para o intervalo sem
|
||||||
|
texto entre uma e outra ser de uns 80 px de rolagem. A última passa de 1 para ficar em cena até o fim. */
|
||||||
& li:nth-child(1) {
|
& li:nth-child(1) {
|
||||||
--from: 0.26;
|
--from: 0.18;
|
||||||
--to: 0.46;
|
--to: 0.49;
|
||||||
}
|
}
|
||||||
|
|
||||||
& li:nth-child(2) {
|
& li:nth-child(2) {
|
||||||
--from: 0.52;
|
--from: 0.51;
|
||||||
--to: 0.71;
|
--to: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
& li:nth-child(3) {
|
& li:nth-child(3) {
|
||||||
--from: 0.76;
|
--from: 0.79;
|
||||||
--to: 0.92;
|
--to: 1.07;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .hero-caption-title {
|
& .hero-caption-title {
|
||||||
@@ -1210,12 +911,6 @@ textarea {
|
|||||||
opacity: calc(1 - var(--hero-p) * 5);
|
opacity: calc(1 - var(--hero-p) * 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* a coluna da direita (molduras) sai junto com o texto: o fundo 3D se abre sem passar atrás dela */
|
|
||||||
& .hero-aside {
|
|
||||||
transform: translate3d(0, calc(var(--hero-p) * -10vh), 0);
|
|
||||||
opacity: calc(1 - var(--hero-p) * 4.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .hero-scrim {
|
& .hero-scrim {
|
||||||
opacity: calc(1 - var(--hero-p) * 1.4);
|
opacity: calc(1 - var(--hero-p) * 1.4);
|
||||||
}
|
}
|
||||||
@@ -1285,7 +980,19 @@ html.lenis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
|
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
|
||||||
recua e desfoca, a nova chega de baixo. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
recua e some, a nova chega de baixo. Sem desfoque e sem sobreposição (a nova só começa quando a antiga acabou de
|
||||||
|
sumir), e o fundo (html e body) fica escuro (--ink) enquanto dura a troca: cada página some e vem de um fundo só,
|
||||||
|
nunca de uma mistura de creme com preto. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
||||||
|
/* biome-ignore lint/correctness/noUnknownPseudoClass: :active-view-transition é do CSS de transições de visualização (Chrome 134+); Biome ainda não o conhece */
|
||||||
|
html:active-view-transition {
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* biome-ignore lint/correctness/noUnknownPseudoClass: idem */
|
||||||
|
html:active-view-transition body {
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
view-transition-name: site-header;
|
view-transition-name: site-header;
|
||||||
}
|
}
|
||||||
@@ -1301,19 +1008,18 @@ html.lenis {
|
|||||||
|
|
||||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||||
::view-transition-old(page) {
|
::view-transition-old(page) {
|
||||||
animation: page-out 280ms cubic-bezier(0.7, 0, 0.84, 0) both;
|
animation: page-out 240ms cubic-bezier(0.7, 0, 0.84, 0) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||||
::view-transition-new(page) {
|
::view-transition-new(page) {
|
||||||
animation: page-in 640ms var(--ease-out) 160ms both;
|
animation: page-in 600ms var(--ease-out) 240ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes page-out {
|
@keyframes page-out {
|
||||||
to {
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1321,7 +1027,6 @@ html.lenis {
|
|||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(40px) scale(1.015);
|
transform: translateY(40px) scale(1.015);
|
||||||
filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
resetFocusOnNavigation,
|
||||||
|
useFocusOnNavigation,
|
||||||
|
} from "./useFocusOnNavigation";
|
||||||
|
|
||||||
|
function Probe({ pathname }: { pathname: string }) {
|
||||||
|
useFocusOnNavigation(pathname);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetFocusOnNavigation();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function mountMain() {
|
||||||
|
const main = document.createElement("div");
|
||||||
|
main.id = "main-content";
|
||||||
|
main.tabIndex = -1;
|
||||||
|
document.body.append(main);
|
||||||
|
return main;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useFocusOnNavigation", () => {
|
||||||
|
it("does nothing on the first load, even with #main-content on the page", () => {
|
||||||
|
const main = mountMain();
|
||||||
|
render(<Probe pathname="/" />);
|
||||||
|
expect(main).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses #main-content once per path change, never scrolling", () => {
|
||||||
|
const main = mountMain();
|
||||||
|
const focus = jest.spyOn(main, "focus");
|
||||||
|
const { rerender } = render(<Probe pathname="/" />);
|
||||||
|
|
||||||
|
rerender(<Probe pathname="/cases" />);
|
||||||
|
expect(main).toHaveFocus();
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
|
||||||
|
rerender(<Probe pathname="/cases" />);
|
||||||
|
expect(focus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still tells a navigation from the first load when the component remounts (first segment changed)", () => {
|
||||||
|
const main = mountMain();
|
||||||
|
const first = render(<Probe pathname="/" />);
|
||||||
|
first.unmount();
|
||||||
|
render(<Probe pathname="/cases" />);
|
||||||
|
expect(main).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a page without #main-content (a 404 shell, an error boundary)", () => {
|
||||||
|
const { rerender } = render(<Probe pathname="/" />);
|
||||||
|
expect(() => rerender(<Probe pathname="/nada" />)).not.toThrow();
|
||||||
|
expect(screen.queryByRole("main")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not fooled by the double effect of React StrictMode", () => {
|
||||||
|
const main = mountMain();
|
||||||
|
render(<Probe pathname="/" />, { reactStrictMode: true });
|
||||||
|
expect(main).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caminho já exibido neste documento. Fica fora do componente porque o template da raiz remonta quando o primeiro
|
||||||
|
* segmento da rota muda (de / para /cases), e a carga inicial precisa seguir distinta de uma troca de página.
|
||||||
|
*/
|
||||||
|
let shownPath: string | null = null;
|
||||||
|
|
||||||
|
/** Os testes reproduzem vários documentos dentro de um só. */
|
||||||
|
export function resetFocusOnNavigation() {
|
||||||
|
shownPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depois de uma troca de página no navegador (o `pathname` mudou), o foco vai para o contêiner do conteúdo
|
||||||
|
* (`#main-content`, o mesmo destino do link "Pular"): leitor de tela e teclado seguem da página nova, não do link
|
||||||
|
* clicado na antiga. Sem rolar a página (preventScroll) e sem nada na carga inicial, que não tem página anterior.
|
||||||
|
*/
|
||||||
|
export function useFocusOnNavigation(pathname: string) {
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = shownPath;
|
||||||
|
shownPath = pathname;
|
||||||
|
if (previous === null || previous === pathname) return;
|
||||||
|
document.getElementById("main-content")?.focus({ preventScroll: true });
|
||||||
|
}, [pathname]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { resetFocusOnNavigation } from "./hooks/useFocusOnNavigation";
|
||||||
import Template from "./template";
|
import Template from "./template";
|
||||||
|
|
||||||
let mockPathname = "/cases";
|
let mockPathname = "/cases";
|
||||||
@@ -36,8 +37,18 @@ beforeEach(() => {
|
|||||||
mockPathname = "/cases";
|
mockPathname = "/cases";
|
||||||
mockMounts.mockClear();
|
mockMounts.mockClear();
|
||||||
mockProps.length = 0;
|
mockProps.length = 0;
|
||||||
|
resetFocusOnNavigation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** O contêiner que o layout põe em volta da página: o destino do foco depois da troca. */
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div id="main-content" tabIndex={-1} data-testid="main">
|
||||||
|
<Template>{children}</Template>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("app/template", () => {
|
describe("app/template", () => {
|
||||||
it("wraps the page in a ViewTransition named page that does not animate in-place updates", () => {
|
it("wraps the page in a ViewTransition named page that does not animate in-place updates", () => {
|
||||||
render(
|
render(
|
||||||
@@ -85,4 +96,64 @@ describe("app/template", () => {
|
|||||||
|
|
||||||
expect(mockMounts).toHaveBeenCalledTimes(1);
|
expect(mockMounts).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the focus alone on the first load and moves it to #main-content, without scrolling, when the path changes", () => {
|
||||||
|
const scrollTo = jest.spyOn(window, "scrollTo");
|
||||||
|
const focus = jest.spyOn(HTMLElement.prototype, "focus");
|
||||||
|
const { rerender } = render(
|
||||||
|
<Shell>
|
||||||
|
<button type="button">Dentro da página</button>
|
||||||
|
</Shell>,
|
||||||
|
);
|
||||||
|
expect(focus).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("main")).not.toHaveFocus();
|
||||||
|
|
||||||
|
mockPathname = "/cases/sev-modaintima";
|
||||||
|
rerender(
|
||||||
|
<Shell>
|
||||||
|
<button type="button">Dentro da página</button>
|
||||||
|
</Shell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("main")).toHaveFocus();
|
||||||
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
|
focus.mockRestore();
|
||||||
|
scrollTo.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not take the focus on a re-render of the same path", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Shell>
|
||||||
|
<p>Página</p>
|
||||||
|
</Shell>,
|
||||||
|
);
|
||||||
|
rerender(
|
||||||
|
<Shell>
|
||||||
|
<p>Página de novo</p>
|
||||||
|
</Shell>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("main")).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never starts a view transition by itself, on the first load or on the focus move", () => {
|
||||||
|
const start = jest.fn();
|
||||||
|
Object.defineProperty(document, "startViewTransition", {
|
||||||
|
configurable: true,
|
||||||
|
value: start,
|
||||||
|
});
|
||||||
|
const { rerender } = render(
|
||||||
|
<Shell>
|
||||||
|
<p>Página</p>
|
||||||
|
</Shell>,
|
||||||
|
);
|
||||||
|
mockPathname = "/servicos";
|
||||||
|
rerender(
|
||||||
|
<Shell>
|
||||||
|
<p>Página</p>
|
||||||
|
</Shell>,
|
||||||
|
);
|
||||||
|
expect(start).not.toHaveBeenCalled();
|
||||||
|
Reflect.deleteProperty(document, "startViewTransition");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ViewTransition } from "react";
|
import { ViewTransition } from "react";
|
||||||
|
import { useFocusOnNavigation } from "@/app/hooks/useFocusOnNavigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* O conteúdo leva o nome "page" e anima com page-out/page-in (globals.css) a cada troca de página.
|
* O conteúdo leva o nome "page" e anima com page-out/page-in (globals.css) a cada troca de página.
|
||||||
@@ -10,6 +11,7 @@ import { ViewTransition } from "react";
|
|||||||
* update="none" corta. Com a chave, a página que sai e a que entra formam o par que o React anima.
|
* 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
|
* 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.
|
* inteira na chegada (a tela piscava duas vezes); só a troca de página anima.
|
||||||
|
* Depois de cada troca o foco vai para #main-content, sem rolar (useFocusOnNavigation); a carga inicial não mexe no foco.
|
||||||
*
|
*
|
||||||
* Só no `next dev`, algumas rotas (/servicos/engenharia-de-software, /servicos/arquitetura-de-software e
|
* 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
|
* /servicos/integracao-hardware-software-iot, às vezes o 404) mostram no console "Each child in a list should have a
|
||||||
@@ -22,6 +24,7 @@ import { ViewTransition } from "react";
|
|||||||
*/
|
*/
|
||||||
export default function Template({ children }: { children: React.ReactNode }) {
|
export default function Template({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
useFocusOnNavigation(pathname);
|
||||||
return (
|
return (
|
||||||
<ViewTransition key={pathname} name="page" update="none">
|
<ViewTransition key={pathname} name="page" update="none">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
||||||
* - desktop.webp e mobile.webp: pôsteres; a versão de celular vale abaixo de 768 px (lib/device.ts);
|
* - desktop.webp e mobile.webp: pôsteres; a versão de celular vale abaixo de 768 px (lib/device.ts);
|
||||||
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
||||||
* - scrub-desktop.mp4 (960x600, H.264 sem áudio, ~0,4 a 1,9 MB): o mesmo vídeo com um quadro-chave a cada 6
|
* - scrub-desktop.mp4 (960x600, H.264 sem áudio, 12 quadros por segundo, até 1,2 MB): o mesmo vídeo com um
|
||||||
* quadros, para a imagem acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro. É o único
|
* quadro-chave a cada 6 quadros, para a imagem acompanhar a rolagem sem travar quando o tempo é mudado a cada
|
||||||
* vídeo da vitrine fixa: o celular da moldura mostra só o pôster, então não existe scrub-mobile.mp4;
|
* quadro (scripts/encode-scrub-video.mjs). É 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.
|
* - og.jpg: capa 1200x630 das imagens de compartilhamento.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,12 +43,12 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
|||||||
{
|
{
|
||||||
id: "sev-modaintima",
|
id: "sev-modaintima",
|
||||||
accent: "#c2303a",
|
accent: "#c2303a",
|
||||||
durations: { desktop: 17.9, mobile: 30.1 },
|
durations: { desktop: 14.4, mobile: 30.17 },
|
||||||
weight: 0.95,
|
weight: 0.95,
|
||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
|
{ label: "As favoritas", desktop: 3.6, mobile: 3.8 },
|
||||||
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
|
{ label: "Coleção completa", desktop: 8, mobile: 9.2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -76,12 +77,12 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
|||||||
{
|
{
|
||||||
id: "sc-plus",
|
id: "sc-plus",
|
||||||
accent: "#22c55e",
|
accent: "#22c55e",
|
||||||
durations: { desktop: 13, mobile: 19.67 },
|
durations: { desktop: 10.4, mobile: 16.4 },
|
||||||
weight: 0.8,
|
weight: 0.8,
|
||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
|
{ label: "Áreas e projetos", desktop: 3.4, mobile: 5.6 },
|
||||||
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
|
{ label: "Contato", desktop: 8.4, mobile: 12.4 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,7 +93,7 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
|||||||
chapters: [
|
chapters: [
|
||||||
{ label: "Três áreas", desktop: 0, mobile: 0 },
|
{ label: "Três áreas", desktop: 0, mobile: 0 },
|
||||||
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
|
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
|
||||||
{ label: "Contato direto", desktop: 2.4, mobile: 4.4 },
|
{ label: "Contato", desktop: 2.4, mobile: 4.4 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Recodifica um vídeo para a vitrine animada, em que o tempo do vídeo muda a cada quadro da rolagem.
|
* Recodifica um vídeo para a vitrine animada, em que o tempo do vídeo muda a cada quadro da rolagem.
|
||||||
* Para a busca ser instantânea o vídeo precisa de um quadro-chave a cada 6 quadros e nenhum B-frame; o tamanho
|
* Para a busca ser instantânea o vídeo precisa de um quadro-chave a cada 6 quadros e nenhum B-frame. O vídeo roda
|
||||||
* de cada arquivo fica em até 3 MB (sobe o CRF e, se preciso, reduz a imagem até caber).
|
* a 12 quadros por segundo (--fps): o tempo dele anda devagar em relação à rolagem (cerca de 1 s de vídeo por 50 px),
|
||||||
|
* então 12 quadros por segundo de vídeo são dezenas por segundo de tela, e o arquivo cai a menos da metade. O tamanho
|
||||||
|
* de cada arquivo fica em até 1,2 MB (--max-mb): sobe o CRF (34, 37, 40, 43) e, se preciso, reduz a imagem até caber.
|
||||||
*
|
*
|
||||||
* ffmpeg-static não é dependência do site: instale-o numa pasta temporária e rode o script a partir dela
|
* ffmpeg-static não é dependência do site: instale-o numa pasta temporária e rode o script a partir dela
|
||||||
* (as dependências são resolvidas a partir da pasta onde o comando foi executado):
|
* (as dependências são resolvidas a partir da pasta onde o comando foi executado; vale também um `ffmpeg` no PATH
|
||||||
|
* ou --ffmpeg <exe>):
|
||||||
*
|
*
|
||||||
* mkdir rec && cd rec && npm init -y && npm i ffmpeg-static
|
* 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 desktop-original.mp4 --out <repo>/public/videos/<id>/scrub-desktop.mp4 --size 960x600
|
||||||
*
|
*
|
||||||
* Só existe o scrub do computador: o celular da moldura mostra o pôster, sem vídeo.
|
* Só existe o scrub do computador: o celular da moldura mostra o pôster, sem vídeo.
|
||||||
*
|
*
|
||||||
@@ -23,21 +26,31 @@ import { createRequire } from "node:module";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const require = createRequire(join(process.cwd(), "noop.js"));
|
const require = createRequire(join(process.cwd(), "noop.js"));
|
||||||
const ffmpegPath = require("ffmpeg-static");
|
|
||||||
|
|
||||||
const args = Object.fromEntries(
|
const args = Object.fromEntries(
|
||||||
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
||||||
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
|
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
|
||||||
return acc;
|
return acc;
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
function resolveFfmpeg() {
|
||||||
|
if (args.ffmpeg) return args.ffmpeg;
|
||||||
|
try {
|
||||||
|
return require("ffmpeg-static");
|
||||||
|
} catch {
|
||||||
|
return "ffmpeg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ffmpegPath = resolveFfmpeg();
|
||||||
|
|
||||||
const { in: input, out: output } = args;
|
const { in: input, out: output } = args;
|
||||||
const [width, height] = (args.size ?? "1120x700").split("x").map(Number);
|
const [width, height] = (args.size ?? "960x600").split("x").map(Number);
|
||||||
const MAX_BYTES = Number(args["max-mb"] ?? 3) * 1024 * 1024;
|
const FPS = Number(args.fps ?? 12);
|
||||||
|
const MAX_BYTES = Number(args["max-mb"] ?? 1.2) * 1024 * 1024;
|
||||||
|
const CRFS = (args.crfs ?? "34,37,40,43").split(",").map(Number);
|
||||||
|
|
||||||
if (!input || !output || !width || !height) {
|
if (!input || !output || !width || !height) {
|
||||||
console.error(
|
console.error(
|
||||||
"uso: --in <video> --out <video.mp4> --size 1120x700 [--max-mb 3]",
|
"uso: --in <video> --out <video.mp4> [--size 960x600] [--fps 12] [--max-mb 1.2] [--crfs 34,37,40,43] [--ffmpeg <exe>]",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -53,7 +66,7 @@ function encode(w, h, crf) {
|
|||||||
"-i",
|
"-i",
|
||||||
input,
|
input,
|
||||||
"-vf",
|
"-vf",
|
||||||
`scale=${w}:${h}:flags=lanczos,fps=24`,
|
`scale=${w}:${h}:flags=lanczos,fps=${FPS}`,
|
||||||
"-an",
|
"-an",
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libx264",
|
"libx264",
|
||||||
@@ -88,7 +101,7 @@ function encode(w, h, crf) {
|
|||||||
for (const scale of [1, 0.9, 0.8]) {
|
for (const scale of [1, 0.9, 0.8]) {
|
||||||
const w = Math.round((width * scale) / 2) * 2;
|
const w = Math.round((width * scale) / 2) * 2;
|
||||||
const h = Math.round((height * scale) / 2) * 2;
|
const h = Math.round((height * scale) / 2) * 2;
|
||||||
for (const crf of [28, 31, 34, 37]) {
|
for (const crf of CRFS) {
|
||||||
const bytes = encode(w, h, crf);
|
const bytes = encode(w, h, crf);
|
||||||
if (bytes <= MAX_BYTES) {
|
if (bytes <= MAX_BYTES) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Grava um vídeo de rolagem em velocidade constante de uma página (Playwright, headless com GPU) e codifica
|
* Grava um vídeo de rolagem de uma página (Playwright, headless com GPU) e codifica MP4 (H.264), WebM (VP9) e um
|
||||||
* MP4 (H.264), WebM (VP9) e um poster WebP, cada vídeo com até 3 MB. É assim que nascem os arquivos de public/videos/<id>/.
|
* poster WebP, cada vídeo com até 3 MB. É assim que nascem os arquivos de public/videos/<id>/.
|
||||||
*
|
*
|
||||||
* Playwright e ffmpeg não são dependências do site. Instale-os numa pasta temporária e rode o script a partir dela:
|
* Playwright e ffmpeg não são dependências do site. Instale-os numa pasta temporária e rode o script a partir dela
|
||||||
|
* (vale também `playwright-core` e um `ffmpeg` no PATH, com --chrome e --ffmpeg apontando para os executáveis):
|
||||||
*
|
*
|
||||||
* mkdir rec && cd rec && npm init -y && npm i playwright ffmpeg-static && npx playwright install chromium
|
* mkdir rec && cd rec && npm init -y && npm i playwright ffmpeg-static && npx playwright install chromium
|
||||||
* node <repo>/scripts/record-project-videos.mjs --slug lavacao-047 --url https://exemplo.com --out ./out
|
* node <repo>/scripts/record-project-videos.mjs --slug lavacao-047 --url https://exemplo.com --out ./out
|
||||||
*
|
*
|
||||||
* Opções: --viewports desktop,mobile (1440x900 e 390x844), --speed 300 (px/s), --max 30 (segundos), --settle 3000 (ms),
|
* Opções: --viewports desktop,mobile (1440x900 e 390x844), --speed 300 (px/s), --max 30 (segundos), --settle 3000 (ms),
|
||||||
* --software 1 (rasterização por software; perde quadros em páginas pesadas).
|
* --software 1 (rasterização por software; perde quadros em páginas pesadas), --chrome <exe>, --ffmpeg <exe>,
|
||||||
|
* --poster-at 0 (segundo do vídeo de onde sai o poster: use um instante em que o cabeçalho já esteja parado),
|
||||||
|
* --fast 5 (quantas vezes mais rápido a rolagem passa por trechos quase em branco; 1 desliga), --lo 0.02 e --hi 0.06
|
||||||
|
* (fração de pixels diferentes do fundo abaixo da qual um trecho é "em branco" e acima da qual é "cheio"),
|
||||||
|
* --hold 1000 (ms de página parada no topo antes de rolar), --ready-text "Nova Coleção" (só começa a gravar quando o
|
||||||
|
* primeiro <h1> da página tem esse texto: para um herói que troca de slide, o vídeo e o poster nascem sempre do mesmo),
|
||||||
|
* --ready-wait 1500 (ms de espera depois de o texto aparecer, para a animação de entrada do título acabar antes de o
|
||||||
|
* vídeo começar), --hide-text "Qual lingerie" (esconde na gravação a seção cujo título tem esse texto: para um bloco que o cliente ainda
|
||||||
|
* não preencheu, como uma grade de categorias com marcadores de imagem vazios, que não deve aparecer no vídeo),
|
||||||
|
* --dry 1 (só mede os trechos em branco, sem gravar).
|
||||||
|
*
|
||||||
|
* Antes de gravar a página é percorrida devagar, com as imagens preguiçosas forçadas e decodificadas e a rede
|
||||||
|
* ociosa: o vídeo não pode mostrar quadros brancos de imagem que ainda não chegou. Depois, 6 quadros do vídeo
|
||||||
|
* pronto são medidos (campo `frames` do relatório; `blank: true` marca um quadro quase todo em branco).
|
||||||
*
|
*
|
||||||
* O relógio do vídeo do Playwright não acompanha o relógio real, então o corte entre o aquecimento da página e a
|
* O relógio do vídeo do Playwright não acompanha o relógio real, então o corte entre o aquecimento da página e a
|
||||||
* rolagem usa quadros magenta (início) e ciano (fim) injetados na própria página e procurados no vídeo.
|
* rolagem usa quadros magenta (início) e ciano (fim) injetados na própria página e procurados no vídeo.
|
||||||
@@ -20,8 +34,23 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
// resolve playwright e ffmpeg-static a partir da pasta onde o comando foi executado, não da pasta do script
|
// resolve playwright e ffmpeg-static a partir da pasta onde o comando foi executado, não da pasta do script
|
||||||
const require = createRequire(join(process.cwd(), "noop.js"));
|
const require = createRequire(join(process.cwd(), "noop.js"));
|
||||||
const ffmpegPath = require("ffmpeg-static");
|
const tryRequire = (...names) => {
|
||||||
const { chromium } = require("playwright");
|
for (const name of names) {
|
||||||
|
try {
|
||||||
|
return require(name);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const cliArgs = process.argv.slice(2);
|
||||||
|
const argOf = (name) => {
|
||||||
|
const at = cliArgs.indexOf(`--${name}`);
|
||||||
|
return at < 0 ? undefined : cliArgs[at + 1];
|
||||||
|
};
|
||||||
|
const ffmpegPath = argOf("ffmpeg") ?? tryRequire("ffmpeg-static") ?? "ffmpeg";
|
||||||
|
const playwright = tryRequire("playwright", "playwright-core");
|
||||||
|
if (!playwright) throw new Error("instale playwright (ou playwright-core)");
|
||||||
|
const { chromium } = playwright;
|
||||||
|
|
||||||
const args = Object.fromEntries(
|
const args = Object.fromEntries(
|
||||||
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
||||||
@@ -38,6 +67,14 @@ const MAX_SECONDS = Number(args.max ?? 30);
|
|||||||
const SETTLE_MS = Number(args.settle ?? 3000);
|
const SETTLE_MS = Number(args.settle ?? 3000);
|
||||||
const MAX_BYTES = 3 * 1024 * 1024;
|
const MAX_BYTES = 3 * 1024 * 1024;
|
||||||
const FPS = 30;
|
const FPS = 30;
|
||||||
|
const FAST = Number(args.fast ?? 5);
|
||||||
|
const BLANK_LO = Number(args.lo ?? 0.02);
|
||||||
|
const BLANK_HI = Number(args.hi ?? 0.06);
|
||||||
|
const POSTER_AT = Number(args["poster-at"] ?? 0);
|
||||||
|
const HOLD_MS = Number(args.hold ?? 1000);
|
||||||
|
const READY_TEXT = args["ready-text"];
|
||||||
|
const HIDE_TEXT = args["hide-text"];
|
||||||
|
const READY_WAIT_MS = Number(args["ready-wait"] ?? 0);
|
||||||
|
|
||||||
const VIEWPORTS = {
|
const VIEWPORTS = {
|
||||||
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
|
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
|
||||||
@@ -193,6 +230,141 @@ async function waitImages(page, timeout = 15000) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Imagens que ainda não chegaram (ou chegaram quebradas) e quantas já estão decodificadas. */
|
||||||
|
const imageStatus = (page) =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const images = [...document.images];
|
||||||
|
const loaded = images.filter((img) => img.complete && img.naturalWidth > 0);
|
||||||
|
return {
|
||||||
|
total: images.length,
|
||||||
|
pending: images
|
||||||
|
.filter((img) => !img.complete || img.naturalWidth === 0)
|
||||||
|
.map((img) => img.currentSrc || img.src)
|
||||||
|
.slice(0, 5),
|
||||||
|
loaded: loaded.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percorre a página devagar, meia tela por vez, com as imagens preguiçosas forçadas a carregar, esperando as da
|
||||||
|
* janela chegarem e a rede ficar ociosa; no fim decodifica todas. Volta ao topo. Nada do que a gravação vai mostrar
|
||||||
|
* pode ainda estar a caminho.
|
||||||
|
*/
|
||||||
|
async function preloadPage(page) {
|
||||||
|
for (let round = 0; round < 2; round++) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const img of document.images) {
|
||||||
|
if (img.loading === "lazy") img.loading = "eager";
|
||||||
|
img.decoding = "sync";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const height = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollHeight,
|
||||||
|
);
|
||||||
|
const view = await page.evaluate(() => window.innerHeight);
|
||||||
|
for (let y = 0; y < height; y += Math.round(view * 0.5)) {
|
||||||
|
await page.evaluate((top) => window.scrollTo(0, top), y);
|
||||||
|
await sleep(350);
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() =>
|
||||||
|
[...document.images]
|
||||||
|
.filter((img) => {
|
||||||
|
const r = img.getBoundingClientRect();
|
||||||
|
return r.bottom > -innerHeight && r.top < innerHeight * 2;
|
||||||
|
})
|
||||||
|
.every((img) => img.complete),
|
||||||
|
null,
|
||||||
|
{ timeout: 6000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await page
|
||||||
|
.waitForLoadState("networkidle", { timeout: 15000 })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await page
|
||||||
|
.evaluate(() =>
|
||||||
|
Promise.all(
|
||||||
|
[...document.images].map((img) => img.decode().catch(() => {})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fração de pixels de um PNG que diferem do fundo (o nível de cinza mais comum), em 64x40. */
|
||||||
|
function inkRatio(png, background) {
|
||||||
|
const r = spawnSync(
|
||||||
|
ffmpegPath,
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-vf",
|
||||||
|
"scale=64:40:flags=area,format=gray",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"rawvideo",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
{ input: png, maxBuffer: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
const px = r.stdout;
|
||||||
|
if (!px?.length) return { ratio: 1, mode: 255 };
|
||||||
|
const hist = new Array(256).fill(0);
|
||||||
|
for (const v of px) hist[v] += 1;
|
||||||
|
let mode = 0;
|
||||||
|
for (let i = 1; i < 256; i++) if (hist[i] > hist[mode]) mode = i;
|
||||||
|
const bg = background ?? mode;
|
||||||
|
let far = 0;
|
||||||
|
for (const v of px) if (Math.abs(v - bg) > 14) far += 1;
|
||||||
|
return { ratio: far / px.length, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mede, a cada 1/8 de tela de rolagem, quanto da janela tem conteúdo (pixels que diferem do fundo da página).
|
||||||
|
* Devolve o passo, as frações e o fator de velocidade de cada posição: 1 onde há conteúdo e até `FAST` onde a janela
|
||||||
|
* está quase toda em branco (a rolagem atravessa o vazio depressa em vez de gravar um vídeo branco).
|
||||||
|
*/
|
||||||
|
async function measureBlank(page) {
|
||||||
|
const height = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollHeight,
|
||||||
|
);
|
||||||
|
const view = await page.evaluate(() => window.innerHeight);
|
||||||
|
const maxScroll = Math.max(0, height - view);
|
||||||
|
const step = Math.max(40, Math.round(view / 8));
|
||||||
|
const ratios = [];
|
||||||
|
const modes = [];
|
||||||
|
for (let y = 0; y <= maxScroll + step; y += step) {
|
||||||
|
await page.evaluate(
|
||||||
|
([top, max]) => window.scrollTo(0, Math.min(top, max)),
|
||||||
|
[y, maxScroll],
|
||||||
|
);
|
||||||
|
await sleep(160);
|
||||||
|
const { ratio, mode } = inkRatio(await page.screenshot({ type: "png" }));
|
||||||
|
ratios.push(ratio);
|
||||||
|
modes.push(mode);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
// média móvel curta: a velocidade muda aos poucos, sem arranque nem freada bruscos
|
||||||
|
const smooth = ratios.map((_, i) => {
|
||||||
|
const part = ratios.slice(Math.max(0, i - 2), i + 3);
|
||||||
|
return Math.max(ratios[i], part.reduce((a, b) => a + b, 0) / part.length);
|
||||||
|
});
|
||||||
|
const factors = smooth.map((ratio) => {
|
||||||
|
if (FAST <= 1 || ratio >= BLANK_HI) return 1;
|
||||||
|
if (ratio <= BLANK_LO) return FAST;
|
||||||
|
const t = (ratio - BLANK_LO) / (BLANK_HI - BLANK_LO);
|
||||||
|
return FAST + (1 - FAST) * t;
|
||||||
|
});
|
||||||
|
return { step, maxScroll, ratios, factors };
|
||||||
|
}
|
||||||
|
|
||||||
async function recordOne(browser, vpName) {
|
async function recordOne(browser, vpName) {
|
||||||
const vp = VIEWPORTS[vpName];
|
const vp = VIEWPORTS[vpName];
|
||||||
const rawDir = join(rawRoot, vpName);
|
const rawDir = join(rawRoot, vpName);
|
||||||
@@ -238,27 +410,50 @@ async function recordOne(browser, vpName) {
|
|||||||
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
|
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
|
||||||
const popups1 = await dismissPopups(page);
|
const popups1 = await dismissPopups(page);
|
||||||
|
|
||||||
// passe de aquecimento: força imagens preguiçosas e conteúdo sob demanda; depois volta ao topo (essa parte é cortada do vídeo)
|
// passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
|
||||||
const warm = await page.evaluate(async () => {
|
// ociosa; depois volta ao topo (essa parte é cortada do vídeo)
|
||||||
const step = Math.round(window.innerHeight * 0.8);
|
await preloadPage(page);
|
||||||
let guard = 0;
|
|
||||||
while (
|
|
||||||
window.scrollY + window.innerHeight <
|
|
||||||
document.documentElement.scrollHeight - 2 &&
|
|
||||||
guard++ < 400
|
|
||||||
) {
|
|
||||||
window.scrollBy(0, step);
|
|
||||||
await new Promise((r) => setTimeout(r, 140));
|
|
||||||
}
|
|
||||||
return document.documentElement.scrollHeight;
|
|
||||||
});
|
|
||||||
await sleep(1200);
|
|
||||||
await waitImages(page);
|
await waitImages(page);
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
await sleep(1800);
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
|
||||||
await sleep(600);
|
await sleep(600);
|
||||||
const popups2 = await dismissPopups(page);
|
const popups2 = await dismissPopups(page);
|
||||||
|
const images = await imageStatus(page);
|
||||||
|
// imagem que não carrega de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo) aparece como ícone de
|
||||||
|
// imagem quebrada com o texto alternativo: não vai para o vídeo
|
||||||
|
const hiddenSections = HIDE_TEXT
|
||||||
|
? await page.evaluate((text) => {
|
||||||
|
const hidden = [];
|
||||||
|
for (const heading of document.querySelectorAll("h1, h2, h3")) {
|
||||||
|
if (!heading.textContent.includes(text)) continue;
|
||||||
|
const section = heading.closest("section") ?? heading.parentElement;
|
||||||
|
if (!section) continue;
|
||||||
|
section.style.display = "none";
|
||||||
|
hidden.push(heading.textContent.trim());
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
|
}, HIDE_TEXT)
|
||||||
|
: [];
|
||||||
|
const hiddenBrokenImages = await page.evaluate(() => {
|
||||||
|
const broken = [...document.images].filter(
|
||||||
|
(img) => img.complete && img.naturalWidth === 0 && img.currentSrc,
|
||||||
|
);
|
||||||
|
for (const img of broken) img.style.visibility = "hidden";
|
||||||
|
return broken.map((img) => img.currentSrc);
|
||||||
|
});
|
||||||
|
|
||||||
|
const blank = await measureBlank(page);
|
||||||
|
await sleep(600);
|
||||||
|
if (args.dry === "1") {
|
||||||
|
await context.close();
|
||||||
|
return {
|
||||||
|
vp: vpName,
|
||||||
|
images,
|
||||||
|
hiddenBrokenImages,
|
||||||
|
step: blank.step,
|
||||||
|
inkRatios: blank.ratios.map((r) => +r.toFixed(3)),
|
||||||
|
factors: blank.factors.map((f) => +f.toFixed(1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const metrics = await page.evaluate(() => ({
|
const metrics = await page.evaluate(() => ({
|
||||||
scrollY: window.scrollY,
|
scrollY: window.scrollY,
|
||||||
@@ -268,22 +463,61 @@ async function recordOne(browser, vpName) {
|
|||||||
hasHorizontalOverflow:
|
hasHorizontalOverflow:
|
||||||
document.documentElement.scrollWidth > window.innerWidth + 1,
|
document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||||
}));
|
}));
|
||||||
const maxScroll = Math.max(0, metrics.height - metrics.view);
|
// tempo de rolagem a 1 px/s: cada trecho custa passo / fator; a velocidade base cabe o total em MAX_SECONDS - 2
|
||||||
const speed = Math.max(SPEED, maxScroll / (MAX_SECONDS - 2));
|
const unitSeconds = blank.factors.reduce(
|
||||||
const expectedScrollSeconds = maxScroll / speed;
|
(sum, factor) => sum + blank.step / factor,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const speed = Math.max(SPEED, unitSeconds / (MAX_SECONDS - 2));
|
||||||
|
const expectedScrollSeconds = unitSeconds / speed;
|
||||||
|
const warm = metrics.height;
|
||||||
|
|
||||||
|
if (READY_TEXT) {
|
||||||
|
// o começo do slide, não um ponto qualquer dele: primeiro espera o texto sair (se já estiver lá), depois entrar
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
(text) =>
|
||||||
|
!(document.querySelector("h1")?.textContent ?? "").includes(text),
|
||||||
|
READY_TEXT,
|
||||||
|
{ timeout: 20000, polling: 100 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
(text) =>
|
||||||
|
(document.querySelector("h1")?.textContent ?? "").includes(text),
|
||||||
|
READY_TEXT,
|
||||||
|
{ timeout: 40000, polling: 100 },
|
||||||
|
)
|
||||||
|
.catch(() => logs.push(`ready-text "${READY_TEXT}" não apareceu no h1`));
|
||||||
|
if (READY_WAIT_MS) await sleep(READY_WAIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// marcadores visuais (magenta no início, ciano no fim): o relógio do vídeo do Playwright não é confiável, então o corte é feito pelos quadros
|
// marcadores visuais (magenta no início, ciano no fim): o relógio do vídeo do Playwright não é confiável, então o corte é feito pelos quadros
|
||||||
await flash(page, "rgb(255,0,255)");
|
await flash(page, "rgb(255,0,255)");
|
||||||
const t0 = Date.now() - tStart;
|
const t0 = Date.now() - tStart;
|
||||||
await sleep(1000);
|
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
|
||||||
const result = await page.evaluate(
|
const result = await page.evaluate(
|
||||||
(speedPxS) =>
|
({ speedPxS, step, factors }) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
const start = performance.now();
|
// fator de velocidade na posição y: interpolado entre as medidas de cada `step` px
|
||||||
|
const factorAt = (y) => {
|
||||||
|
const at = Math.min(factors.length - 1, Math.max(0, y / step));
|
||||||
|
const i = Math.floor(at);
|
||||||
|
const next = Math.min(factors.length - 1, i + 1);
|
||||||
|
return factors[i] + (factors[next] - factors[i]) * (at - i);
|
||||||
|
};
|
||||||
|
let y = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
const start = last;
|
||||||
const tick = (now) => {
|
const tick = (now) => {
|
||||||
const maxNow =
|
const maxNow =
|
||||||
document.documentElement.scrollHeight - window.innerHeight;
|
document.documentElement.scrollHeight - window.innerHeight;
|
||||||
const y = Math.min(((now - start) / 1000) * speedPxS, maxNow);
|
y = Math.min(
|
||||||
|
y + ((now - last) / 1000) * speedPxS * factorAt(y),
|
||||||
|
maxNow,
|
||||||
|
);
|
||||||
|
last = now;
|
||||||
window.scrollTo(0, y);
|
window.scrollTo(0, y);
|
||||||
if (y >= maxNow - 0.5)
|
if (y >= maxNow - 0.5)
|
||||||
resolve({ endY: window.scrollY, maxNow, ms: now - start });
|
resolve({ endY: window.scrollY, maxNow, ms: now - start });
|
||||||
@@ -291,7 +525,7 @@ async function recordOne(browser, vpName) {
|
|||||||
};
|
};
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}),
|
}),
|
||||||
speed,
|
{ speedPxS: speed, step: blank.step, factors: blank.factors },
|
||||||
);
|
);
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const t1 = Date.now() - tStart;
|
const t1 = Date.now() - tStart;
|
||||||
@@ -390,6 +624,8 @@ async function recordOne(browser, vpName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ff([
|
ff([
|
||||||
|
"-ss",
|
||||||
|
String(POSTER_AT),
|
||||||
"-i",
|
"-i",
|
||||||
join(outDir, `${vpName}.mp4`),
|
join(outDir, `${vpName}.mp4`),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -401,6 +637,38 @@ async function recordOne(browser, vpName) {
|
|||||||
join(outDir, `${vpName}.webp`),
|
join(outDir, `${vpName}.webp`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco
|
||||||
|
const frames = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const at = ((i + 0.5) / 6) * Number(dur);
|
||||||
|
const png = spawnSync(
|
||||||
|
ffmpegPath,
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-ss",
|
||||||
|
at.toFixed(2),
|
||||||
|
"-i",
|
||||||
|
join(outDir, `${vpName}.mp4`),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"image2pipe",
|
||||||
|
"-c:v",
|
||||||
|
"png",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
{ maxBuffer: 1024 * 1024 * 16 },
|
||||||
|
).stdout;
|
||||||
|
const { ratio } = inkRatio(png);
|
||||||
|
frames.push({
|
||||||
|
at: +at.toFixed(1),
|
||||||
|
ink: +ratio.toFixed(3),
|
||||||
|
blank: ratio < BLANK_LO,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// folha de contato para revisão visual (fora da pasta de saída)
|
// folha de contato para revisão visual (fora da pasta de saída)
|
||||||
ff([
|
ff([
|
||||||
"-i",
|
"-i",
|
||||||
@@ -428,6 +696,10 @@ async function recordOne(browser, vpName) {
|
|||||||
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
|
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
|
||||||
popupsClosed: [...popups1, ...popups2],
|
popupsClosed: [...popups1, ...popups2],
|
||||||
horizontalOverflow: metrics.hasHorizontalOverflow,
|
horizontalOverflow: metrics.hasHorizontalOverflow,
|
||||||
|
images,
|
||||||
|
hiddenBrokenImages,
|
||||||
|
hiddenSections,
|
||||||
|
frames,
|
||||||
sizesKB: Object.fromEntries(
|
sizesKB: Object.fromEntries(
|
||||||
Object.entries(sizes).map(([k, v]) => [
|
Object.entries(sizes).map(([k, v]) => [
|
||||||
k,
|
k,
|
||||||
@@ -442,7 +714,8 @@ async function recordOne(browser, vpName) {
|
|||||||
const software = args.software === "1";
|
const software = args.software === "1";
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
channel: software ? undefined : "chromium",
|
executablePath: args.chrome,
|
||||||
|
channel: software || args.chrome ? undefined : "chromium",
|
||||||
args: software
|
args: software
|
||||||
? [
|
? [
|
||||||
"--use-gl=angle",
|
"--use-gl=angle",
|
||||||
|
|||||||