fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
1 parent
9e2d65a926
commit
204ebd5fa5
49 files changed
+2197
-606
No files matched your search
@@ -179,9 +179,12 @@ describe("Section", () => {
|
||||
"ds-reveal",
|
||||
);
|
||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
|
||||
expect(screen.getByText("lateral").closest("aside")).toHaveClass(
|
||||
"ds-reveal",
|
||||
);
|
||||
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
|
||||
const side = screen.getByText("lateral").parentElement;
|
||||
expect(side?.tagName).toBe("DIV");
|
||||
expect(side).toHaveClass("ds-reveal");
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
expect(screen.queryByRole("complementary")).toBeNull();
|
||||
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
||||
expect(header).not.toHaveAttribute("data-shown");
|
||||
|
||||
@@ -205,8 +208,9 @@ describe("Section", () => {
|
||||
|
||||
const eyebrow = delay(screen.getByText("Sobre"));
|
||||
const description = delay(screen.getByText("Texto"));
|
||||
// o conteúdo lateral é um <div class="ds-reveal">, não um <aside> (ver Section.tsx)
|
||||
const aside = delay(
|
||||
screen.getByText("lateral").closest("aside") as HTMLElement,
|
||||
screen.getByText("lateral").closest(".ds-reveal") as HTMLElement,
|
||||
);
|
||||
|
||||
expect(eyebrow).toBeLessThan(description);
|
||||
|
||||
@@ -20,7 +20,9 @@ describe("Section", () => {
|
||||
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
||||
expect(container.querySelector(".ds-eyebrow")).toBeNull();
|
||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
expect(
|
||||
container.querySelector(".ds-section-header > .ds-reveal"),
|
||||
).toBeNull();
|
||||
// a linha que se desenha embaixo do título é sempre desenhada
|
||||
expect(container.querySelector(".ds-draw-line")).toBeInTheDocument();
|
||||
});
|
||||
@@ -43,7 +45,9 @@ describe("Section", () => {
|
||||
|
||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow");
|
||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
expect(
|
||||
container.querySelector(".ds-section-header > .ds-reveal"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: only the description -> the description shows, the eyebrow and the aside stay out", () => {
|
||||
@@ -57,7 +61,9 @@ describe("Section", () => {
|
||||
"ds-section-description",
|
||||
);
|
||||
expect(container.querySelector(".ds-eyebrow")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
expect(
|
||||
container.querySelector(".ds-section-header > .ds-reveal"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: only the aside -> the aside shows next to the title, the eyebrow and the description stay out", () => {
|
||||
@@ -67,7 +73,10 @@ describe("Section", () => {
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("lateral").closest("aside")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".ds-section-header > .ds-reveal"),
|
||||
).toHaveTextContent("lateral");
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
expect(container.querySelector(".ds-eyebrow")).toBeNull();
|
||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -66,10 +66,12 @@ export const Section = ({
|
||||
<DrawLine delay={0.16} />
|
||||
</div>
|
||||
|
||||
{/* <div>, não <aside>: dentro de <main> um <aside> vira landmark "complementary" fora do nível do topo
|
||||
(axe: landmark-complementary-is-top-level); o conteúdo é um detalhe do cabeçalho, não uma região */}
|
||||
{aside && (
|
||||
<aside className="ds-reveal" style={stagger(0.18)}>
|
||||
<div className="ds-reveal" style={stagger(0.18)}>
|
||||
{aside}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,9 +70,12 @@ type Observer = {
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
|
||||
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
||||
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
||||
|
||||
function Hero({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<section className="hero">
|
||||
<section className="hero" style={HERO_TAIL}>
|
||||
<HeroField />
|
||||
{children}
|
||||
</section>
|
||||
@@ -597,3 +600,122 @@ describe("HeroField: desmontagem em pontos que faltavam", () => {
|
||||
expect(hero).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
});
|
||||
|
||||
/** Um contexto WebGL de teste que conta ao navegador o nome do renderizador (WEBGL_debug_renderer_info). */
|
||||
function contextRenderedBy(renderer: unknown, loseContext = jest.fn()) {
|
||||
const RENDERER = 37446;
|
||||
return {
|
||||
getExtension: (name: string) => {
|
||||
if (name === "WEBGL_debug_renderer_info")
|
||||
return { UNMASKED_RENDERER_WEBGL: RENDERER };
|
||||
return name === "WEBGL_lose_context" ? { loseContext } : null;
|
||||
},
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === RENDERER ? renderer : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", () => {
|
||||
it.each([
|
||||
[
|
||||
"SwiftShader (Chrome sem GPU)",
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
|
||||
],
|
||||
["llvmpipe (Mesa)", "llvmpipe (LLVM 15.0.7, 256 bits)"],
|
||||
[
|
||||
"Microsoft Basic Render Driver",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
|
||||
],
|
||||
])(
|
||||
"path: %s -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene, and the probe context is still released",
|
||||
async (_name, renderer) => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => contextRenderedBy(renderer, loseContext));
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
|
||||
"scroll",
|
||||
);
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a hardware renderer -> the scene mounts and the dive starts", async () => {
|
||||
setContext(() =>
|
||||
contextRenderedBy("ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11)"),
|
||||
);
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: no renderer info extension (Firefox, Safari) -> cannot tell, so the scene mounts", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the renderer name is not a string -> cannot tell, so the scene mounts", async () => {
|
||||
setContext(() => contextRenderedBy(null));
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing from the stylesheet", undefined],
|
||||
["not a number", "abc"],
|
||||
])(
|
||||
"path: --hero-tail %s -> no tail, the rail is the whole hero minus one screen (1960 px, never NaN)",
|
||||
(_name, tail) => {
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
const style = tail
|
||||
? ({ "--hero-tail": tail } as React.CSSProperties)
|
||||
: undefined;
|
||||
render(
|
||||
<section className="hero" style={style}>
|
||||
<HeroField />
|
||||
</section>,
|
||||
);
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top: -700, height: 2760, bottom: 2060 }) as DOMRect;
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
// 2760 - 800 = 1960 de trilho; com a cauda de 1,7 tela (o CSS real) seria 1400 e o progresso 0,5
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
700 / 1960,
|
||||
3,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -38,14 +38,29 @@ function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Um WebGL2 que abre, e que diz na extensão de depuração em que renderizador desenha. */
|
||||
function mockWebGLRenderer(renderer: string) {
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info"
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? renderer : null,
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
||||
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
||||
|
||||
function Hero() {
|
||||
return (
|
||||
<section className="hero">
|
||||
<section className="hero" style={HERO_TAIL}>
|
||||
<HeroField />
|
||||
</section>
|
||||
);
|
||||
@@ -155,7 +170,7 @@ describe("HeroField (ambient background)", () => {
|
||||
expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
it.each<[string, () => void]>([
|
||||
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
|
||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||
@@ -168,6 +183,17 @@ describe("HeroField (ambient background)", () => {
|
||||
undefined;
|
||||
},
|
||||
],
|
||||
...[
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
].map(
|
||||
(renderer) =>
|
||||
[
|
||||
`software WebGL (${renderer.slice(0, 24)}...)`,
|
||||
() => mockWebGLRenderer(renderer),
|
||||
] as [string, () => void],
|
||||
),
|
||||
])(
|
||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
||||
async (_label, arrange) => {
|
||||
|
||||
@@ -7,17 +7,23 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
import { isLowPowerDevice, isSoftwareWebGL } from "@/lib/device";
|
||||
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. */
|
||||
export const DIVE_QUERY =
|
||||
"(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.
|
||||
* Telas de hero além do mergulho: 100 do palco preso e o resto em que Projetos sobe sobre ele. Quem manda é o CSS
|
||||
* (`--hero-tail` em globals.css, a mesma variável da altura do hero e da margem de Projetos); o mergulho (progresso 0
|
||||
* a 1) é o resto da altura do hero. Sem a variável (a folha de estilos não definiu), não há cauda: só o palco.
|
||||
*/
|
||||
export const HERO_TAIL_SCREENS = 1.7;
|
||||
function heroTailScreens(section: HTMLElement) {
|
||||
const tail = Number.parseFloat(
|
||||
getComputedStyle(section).getPropertyValue("--hero-tail"),
|
||||
);
|
||||
return Number.isFinite(tail) ? tail : 1;
|
||||
}
|
||||
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
|
||||
const IDLE_SLACK_MS = 20;
|
||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||
@@ -37,12 +43,18 @@ const smooth = (a: number, b: number, x: number) => {
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
/**
|
||||
* O aparelho abre um contexto WebGL2 de verdade, na GPU. Um WebGL por software (SwiftShader, llvmpipe, Microsoft
|
||||
* Basic Render) abre, mas desenha a cena a poucos quadros por segundo na CPU: nesse caso vale o hero estático.
|
||||
*/
|
||||
function supportsWebGL2() {
|
||||
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
return context !== null;
|
||||
if (!context) return false;
|
||||
const software = isSoftwareWebGL(context);
|
||||
context.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
return !software;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -89,11 +101,9 @@ function whenIdle(run: () => void) {
|
||||
|
||||
/**
|
||||
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
|
||||
* num intervalo ocioso) e só então o Canvas. Devolve `null` se o aparelho não faz WebGL2 ou se a tela foi
|
||||
* desmontada no meio do caminho.
|
||||
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
|
||||
*/
|
||||
async function loadScene(isAlive: () => boolean) {
|
||||
if (!supportsWebGL2()) return null;
|
||||
const scene = await import("./HeroFieldScene");
|
||||
await nextIdle();
|
||||
if (!isAlive()) return null;
|
||||
@@ -113,13 +123,14 @@ function trackDive(
|
||||
onDone: (done: boolean) => void,
|
||||
) {
|
||||
section.dataset.dive = "";
|
||||
const tail = heroTailScreens(section);
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = section.getBoundingClientRect();
|
||||
// o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele
|
||||
const span = Math.max(
|
||||
rect.height - window.innerHeight * HERO_TAIL_SCREENS,
|
||||
rect.height - window.innerHeight * tail,
|
||||
window.innerHeight * 0.8,
|
||||
);
|
||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||
@@ -167,8 +178,8 @@ function trackVisibility(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2, sem toque, com mais de
|
||||
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
|
||||
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
|
||||
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
|
||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
||||
@@ -191,12 +202,15 @@ export default function HeroField() {
|
||||
if (isLowPowerDevice()) return;
|
||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
||||
if (!section) return;
|
||||
// WebGL2 só por software (ou nenhum): o hero estático de 100svh, já na hidratação, sem o bloco de rolagem do
|
||||
// mergulho que depois seria desfeito com a página já rolada
|
||||
if (!supportsWebGL2()) return;
|
||||
let alive = true;
|
||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
: undefined;
|
||||
const stopVisibility = trackVisibility(section, setVisible);
|
||||
// sem WebGL2, com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
|
||||
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
|
||||
giveUp.current = () => {
|
||||
stopDive?.();
|
||||
if (alive) setFailed(true);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/**
|
||||
* Caminho-base de mountField que HeroFieldScene.test.tsx não exerce: o erro que vem ANTES de as peças existirem
|
||||
* (a nuvem de pontos ou a câmera não se criam). O renderizador já abriu o contexto WebGL, então precisa ser solto
|
||||
* (`disposeRenderer`, o `else` do `catch`), o canvas sai da página e a página é avisada. Os outros caminhos de
|
||||
* mountField (desmontada antes da 1ª tarefa, entre o renderizador e as peças, durante a compilação, shader que não
|
||||
* compila, sucesso) estão em HeroFieldScene.test.tsx.
|
||||
*/
|
||||
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockBuildThrows = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
render = jest.fn();
|
||||
compileAsync = jest.fn(() => Promise.resolve());
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
getPixelRatio = () => 1;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("three", () => {
|
||||
const actual = jest.requireActual("three");
|
||||
class FailingCamera extends actual.PerspectiveCamera {
|
||||
constructor(...args: unknown[]) {
|
||||
if (mockBuildThrows) throw new Error("sem memória para a cena");
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
PerspectiveCamera: FailingCamera,
|
||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockBuildThrows = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const props = () => ({
|
||||
progressRef: { current: 0 },
|
||||
visible: false,
|
||||
onReady: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
});
|
||||
|
||||
const buildScene = () =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
describe("mountField: erro antes de as peças existirem", () => {
|
||||
it("path: the scene parts fail to build -> the renderer opened a moment ago is released, the canvas leaves and the page is told once", async () => {
|
||||
mockBuildThrows = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = render(<HeroFieldScene {...scene} />);
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("path: nothing fails -> the same setup mounts, draws and is released only on unmount (the control for the case above)", async () => {
|
||||
const scene = props();
|
||||
|
||||
const { container, unmount } = render(<HeroFieldScene {...scene} />);
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].dispose).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -502,14 +502,14 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.frame()).toBe(0);
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: everything settled and the hero is not visible -> no frame", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.frame()).toBe(0);
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
||||
@@ -518,7 +518,7 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
expect(scene.frame(50)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||
@@ -527,7 +527,7 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
expect(scene.frame()).toBe(0);
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||
@@ -543,11 +543,11 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame()).toBe(0);
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
|
||||
scene.engine.setVisible(true);
|
||||
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
expect(scene.frame(50)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: several requests before a frame become ONE animation frame", () => {
|
||||
@@ -843,6 +843,86 @@ describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", () => {
|
||||
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
|
||||
function drawnOn(
|
||||
scene: ReturnType<typeof setup>,
|
||||
refreshMs: number,
|
||||
seconds = 1,
|
||||
) {
|
||||
const before = scene.renderer.render.mock.calls.length;
|
||||
const frames = Math.round((seconds * 1000) / refreshMs);
|
||||
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
|
||||
return scene.renderer.render.mock.calls.length - before;
|
||||
}
|
||||
|
||||
function withMovingCursor() {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
return scene;
|
||||
}
|
||||
|
||||
it.each([
|
||||
[144, 1000 / 144],
|
||||
[120, 1000 / 120],
|
||||
[60, 1000 / 60],
|
||||
])(
|
||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second, not one per screen frame",
|
||||
(_hz, refreshMs) => {
|
||||
const scene = withMovingCursor();
|
||||
|
||||
const drawn = drawnOn(scene, refreshMs, 2);
|
||||
|
||||
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
|
||||
expect(drawn).toBeLessThanOrEqual(2 * 31);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a skipped frame draws nothing, keeps the clock of the last drawn one and asks for the next", () => {
|
||||
const scene = withMovingCursor();
|
||||
scene.frame(50); // o último desenhado
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
|
||||
expect(scene.frame(10)).toBe(1); // 10 ms depois: pula e pede outro
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
|
||||
|
||||
expect(scene.frame(10)).toBe(1);
|
||||
expect(scene.frame(10)).toBe(1);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
|
||||
|
||||
// 40 ms depois do último desenhado: sai (o relógio do quadro pulado não conta)
|
||||
scene.frame(10);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
});
|
||||
|
||||
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.frame(); // a entrada ainda corre
|
||||
const intro = drawnOn(scene, 1000 / 144, 0.5);
|
||||
expect(intro).toBe(Math.round(500 / (1000 / 144)));
|
||||
|
||||
scene.settle(200); // tudo assentado
|
||||
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
|
||||
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
|
||||
expect(scrolling).toBe(15);
|
||||
});
|
||||
|
||||
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
|
||||
scene.frame(33);
|
||||
|
||||
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
|
||||
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
scene.frame(33);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -3,7 +3,9 @@ import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/*
|
||||
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê.
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
|
||||
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
|
||||
* existe: o renderizador é um dublê.
|
||||
*/
|
||||
|
||||
type RendererOptions = {
|
||||
@@ -13,12 +15,15 @@ type RendererOptions = {
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
|
||||
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 1;
|
||||
options: RendererOptions;
|
||||
render = jest.fn();
|
||||
compileAsync = jest.fn(() => mockCompile());
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
@@ -59,6 +64,7 @@ beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
mockCompile = () => Promise.resolve();
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
|
||||
@@ -66,9 +72,27 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
|
||||
const buildScene = (ms = 10) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
|
||||
const nextTask = () =>
|
||||
act(async () => {
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
async function mount(ui: React.ReactElement) {
|
||||
const result = render(ui);
|
||||
await buildScene();
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("HeroFieldScene (ambient 3D)", () => {
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => {
|
||||
const { container } = render(<HeroFieldScene {...props()} />);
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
|
||||
const { container } = await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].options).toMatchObject({
|
||||
@@ -88,15 +112,18 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
[1.25, 1.25],
|
||||
[2, 1.5],
|
||||
[3, 1.5],
|
||||
])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
});
|
||||
])(
|
||||
"caps the pixel ratio at 1.5 (device %s -> %s)",
|
||||
async (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
await mount(<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", () => {
|
||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
||||
@@ -115,9 +142,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("tells the page it is ready after the second frame", () => {
|
||||
it("tells the page it is ready after the second frame", async () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
@@ -127,10 +154,12 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => {
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(<HeroFieldScene {...scene} visible={false} />);
|
||||
const { rerender } = await mount(
|
||||
<HeroFieldScene {...scene} visible={false} />,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
});
|
||||
@@ -158,9 +187,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the context, the canvas and the timers when it unmounts", () => {
|
||||
it("releases the context, the canvas and the timers when it unmounts", async () => {
|
||||
const scene = props();
|
||||
const { container, unmount } = render(
|
||||
const { container, unmount } = await mount(
|
||||
<HeroFieldScene {...scene} visible />,
|
||||
);
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
@@ -178,9 +207,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => {
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
|
||||
const first = props();
|
||||
const { rerender } = render(<HeroFieldScene {...first} />);
|
||||
const { rerender } = await mount(<HeroFieldScene {...first} />);
|
||||
const second = props();
|
||||
rerender(
|
||||
<HeroFieldScene
|
||||
@@ -200,11 +229,11 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(second.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => {
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
|
||||
mockFailToCreate = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = render(<HeroFieldScene {...scene} />);
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
@@ -217,18 +246,18 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
[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) => {
|
||||
async (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => {
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
act(() => {
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
@@ -238,4 +267,142 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("built in separate tasks (no single long task)", () => {
|
||||
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
|
||||
let finishCompile = () => {};
|
||||
mockCompile = () =>
|
||||
new Promise((resolve) => {
|
||||
finishCompile = () => resolve(undefined);
|
||||
});
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
// nada acontece dentro do commit do React: nem o contexto WebGL
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
|
||||
await nextTask();
|
||||
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
await nextTask();
|
||||
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
|
||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
||||
|
||||
finishCompile();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
|
||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
await nextTask();
|
||||
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
|
||||
expect(mockRenderers[0].setSize).toHaveBeenCalled();
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].render).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(mockRenderers[0].render).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compiles the very scene it is going to draw", async () => {
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
const [scene, camera] = mockRenderers[0].compileAsync.mock
|
||||
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
|
||||
expect(scene.isScene).toBe(true);
|
||||
expect(camera.isCamera).toBe(true);
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
|
||||
});
|
||||
|
||||
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
|
||||
const scene = props();
|
||||
const { unmount } = render(<HeroFieldScene {...scene} />);
|
||||
|
||||
unmount();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
|
||||
const { container, unmount } = render(<HeroFieldScene {...props()} />);
|
||||
await nextTask();
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("releases the context if it is unmounted while the shader compiles", async () => {
|
||||
let finishCompile = () => {};
|
||||
mockCompile = () =>
|
||||
new Promise((resolve) => {
|
||||
finishCompile = () => resolve(undefined);
|
||||
});
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const { container, unmount } = await mount(
|
||||
<HeroFieldScene {...props()} />,
|
||||
);
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
finishCompile();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the page, and frees the context, when the shader does not compile", async () => {
|
||||
mockCompile = () => Promise.reject(new Error("shader inválido"));
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the visibility the page asked for while it was still being built", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(
|
||||
<HeroFieldScene {...scene} visible={false} />,
|
||||
);
|
||||
rerender(<HeroFieldScene {...scene} visible />);
|
||||
|
||||
await buildScene();
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(980);
|
||||
});
|
||||
|
||||
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
|
||||
expect(frames.mock.calls.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -382,9 +382,9 @@ const FRAME = 5.2;
|
||||
/*
|
||||
* 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
|
||||
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) entram um pouco antes de a
|
||||
* 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).
|
||||
* imagem, uma tabela de dados). As legendas do Hero (HERO_CAPTIONS, em hero-captions.ts) entram um pouco antes de
|
||||
* a câmera chegar a cada camada e saem um pouco depois (a última fica até Projetos subir e cobri-la); se os
|
||||
* quadros abaixo mudarem, os trechos delas mudam junto.
|
||||
* 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.
|
||||
*/
|
||||
@@ -602,11 +602,16 @@ export function updatePointer(rig: Rig, tick: Tick) {
|
||||
return targetAmt;
|
||||
}
|
||||
|
||||
/** A entrada ou a rolagem ainda não assentou (o progresso mostrado ainda não alcançou o da página). */
|
||||
export function isSettling(u: Uniforms, s: FieldState, target: number) {
|
||||
return u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
|
||||
* 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
|
||||
* à vista.
|
||||
* rolagem); o quadro pede o seguinte (no ritmo da tela, até 60) só enquanto algo ainda muda. A entrada e a rolagem
|
||||
* terminam de assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta
|
||||
* com o hero à vista.
|
||||
*/
|
||||
export function needsFrame(
|
||||
u: Uniforms,
|
||||
@@ -616,12 +621,18 @@ export function needsFrame(
|
||||
cursorActive: boolean,
|
||||
visible: boolean,
|
||||
) {
|
||||
const settling = u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
|
||||
const interacting =
|
||||
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
|
||||
return settling || (visible && interacting);
|
||||
return isSettling(u, s, target) || (visible && interacting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intervalo mínimo (ms) entre dois quadros quando só o cursor os pede: uns 30 por segundo, mesmo numa tela de 144 Hz
|
||||
* (que desenhava 142). Fica um pouco abaixo de 33,3 (dois quadros de uma tela de 60 Hz) para a tela de 60 Hz manter
|
||||
* 30 e a batida (um setInterval de 33 ms, com a folga do temporizador) não perder quadros.
|
||||
*/
|
||||
const CURSOR_FRAME_MS = 32;
|
||||
|
||||
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
|
||||
function createMaterial() {
|
||||
return new ShaderMaterial({
|
||||
@@ -824,18 +835,46 @@ function listenToField({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Completa o renderizador com o que o quadro desenha: a nuvem de pontos e o estado. Separado de `createRenderer` (que
|
||||
* abre o contexto WebGL) para a montagem em tarefas poder pôr um passo em cada uma.
|
||||
*/
|
||||
export function createField(
|
||||
function createParts(renderer: WebGLRenderer) {
|
||||
return {
|
||||
renderer,
|
||||
world: buildWorld(),
|
||||
state: createState(),
|
||||
helpers: createHelpers(),
|
||||
};
|
||||
}
|
||||
|
||||
type Parts = ReturnType<typeof createParts>;
|
||||
|
||||
/** Solta o contexto WebGL e tira o canvas da página. */
|
||||
function disposeRenderer(renderer: WebGLRenderer) {
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
renderer.domElement.remove();
|
||||
}
|
||||
|
||||
/** Solta o que `createParts` criou: a geometria, o material e o contexto WebGL. */
|
||||
function disposeParts({ renderer, world }: Parts) {
|
||||
world.geometry.dispose();
|
||||
world.material.dispose();
|
||||
disposeRenderer(renderer);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cena sem React, a partir das peças já criadas: liga o tamanho, o mouse e a perda do contexto, 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. O primeiro quadro sai no
|
||||
* requestAnimationFrame que `resize` pede, numa tarefa própria.
|
||||
*/
|
||||
function startField(
|
||||
host: HTMLElement,
|
||||
parts: Parts,
|
||||
{ progressRef, onReady, onError }: FieldCallbacks,
|
||||
): FieldEngine {
|
||||
const renderer = createRenderer(host);
|
||||
const world = buildWorld();
|
||||
const state = createState();
|
||||
const helpers = createHelpers();
|
||||
const { renderer, world, state, helpers } = parts;
|
||||
const started = performance.now();
|
||||
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
|
||||
let visible = false;
|
||||
@@ -854,11 +893,22 @@ export function createField(
|
||||
function render(now: number) {
|
||||
raf = 0;
|
||||
if (!alive) return;
|
||||
const target = progressRef.current ?? 0;
|
||||
// só o cursor pede quadro (a entrada e a rolagem seguem a cada quadro): pula este se faz menos de ~32 ms do
|
||||
// último e pede o próximo, em vez de desenhar a cada quadro da tela
|
||||
if (
|
||||
last &&
|
||||
now - last < CURSOR_FRAME_MS &&
|
||||
!isSettling(world.material.uniforms, state, target)
|
||||
) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||
last = now;
|
||||
try {
|
||||
const step = stepField(world, state, helpers, layout, {
|
||||
target: progressRef.current ?? 0,
|
||||
target,
|
||||
visible,
|
||||
time: (now - started) / 1000,
|
||||
delta,
|
||||
@@ -903,15 +953,59 @@ export function createField(
|
||||
cancelAnimationFrame(raf);
|
||||
stopPulse?.();
|
||||
stopListening();
|
||||
world.geometry.dispose();
|
||||
world.material.dispose();
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
renderer.domElement.remove();
|
||||
disposeParts(parts);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL. */
|
||||
export function createField(
|
||||
host: HTMLElement,
|
||||
callbacks: FieldCallbacks,
|
||||
): FieldEngine {
|
||||
return startField(host, createParts(createRenderer(host)), callbacks);
|
||||
}
|
||||
|
||||
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa, depois de pintar e de atender o visitante. */
|
||||
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/**
|
||||
* A mesma cena montada em tarefas separadas, para nenhuma passar de uns poucos milissegundos (criar tudo de uma vez
|
||||
* era uma só tarefa de 90 a 250 ms): o renderizador (o contexto WebGL); a nuvem de pontos e o estado; a compilação do
|
||||
* shader (`compileAsync`, que o navegador faz fora da thread principal); e, por fim, o tamanho e o primeiro quadro
|
||||
* (que sai no requestAnimationFrame seguinte). Devolve `null` se `isAlive` virou falso no caminho; lança se o
|
||||
* aparelho não cria um contexto WebGL ou se o shader não compila.
|
||||
*/
|
||||
export async function mountField(
|
||||
host: HTMLElement,
|
||||
callbacks: FieldCallbacks,
|
||||
isAlive: () => boolean,
|
||||
): Promise<FieldEngine | null> {
|
||||
await nextTask();
|
||||
if (!isAlive()) return null;
|
||||
const renderer = createRenderer(host);
|
||||
let parts: Parts | undefined;
|
||||
try {
|
||||
await nextTask();
|
||||
if (!isAlive()) {
|
||||
disposeRenderer(renderer);
|
||||
return null;
|
||||
}
|
||||
parts = createParts(renderer);
|
||||
await renderer.compileAsync(parts.world.scene, parts.world.camera);
|
||||
await nextTask();
|
||||
} catch (error) {
|
||||
if (parts) disposeParts(parts);
|
||||
else disposeRenderer(renderer);
|
||||
throw error;
|
||||
}
|
||||
if (!isAlive()) {
|
||||
disposeParts(parts);
|
||||
return null;
|
||||
}
|
||||
return startField(host, parts, callbacks);
|
||||
}
|
||||
|
||||
export default function HeroFieldScene({
|
||||
progressRef,
|
||||
visible,
|
||||
@@ -926,27 +1020,41 @@ export default function HeroFieldScene({
|
||||
callbacks.current = { onReady, onError };
|
||||
});
|
||||
|
||||
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois de o `visible` já ter chegado)
|
||||
const wantsVisible = useRef(visible);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
try {
|
||||
engine.current = createField(host, {
|
||||
let alive = true;
|
||||
mountField(
|
||||
host,
|
||||
{
|
||||
progressRef,
|
||||
onReady: () => callbacks.current.onReady(),
|
||||
onError: () => callbacks.current.onError(),
|
||||
},
|
||||
() => alive,
|
||||
)
|
||||
.then((created) => {
|
||||
if (!created) return;
|
||||
if (!alive) return created.dispose();
|
||||
engine.current = created;
|
||||
created.setVisible(wantsVisible.current);
|
||||
})
|
||||
// sem WebGL (ou o contexto não abriu, o shader não compilou): o hero fica com o fundo estático
|
||||
.catch(() => {
|
||||
if (alive) callbacks.current.onError();
|
||||
});
|
||||
} catch {
|
||||
// sem WebGL (ou o contexto não abriu): o hero fica com o fundo estático
|
||||
callbacks.current.onError();
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
engine.current?.dispose();
|
||||
engine.current = null;
|
||||
};
|
||||
}, [progressRef]);
|
||||
|
||||
useEffect(() => {
|
||||
wantsVisible.current = visible;
|
||||
engine.current?.setVisible(visible);
|
||||
}, [visible]);
|
||||
|
||||
|
||||
@@ -129,14 +129,25 @@ describe("samePageHashTarget (rules shared with the arrival code)", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a link to the same path with another query string", () => {
|
||||
window.history.replaceState(null, "", "/?a=1");
|
||||
it("ignores the query string: the page is the same, so the header anchors keep scrolling with ?tipo= in the URL", () => {
|
||||
window.history.replaceState(null, "", "/?tipo=site");
|
||||
// O link do cabeçalho não repete a busca; um link com outra busca ainda é a mesma página.
|
||||
expect(samePageHashTarget(clickOn(`<a href="/#alvo">x</a>`))?.hash).toBe(
|
||||
"#alvo",
|
||||
);
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/?a=2#alvo">x</a>`)),
|
||||
).toBeNull();
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/?a=1#alvo">x</a>`))?.hash,
|
||||
samePageHashTarget(clickOn(`<a href="/?tipo=app#alvo">x</a>`))?.hash,
|
||||
).toBe("#alvo");
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/?tipo=site#alvo">x</a>`))?.hash,
|
||||
).toBe("#alvo");
|
||||
});
|
||||
|
||||
it("still refuses a link to another path, with or without a query string", () => {
|
||||
window.history.replaceState(null, "", "/?tipo=site");
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/outra?tipo=site#alvo">x</a>`)),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,13 +32,14 @@ function sameTabLink(event: MouseEvent) {
|
||||
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. */
|
||||
/**
|
||||
* O `#id` de um link para esta mesma página (mesma origem e mesmo caminho); `null` se leva a outro lugar ou não tem
|
||||
* âncora. A busca (`?tipo=`) não conta: a página é a mesma e o link do cabeçalho (`/#contact`) não a repete.
|
||||
*/
|
||||
function hashOnThisPage(link: HTMLAnchorElement) {
|
||||
const url = new URL(link.href);
|
||||
const same =
|
||||
url.origin === location.origin &&
|
||||
url.pathname === location.pathname &&
|
||||
url.search === location.search;
|
||||
url.origin === location.origin && url.pathname === location.pathname;
|
||||
return same && url.hash ? url.hash : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { CSSProperties } from "react";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
/*
|
||||
* A altura do mergulho do hero vive num lugar só: `--hero-tail` (globals.css), que o CSS usa na altura do hero e na
|
||||
* margem de Projetos, e que o HeroField lê para saber onde o progresso (--hero-p) acaba: quando Projetos começa a
|
||||
* subir sobre o palco, `--hero-tail` telas antes do fim do hero. Estes testes conferem o comportamento (o progresso
|
||||
* segue a variável), não o texto do CSS.
|
||||
*/
|
||||
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
prepareField: () => {},
|
||||
}));
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
const original = {
|
||||
matchMedia: window.matchMedia,
|
||||
webgl2: win.WebGL2RenderingContext,
|
||||
getContext: HTMLCanvasElement.prototype.getContext,
|
||||
innerHeight: Object.getOwnPropertyDescriptor(window, "innerHeight"),
|
||||
};
|
||||
|
||||
const SCREEN = 800;
|
||||
|
||||
beforeEach(() => {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query === DIVE_QUERY,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
Object.defineProperty(navigator, "hardwareConcurrency", {
|
||||
configurable: true,
|
||||
value: 8,
|
||||
});
|
||||
win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
getExtension: () => null,
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: SCREEN,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original.matchMedia;
|
||||
win.WebGL2RenderingContext = original.webgl2;
|
||||
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||
if (original.innerHeight)
|
||||
Object.defineProperty(window, "innerHeight", original.innerHeight);
|
||||
});
|
||||
|
||||
/** O hero com a altura (px) que o CSS lhe dá, rolado `scrolled` px; devolve o progresso do mergulho. */
|
||||
function progressAt({
|
||||
tail,
|
||||
height,
|
||||
scrolled,
|
||||
}: {
|
||||
tail?: number;
|
||||
height: number;
|
||||
scrolled: number;
|
||||
}) {
|
||||
const style =
|
||||
tail === undefined ? undefined : ({ "--hero-tail": tail } as CSSProperties);
|
||||
const { container, unmount } = render(
|
||||
<section className="hero" style={style}>
|
||||
<HeroField />
|
||||
</section>,
|
||||
);
|
||||
const hero = container.querySelector(".hero") as HTMLElement;
|
||||
hero.getBoundingClientRect = () =>
|
||||
({ top: -scrolled, height, bottom: height - scrolled }) as DOMRect;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
// o rAF do jsdom corre em ~16 ms
|
||||
return new Promise<number>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const progress = Number(hero.style.getPropertyValue("--hero-p"));
|
||||
unmount();
|
||||
resolve(progress);
|
||||
}, 60);
|
||||
});
|
||||
}
|
||||
|
||||
describe("hero dive: --hero-tail is the one source of its height", () => {
|
||||
it("ends the dive where Projects starts to rise: --hero-tail screens before the end of the hero", async () => {
|
||||
// hero de 2800 px com 2 telas de cauda: o mergulho tem 2800 - 2 * 800 = 1200 px
|
||||
expect(
|
||||
await progressAt({ tail: 2, height: 2800, scrolled: 600 }),
|
||||
).toBeCloseTo(0.5);
|
||||
expect(await progressAt({ tail: 2, height: 2800, scrolled: 1200 })).toBe(1);
|
||||
});
|
||||
|
||||
it("follows the variable: the same hero with a shorter tail has a longer dive", async () => {
|
||||
// 2800 - 1,5 * 800 = 1600 px de mergulho
|
||||
expect(
|
||||
await progressAt({ tail: 1.5, height: 2800, scrolled: 800 }),
|
||||
).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("treats a hero without --hero-tail as stage only (the dive is the rest of the height)", async () => {
|
||||
// 2000 - 1 * 800 = 1200 px de mergulho
|
||||
expect(await progressAt({ height: 2000, scrolled: 600 })).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
@@ -509,15 +509,19 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
|
||||
expect(prevented).toBe(false);
|
||||
});
|
||||
|
||||
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
|
||||
it("path: reduced motion -> no Lenis, and each anchor click is an instant jump, never an animation", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("projetos"));
|
||||
const first = click(link("projetos"));
|
||||
const second = click(link("projetos"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(first.prevented).toBe(true);
|
||||
expect(second.prevented).toBe(true);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(2);
|
||||
for (const [options] of (window.scrollTo as jest.Mock).mock.calls)
|
||||
expect(options).toMatchObject({ behavior: "instant" });
|
||||
});
|
||||
|
||||
it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => {
|
||||
|
||||
@@ -252,19 +252,32 @@ describe("useCinematicScroll", () => {
|
||||
expect(contact).toHaveTextContent("Contato");
|
||||
});
|
||||
|
||||
it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => {
|
||||
it("with reduced motion there is no smooth scroll, and EVERY anchor click jumps instantly (no animation)", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
|
||||
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
act(() => {
|
||||
link.dispatchEvent(event);
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
const event = new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
act(() => {
|
||||
link.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
// o clique é tratado em todas as vezes, não só na primeira: salto instantâneo até a cena
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({
|
||||
top: TOPS.projects,
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
expect(document.getElementById("projects")).toHaveFocus();
|
||||
});
|
||||
|
||||
describe("smooth scroll", () => {
|
||||
|
||||
@@ -15,7 +15,8 @@ import { focusFragment, onFragmentClick } from "./fragment-nav";
|
||||
* 2. Este hook cuida só do que o CSS não faz: no mouse, o Lenis suaviza a roda (com o relógio ligado apenas
|
||||
* enquanto algo se move, nunca com a página parada), e os links internos (#id) "avançam o vídeo" até o
|
||||
* destino em vez de saltar (o clique vem de fragment-nav). No toque a rolagem nativa corre em outra thread:
|
||||
* nem o Lenis é baixado.
|
||||
* nem o Lenis é baixado. Com movimento reduzido não há Lenis nem animação, mas o clique segue tratado aqui: o
|
||||
* salto é instantâneo, em todos os cliques (o link do Next só rolava no primeiro, com a mesma âncora).
|
||||
*
|
||||
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
|
||||
*/
|
||||
@@ -119,17 +120,23 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
const reducedMotion = prefersReducedMotion();
|
||||
let cancelled = false;
|
||||
let smooth: SmoothScroll | null = null;
|
||||
|
||||
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
||||
if (!isCoarsePointer())
|
||||
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote); com movimento reduzido,
|
||||
// também não há suavização
|
||||
if (!reducedMotion && !isCoarsePointer())
|
||||
startSmoothScroll(() => !cancelled).then((scroll) => {
|
||||
smooth = scroll;
|
||||
});
|
||||
|
||||
const scrollToTop = (top: number) => {
|
||||
if (reducedMotion) {
|
||||
// "instant" vale mesmo com `scroll-behavior: smooth` no CSS
|
||||
window.scrollTo({ top, behavior: "instant" });
|
||||
return;
|
||||
}
|
||||
const duration = anchorDuration(top - window.scrollY, vh());
|
||||
if (smooth) smooth.scrollTo(top, duration);
|
||||
else
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
@@ -47,6 +48,16 @@ const headerHeight = () => {
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/**
|
||||
* Larguras (CSS) em que os pôsteres aparecem, para o next/image pedir o arquivo do tamanho certo (um WebP/AVIF de
|
||||
* ~1080 px no notebook e de ~256 px no celular) e não o original de 1440 px: o notebook mede ~58vw (até ~1100 px nas
|
||||
* telas largas; 90vw abaixo de 1024 px, com a vitrine em coluna) e o celular da moldura, 22% dele.
|
||||
*/
|
||||
const LAPTOP_POSTER_SIZES =
|
||||
"(min-width: 1900px) 1100px, (min-width: 1024px) 58vw, 90vw";
|
||||
const PHONE_POSTER_SIZES =
|
||||
"(min-width: 1900px) 240px, (min-width: 1024px) 13vw, 20vw";
|
||||
|
||||
/** 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;
|
||||
@@ -519,16 +530,22 @@ function ReelLayer({
|
||||
inert={!isActive}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
|
||||
}
|
||||
>
|
||||
<div className="ds-reel-screen">
|
||||
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
|
||||
{poster && (
|
||||
<Image
|
||||
src={desktop.poster}
|
||||
alt=""
|
||||
width={desktop.width}
|
||||
height={desktop.height}
|
||||
sizes={LAPTOP_POSTER_SIZES}
|
||||
loading="eager"
|
||||
fetchPriority="low"
|
||||
/>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attached ? getScrubSource(config.id) : undefined}
|
||||
poster={poster ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
@@ -541,10 +558,19 @@ function ReelLayer({
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={poster ? phone.poster : undefined}
|
||||
/>
|
||||
<PhoneFrame className="ds-reel-phone">
|
||||
{poster && (
|
||||
<Image
|
||||
src={phone.poster}
|
||||
alt=""
|
||||
width={phone.width}
|
||||
height={phone.height}
|
||||
sizes={PHONE_POSTER_SIZES}
|
||||
loading="eager"
|
||||
fetchPriority="low"
|
||||
/>
|
||||
)}
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -615,7 +641,11 @@ function ReelCaption({
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
|
||||
<ProjectActions
|
||||
item={item}
|
||||
onExpand={() => onExpand(config.id)}
|
||||
grouped
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { canOpenSite, ProjectActions } from "./ProjectActions";
|
||||
import { buildShowcaseItems, type ShowcaseItem } from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ProjectActions (CC 7) e canOpenSite (CC 2).
|
||||
* Sem `grouped` (a lista): "Ver case", "Ampliar vídeo" (só com onExpand) e "Abrir site" (só com site no ar) são três
|
||||
* paradas do Tab cada uma. Com `grouped` (a vitrine fixa): uma barra de ferramentas e UMA parada, o roving tabindex:
|
||||
* a parada é "Ver case" até o foco passar a outra ação que ainda exista; as setas e Home/End movem o foco.
|
||||
* Combinações: grouped x (com | sem onExpand) x (com | sem site).
|
||||
*/
|
||||
|
||||
jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch: _prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: unknown } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => <a {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
const base = buildShowcaseItems(getFeaturedProjects(null)).find(
|
||||
({ project }) => project.demoUrl,
|
||||
) as ShowcaseItem;
|
||||
|
||||
function item(overrides: Partial<ShowcaseItem["project"]>): ShowcaseItem {
|
||||
return { ...base, project: { ...base.project, ...overrides } };
|
||||
}
|
||||
|
||||
const withSite = item({
|
||||
demoUrl: "https://exemplo.com.br",
|
||||
demoAvailability: "available",
|
||||
});
|
||||
const withoutSite = item({ demoUrl: null, demoAvailability: "unknown" });
|
||||
const siteDown = item({
|
||||
demoUrl: "https://exemplo.com.br",
|
||||
demoAvailability: "unavailable",
|
||||
});
|
||||
|
||||
const link = (name: RegExp) => screen.getByRole("link", { name });
|
||||
const tabindexes = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>("[data-roving]")).map(
|
||||
(element) => `${element.getAttribute("data-roving")}:${element.tabIndex}`,
|
||||
);
|
||||
const press = (key: string) =>
|
||||
fireEvent.keyDown(document.activeElement as Element, { key });
|
||||
|
||||
describe("canOpenSite", () => {
|
||||
it.each([
|
||||
["site no ar", withSite, true],
|
||||
["sem endereço", withoutSite, false],
|
||||
["endereço dado como fora do ar", siteDown, false],
|
||||
])("path: %s -> %s", (_name, project, expected) => {
|
||||
expect(canOpenSite(project.project)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectActions sem grouped (a lista)", () => {
|
||||
it("path: expand and a live site -> three actions, no toolbar, no roving attributes (each is its own Tab stop)", () => {
|
||||
const { container } = render(
|
||||
<ProjectActions item={withSite} onExpand={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(link(/Ver case/)).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /Ampliar/ })).toBeVisible();
|
||||
expect(link(/Abrir site/)).toHaveAttribute("target", "_blank");
|
||||
expect(screen.queryByRole("toolbar")).toBeNull();
|
||||
expect(container.querySelector("[data-roving]")).toBeNull();
|
||||
expect(container.querySelector("[tabindex]")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no onExpand (before the page mounts) and no site -> only 'Ver case'", () => {
|
||||
render(<ProjectActions item={withoutSite} />);
|
||||
|
||||
expect(link(/Ver case/)).toBeVisible();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /Abrir site/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: a site declared down -> no 'Abrir site' even with an address", () => {
|
||||
render(<ProjectActions item={siteDown} onExpand={jest.fn()} />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /Abrir site/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: the expand button runs onExpand", () => {
|
||||
const onExpand = jest.fn();
|
||||
render(<ProjectActions item={withSite} onExpand={onExpand} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Ampliar/ }));
|
||||
|
||||
expect(onExpand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectActions com grouped (a vitrine fixa)", () => {
|
||||
it("path: all three actions -> one toolbar named after the project, one Tab stop ('Ver case'), the others at -1", () => {
|
||||
render(<ProjectActions item={withSite} onExpand={jest.fn()} grouped />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("toolbar", {
|
||||
name: `Ações de ${withSite.project.title}`,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(tabindexes()).toEqual(["case:0", "expand:-1", "site:-1"]);
|
||||
});
|
||||
|
||||
it("path: the arrows walk the three actions in order and stop at the ends; Home and End jump; Tab is left alone", () => {
|
||||
render(<ProjectActions item={withSite} onExpand={jest.fn()} grouped />);
|
||||
const expand = screen.getByRole("button", { name: /Ampliar/ });
|
||||
|
||||
act(() => link(/Ver case/).focus());
|
||||
press("ArrowRight");
|
||||
expect(expand).toHaveFocus();
|
||||
press("ArrowRight");
|
||||
expect(link(/Abrir site/)).toHaveFocus();
|
||||
press("ArrowRight");
|
||||
expect(link(/Abrir site/)).toHaveFocus();
|
||||
press("Home");
|
||||
expect(link(/Ver case/)).toHaveFocus();
|
||||
press("End");
|
||||
expect(link(/Abrir site/)).toHaveFocus();
|
||||
// o Tab sai do grupo em vez de andar dentro dele: não é cancelado
|
||||
expect(press("Tab")).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the Tab stop follows the last focused action, so Shift+Tab and Tab come back to it", () => {
|
||||
render(<ProjectActions item={withSite} onExpand={jest.fn()} grouped />);
|
||||
|
||||
act(() => link(/Abrir site/).focus());
|
||||
|
||||
expect(tabindexes()).toEqual(["case:-1", "expand:-1", "site:0"]);
|
||||
});
|
||||
|
||||
it("path: no site -> the toolbar has two actions and the arrows never reach a missing 'Abrir site'", () => {
|
||||
render(<ProjectActions item={withoutSite} onExpand={jest.fn()} grouped />);
|
||||
|
||||
expect(tabindexes()).toEqual(["case:0", "expand:-1"]);
|
||||
act(() => screen.getByRole("button", { name: /Ampliar/ }).focus());
|
||||
press("ArrowRight");
|
||||
expect(screen.getByRole("button", { name: /Ampliar/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: no onExpand and no site -> a toolbar of one action, which is the Tab stop", () => {
|
||||
render(<ProjectActions item={withoutSite} grouped />);
|
||||
|
||||
expect(tabindexes()).toEqual(["case:0"]);
|
||||
expect(screen.getByRole("toolbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the stop was on 'Abrir site' and the next render has no site -> back to 'Ver case', never a missing stop", () => {
|
||||
const { rerender } = render(
|
||||
<ProjectActions item={withSite} onExpand={jest.fn()} grouped />,
|
||||
);
|
||||
act(() => link(/Abrir site/).focus());
|
||||
expect(tabindexes()).toContain("site:0");
|
||||
|
||||
rerender(
|
||||
<ProjectActions item={withoutSite} onExpand={jest.fn()} grouped />,
|
||||
);
|
||||
|
||||
expect(tabindexes()).toEqual(["case:0", "expand:-1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||
import { useRovingTabindex } from "./useRovingTabindex";
|
||||
|
||||
/** O site publicado abre em nova aba quando existe e não foi dado como fora do ar. */
|
||||
export function canOpenSite(project: ShowcaseItem["project"]) {
|
||||
@@ -10,19 +13,49 @@ export function canOpenSite(project: ShowcaseItem["project"]) {
|
||||
* Ações de um projeto na vitrine: "Ver case" (contorno) leva ao estudo de caso; "Ampliar vídeo" (contorno,
|
||||
* com ícone) abre a janela ampliada; "Abrir site" (link de texto, nova aba) só existe com `demoUrl`.
|
||||
* Nenhuma é verde-limão: o preenchido de destaque é só da ação de contato.
|
||||
*
|
||||
* Com `grouped`, as ações são UMA parada no Tab (barra de ferramentas com roving tabindex: as setas e Home/End movem o
|
||||
* foco entre elas): na vitrine fixa só o projeto em cena existe, e sem isto eram três paradas a mais por projeto.
|
||||
*/
|
||||
export function ProjectActions({
|
||||
item,
|
||||
onExpand,
|
||||
grouped = false,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
/** Sem a janela ampliada (antes de o navegador montar a página) o botão nem aparece. */
|
||||
onExpand?: () => void;
|
||||
/** As ações viram uma parada só no Tab (a vitrine fixa); na lista, cada uma é a sua. */
|
||||
grouped?: boolean;
|
||||
}) {
|
||||
const { project } = item;
|
||||
const site = canOpenSite(project);
|
||||
// só as ações que existem podem ser a parada do grupo (um projeto sem site não tem "Abrir site")
|
||||
const present = [
|
||||
"case",
|
||||
...(onExpand ? ["expand"] : []),
|
||||
...(site ? ["site"] : []),
|
||||
];
|
||||
const { groupProps, itemProps } = useRovingTabindex("case", (key) =>
|
||||
present.includes(key),
|
||||
);
|
||||
const roving = (key: string) => (grouped ? itemProps(key) : {});
|
||||
return (
|
||||
<div className="ds-reel-actions">
|
||||
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||||
<div
|
||||
className="ds-reel-actions"
|
||||
{...(grouped
|
||||
? {
|
||||
role: "toolbar",
|
||||
"aria-label": `Ações de ${project.title}`,
|
||||
...groupProps,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Link
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/cases/${project.id}`}
|
||||
{...roving("case")}
|
||||
>
|
||||
Ver case
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</Link>
|
||||
@@ -32,6 +65,7 @@ export function ProjectActions({
|
||||
className="ds-link ds-link-muted ds-reel-expand"
|
||||
onClick={onExpand}
|
||||
aria-haspopup="dialog"
|
||||
{...roving("expand")}
|
||||
>
|
||||
<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" />
|
||||
@@ -41,12 +75,13 @@ export function ProjectActions({
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</button>
|
||||
)}
|
||||
{canOpenSite(project) && (
|
||||
{site && (
|
||||
<a
|
||||
className="ds-link ds-link-text"
|
||||
href={project.demoUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...roving("site")}
|
||||
>
|
||||
Abrir site
|
||||
<span className="sr-only">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
||||
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Caminhos-base de readMetadata (CC 3) e do efeito de `readyState` no ProjectLightbox (complemento de
|
||||
* ProjectLightbox.test.tsx): o vídeo que já chega com os metadados (cache, preload) não dispara `loadedmetadata`
|
||||
* depois de montado, então o efeito lê a duração e aplica o ponto pendente da troca de aparelho.
|
||||
* O que o teste principal já cobre: pronto na abertura sem ponto pendente; troca de aparelho com o evento.
|
||||
* Aqui: troca de aparelho com o vídeo novo JÁ pronto (o ponto pendente é aplicado sem evento algum), troca de
|
||||
* projeto com o vídeo novo já pronto (sem ponto pendente, o vídeo começa do zero), e vídeo ainda sem metadados.
|
||||
*/
|
||||
|
||||
jest.mock("./ProjectLightboxLazy", () => ({
|
||||
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
||||
}));
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => false,
|
||||
}));
|
||||
|
||||
const DURATION = 30;
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
const projects = getFeaturedProjects(null);
|
||||
/** O ponto de cada <video> (jsdom não tem reprodução). */
|
||||
const positions = new WeakMap<object, number>();
|
||||
let readyState = 0;
|
||||
|
||||
function Card({ id }: { id: string }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => openProject(id)}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function open(id = "lavacao-047") {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="sev-modaintima" />
|
||||
<Card id="lavacao-047" />
|
||||
<Card id="nutriscan" />
|
||||
<Card id="as-servicos" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` }));
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
const video = (dialog: HTMLElement) =>
|
||||
dialog.querySelector("video") as HTMLVideoElement;
|
||||
|
||||
beforeAll(() => {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
HTMLDialogElement.prototype.showModal = function showModal(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
readyState = 0;
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "readyState", "get")
|
||||
.mockImplementation(() => readyState);
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "duration", "get")
|
||||
.mockImplementation(() => DURATION);
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "currentTime", "get")
|
||||
.mockImplementation(function currentTime(this: HTMLMediaElement) {
|
||||
return positions.get(this) ?? 0;
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "currentTime", "set")
|
||||
.mockImplementation(function currentTime(
|
||||
this: HTMLMediaElement,
|
||||
value: number,
|
||||
) {
|
||||
positions.set(this, value);
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "play")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
document.documentElement.classList.remove("ds-lb-open");
|
||||
});
|
||||
|
||||
describe("ProjectLightbox: vídeo que já chega com os metadados", () => {
|
||||
it("path: switching device and the new video is already ready -> the pending point is applied without any loadedmetadata event", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = open();
|
||||
const desktop = video(dialog);
|
||||
positions.set(desktop, DURATION * 0.5);
|
||||
|
||||
readyState = 1;
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
|
||||
const phone = video(dialog);
|
||||
expect(phone).not.toBe(desktop);
|
||||
expect(positions.get(phone)).toBeCloseTo(
|
||||
mobileFractionAt(lavacao, 0.5) * DURATION,
|
||||
5,
|
||||
);
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toMatch(
|
||||
/ \/ 0:30$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: switching device, the new video is NOT ready yet -> nothing is applied until loadedmetadata, then the point is applied once", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = open();
|
||||
positions.set(video(dialog), DURATION * 0.5);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
const phone = video(dialog);
|
||||
expect(positions.has(phone)).toBe(false);
|
||||
|
||||
act(() => {
|
||||
phone.dispatchEvent(new Event("loadedmetadata"));
|
||||
});
|
||||
const applied = positions.get(phone);
|
||||
expect(applied).toBeCloseTo(mobileFractionAt(lavacao, 0.5) * DURATION, 5);
|
||||
|
||||
// um segundo loadedmetadata (o vídeo recarrega) não puxa o ponto de volta: o pendente já foi consumido
|
||||
positions.set(phone, 7);
|
||||
act(() => {
|
||||
phone.dispatchEvent(new Event("loadedmetadata"));
|
||||
});
|
||||
expect(positions.get(phone)).toBe(7);
|
||||
});
|
||||
|
||||
it("path: moving to another project whose video is already ready -> it starts from zero, with the duration shown", () => {
|
||||
const dialog = open("lavacao-047");
|
||||
positions.set(video(dialog), DURATION * 0.5);
|
||||
|
||||
readyState = 1;
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /próximo/i }));
|
||||
|
||||
expect(positions.has(video(dialog))).toBe(false);
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toMatch(
|
||||
/^0:00 \/ 0:30$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,50 @@ describe("ProjectLightbox", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the duration when the video already has its metadata at opening (loadedmetadata never fires again)", () => {
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "readyState", "get")
|
||||
.mockReturnValue(1);
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "duration", "get")
|
||||
.mockReturnValue(75);
|
||||
|
||||
const dialog = setup();
|
||||
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 1:15",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps 0:00 / 0:00 until the metadata is there, then reads the duration from the playback tick", () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
const dialog = setup();
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 0:00",
|
||||
);
|
||||
|
||||
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "duration", {
|
||||
configurable: true,
|
||||
value: 42,
|
||||
});
|
||||
// sem evento algum: quem lê é o marcador, quadro a quadro, enquanto o vídeo toca
|
||||
act(() => {
|
||||
video.dispatchEvent(new Event("play"));
|
||||
});
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 0:42",
|
||||
);
|
||||
});
|
||||
|
||||
it("seeks with the range slider", () => {
|
||||
const dialog = setup();
|
||||
const video = loadVideo(dialog, 30);
|
||||
|
||||
@@ -76,6 +76,23 @@ export function ProjectLightbox({
|
||||
const [fraction, setFraction] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
// lê a duração e aplica o ponto pendente da troca de aparelho; vale para o `loadedmetadata` e para o vídeo que já
|
||||
// chegou com os metadados (cache, preload): nesse caso o evento passou antes de alguém ouvir e não volta
|
||||
const readMetadata = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setDuration(video.duration);
|
||||
if (pendingFraction.current !== null) {
|
||||
video.currentTime = pendingFraction.current * video.duration;
|
||||
pendingFraction.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: o <video> é outro elemento a cada projeto/aparelho (key)
|
||||
useEffect(() => {
|
||||
if ((videoRef.current?.readyState ?? 0) >= 1) readMetadata();
|
||||
}, [config.id, device, readMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reducedMotion) return;
|
||||
wantsToPlay.current = false;
|
||||
@@ -108,8 +125,10 @@ export function ProjectLightbox({
|
||||
let id = 0;
|
||||
const tick = () => {
|
||||
const current = videoRef.current;
|
||||
if (current?.duration)
|
||||
if (current?.duration) {
|
||||
setDuration(current.duration);
|
||||
setFraction(current.currentTime / current.duration);
|
||||
}
|
||||
id = requestAnimationFrame(tick);
|
||||
};
|
||||
id = requestAnimationFrame(tick);
|
||||
@@ -157,16 +176,6 @@ export function ProjectLightbox({
|
||||
onNavigate(next);
|
||||
};
|
||||
|
||||
const handleMetadata = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setDuration(video.duration);
|
||||
if (pendingFraction.current !== null) {
|
||||
video.currentTime = pendingFraction.current * video.duration;
|
||||
pendingFraction.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTime = () => {
|
||||
const video = videoRef.current;
|
||||
if (video?.duration) setFraction(video.currentTime / video.duration);
|
||||
@@ -219,7 +228,7 @@ export function ProjectLightbox({
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
onLoadedMetadata={handleMetadata}
|
||||
onLoadedMetadata={readMetadata}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onTimeUpdate={handleTime}
|
||||
|
||||
@@ -124,7 +124,8 @@ describe("ProjectList", () => {
|
||||
expect(decodeURIComponent(image.getAttribute("src") ?? "")).toContain(
|
||||
"mobile.webp",
|
||||
);
|
||||
expect(image).toHaveAttribute("sizes", "(max-width: 767px) 70vw");
|
||||
// o celular da moldura mede ~160 px no carrossel: o pôster de 390 px não viaja inteiro
|
||||
expect(image).toHaveAttribute("sizes", "160px");
|
||||
});
|
||||
|
||||
it("path: an item that is not near yet keeps its frame but no image", () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { PhoneFrame } from "./PhoneFrame";
|
||||
import { ProjectActions } from "./ProjectActions";
|
||||
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
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
|
||||
@@ -87,7 +86,8 @@ function ListItem({
|
||||
alt={`Captura de tela de ${project.title}`}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
sizes={device === "mobile" ? "(max-width: 767px) 70vw" : "50vw"}
|
||||
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
|
||||
sizes={device === "mobile" ? "160px" : "50vw"}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ function mockViewport({ phone = false }: { phone?: boolean } = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** O arquivo do pôster que a imagem dentro de `element` pede (decodificado); "" sem imagem. */
|
||||
const posterIn = (element: Element | null | undefined) =>
|
||||
decodeURIComponent(element?.querySelector("img")?.getAttribute("src") ?? "");
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const weights = [
|
||||
"sev-modaintima",
|
||||
@@ -294,10 +298,9 @@ describe("ProjectReel", () => {
|
||||
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
|
||||
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
|
||||
expect(layer.querySelector(".ds-phone video")).toBeNull();
|
||||
expect(
|
||||
(layer.querySelector(".ds-phone-screen") as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("lavacao-047/mobile.webp");
|
||||
expect(posterIn(layer.querySelector(".ds-phone-screen"))).toContain(
|
||||
"lavacao-047/mobile.webp",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the chapter on screen and counts them", () => {
|
||||
@@ -412,17 +415,48 @@ describe("ProjectReel", () => {
|
||||
it("asks for posters only near the reel, and only for the project on screen and its neighbours", () => {
|
||||
reelTop = 6000;
|
||||
renderReel();
|
||||
const background = (index: number) =>
|
||||
(document.querySelectorAll(".ds-reel-screen")[index] as HTMLElement).style
|
||||
.backgroundImage;
|
||||
expect(background(0)).toBe("");
|
||||
const poster = (index: number) =>
|
||||
posterIn(document.querySelectorAll(".ds-reel-screen")[index]);
|
||||
expect(poster(0)).toBe("");
|
||||
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
||||
|
||||
scrollTo(0); // chegou a menos de uma tela
|
||||
expect(background(0)).toContain("sev-modaintima/desktop.webp");
|
||||
expect(background(1)).toContain("lavacao-047/desktop.webp");
|
||||
expect(background(3)).toBe("");
|
||||
expect(background(4)).toBe("");
|
||||
expect(poster(0)).toContain("sev-modaintima/desktop.webp");
|
||||
expect(poster(1)).toContain("lavacao-047/desktop.webp");
|
||||
expect(poster(3)).toBe("");
|
||||
expect(poster(4)).toBe("");
|
||||
});
|
||||
|
||||
it("downloads each poster once, as an optimized image sized for where it shows, never the 1440 px original twice", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
|
||||
// nenhum pôster vai por imagem de fundo nem pelo atributo do vídeo (cada um era uma requisição do arquivo original)
|
||||
for (const screen of document.querySelectorAll<HTMLElement>(
|
||||
".ds-reel-screen, .ds-phone-screen",
|
||||
))
|
||||
expect(screen.style.backgroundImage).toBe("");
|
||||
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
||||
const posters = [...document.querySelectorAll(".ds-reel img")];
|
||||
// o do projeto em cena e o do vizinho, no notebook e no celular
|
||||
expect(posters).toHaveLength(4);
|
||||
for (const image of posters) {
|
||||
// otimizado (next/image: o arquivo é redimensionado pelo servidor) e com o tamanho em que aparece
|
||||
expect(image.getAttribute("src")).toContain("/_next/image");
|
||||
expect(image.getAttribute("sizes")).toBeTruthy();
|
||||
expect(image).toHaveAttribute("alt", "");
|
||||
}
|
||||
const laptops = posters.filter((image) => image.closest(".ds-reel-screen"));
|
||||
const phones = posters.filter((image) => image.closest(".ds-phone-screen"));
|
||||
expect(laptops).toHaveLength(2);
|
||||
expect(phones).toHaveLength(2);
|
||||
for (const image of laptops)
|
||||
expect(image.getAttribute("sizes")).toContain("58vw");
|
||||
for (const image of phones)
|
||||
expect(image.getAttribute("sizes")).toContain("13vw");
|
||||
// cada pôster é uma URL só (não há dois pedidos do mesmo arquivo)
|
||||
const urls = posters.map((image) => image.getAttribute("src"));
|
||||
expect(new Set(urls).size).toBe(urls.length);
|
||||
});
|
||||
|
||||
it("respects the data saver: no scroll video is downloaded", () => {
|
||||
@@ -437,10 +471,9 @@ describe("ProjectReel", () => {
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
// o pôster continua lá
|
||||
expect(
|
||||
(document.querySelectorAll(".ds-reel-screen")[0] as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("desktop.webp");
|
||||
expect(posterIn(document.querySelectorAll(".ds-reel-screen")[0])).toContain(
|
||||
"desktop.webp",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
|
||||
@@ -790,11 +823,81 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => {
|
||||
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);
|
||||
describe("the caption actions", () => {
|
||||
const tabStopsAll = (root: Element) => [
|
||||
...root.querySelectorAll<HTMLElement>("a[href], button"),
|
||||
];
|
||||
const tabStops = (root: Element) =>
|
||||
tabStopsAll(root).filter(
|
||||
(element) => element.getAttribute("tabindex") !== "-1",
|
||||
);
|
||||
|
||||
it("are ONE tab stop (Ver case, Ampliar vídeo and Abrir site as a toolbar), so a project costs two stops with the index", () => {
|
||||
renderReel();
|
||||
const toolbar = within(caption()).getByRole("toolbar", {
|
||||
name: "Ações de SEV Moda Íntima",
|
||||
});
|
||||
|
||||
expect(tabStopsAll(toolbar)).toHaveLength(3);
|
||||
expect(tabStops(toolbar)).toHaveLength(1);
|
||||
expect(tabStops(toolbar)[0]).toHaveAccessibleName(/Ver case/);
|
||||
// o índice (uma parada) + as ações (uma parada): a vitrine inteira custa duas paradas, não oito
|
||||
const reel = document.querySelector(".ds-reel") as HTMLElement;
|
||||
const stops = tabStops(reel).filter(
|
||||
(element) => !element.closest("[inert]"),
|
||||
);
|
||||
expect(stops).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keep Ampliar vídeo and Abrir site reachable with the arrows, and Enter on Ampliar opens it", () => {
|
||||
renderReel();
|
||||
const toolbar = within(caption()).getByRole("toolbar");
|
||||
act(() => {
|
||||
tabStops(toolbar)[0].focus();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
expect(document.activeElement).toHaveAccessibleName(/Ampliar vídeo/);
|
||||
// a parada do Tab foi para onde o foco está
|
||||
expect(tabStops(toolbar)).toEqual([document.activeElement]);
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "Home" });
|
||||
expect(document.activeElement).toHaveAccessibleName(/Ver case/);
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
fireEvent.click(document.activeElement as Element);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("follow a project without a site: two actions, and the stop never lands on the missing one", () => {
|
||||
renderReel();
|
||||
const toolbar = () => within(caption()).getByRole("toolbar");
|
||||
act(() => {
|
||||
tabStops(toolbar())[0].focus();
|
||||
});
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
||||
|
||||
scrollTo(1); // AS Serviços: sem site
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||
});
|
||||
|
||||
expect(caption()).toHaveTextContent("AS Serviços");
|
||||
expect(tabStopsAll(toolbar())).toHaveLength(2);
|
||||
expect(tabStops(toolbar())).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { PinnedReelLazy } from "./PinnedReelLazy";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import { type ShowcaseItem, useShowcaseItems } from "./ShowcaseProvider";
|
||||
import { usePinnedViewport, useViewportDevice } from "./useShowcaseMedia";
|
||||
import "./reel-list.css";
|
||||
|
||||
/**
|
||||
* O bloco que segura o lugar da vitrine fixa, com a mesma altura dela: a página não muda de tamanho quando ela
|
||||
|
||||
@@ -142,8 +142,18 @@ describe("carregamento sob demanda", () => {
|
||||
// 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"/);
|
||||
// só as linhas de import contam: o comentário do layout pode citar os arquivos pelo nome
|
||||
expect(
|
||||
fs.readFileSync(path.join(__dirname, "../../layout.tsx"), "utf8"),
|
||||
).not.toMatch(/pinned-reel\.css|lightbox\.css|reel-list\.css/);
|
||||
).not.toMatch(
|
||||
/^\s*import\s+["'][^"']*(pinned-reel|lightbox|reel-list)\.css["']/m,
|
||||
);
|
||||
// a lista de projetos e o casco da vitrine (reel-list.css) vêm dentro do global: a home não carrega uma terceira
|
||||
// folha bloqueante, e nenhum componente a importa
|
||||
expect(read("ProjectReel.tsx")).not.toMatch(/reel-list\.css/);
|
||||
expect(read("ProjectList.tsx")).not.toMatch(/reel-list\.css/);
|
||||
expect(
|
||||
fs.readFileSync(path.join(__dirname, "../../globals.css"), "utf8"),
|
||||
).toMatch(/@import "\.\/components\/showcase\/reel-list\.css"/);
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
/* a barra de cookies (fixa na base da janela, na primeira visita) não cobre a legenda e os botões: o palco perde a
|
||||
altura dela (--cookie-bar-height, publicada por GoogleAnalytics enquanto a barra está aberta) */
|
||||
margin-bottom: var(--cookie-bar-height, 0px);
|
||||
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
|
||||
container-type: size;
|
||||
}
|
||||
@@ -286,8 +289,10 @@
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a center top / cover no-repeat;
|
||||
background: #0a0a0a;
|
||||
|
||||
/* o pôster (imagem otimizada) fica embaixo; o vídeo, depois dele no DOM, cobre-o ao carregar */
|
||||
& > img,
|
||||
& > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/*
|
||||
* 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
|
||||
* bloco que segura o lugar da vitrine antes de o navegador montar a página. Vem dentro do global (app/globals.css o
|
||||
* importa), para a home não carregar uma terceira folha bloqueante; nenhum componente a importa. 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
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
height: 20px;
|
||||
margin-inline: auto;
|
||||
padding: 0 9px;
|
||||
font-family: var(--font-mono);
|
||||
/* na barra de endereço do hero (primeira dobra): monoespacada do sistema, sem baixar a Geist Mono na carga */
|
||||
font-family: var(--font-mono-system);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
+45
-25
@@ -1,4 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
/* A lista de projetos e o casco da vitrine (home): no mesmo arquivo que o global, para a home não carregar uma terceira
|
||||
folha bloqueante. A vitrine fixa (pinned-reel.css) e a janela ampliada (lightbox.css) seguem sob demanda. As molduras
|
||||
(showcase.css) vêm de app/layout.tsx, ao lado deste arquivo. */
|
||||
@import "./components/showcase/reel-list.css";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@theme {
|
||||
@@ -60,6 +64,9 @@
|
||||
--fw-card: 600;
|
||||
--font-display: var(--font-space-grotesk), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
/* A monoespacada do sistema, para rótulos que aparecem na carga (primeira dobra da home, mesmo com opacidade 0):
|
||||
a Geist Mono só é baixada quando algum texto a usa, e estes não valem um arquivo a mais na carga. */
|
||||
--font-mono-system: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
--font-body: var(--font-display);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
@@ -265,6 +272,12 @@ textarea {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* Empilhados (celular e telas estreitas), o título e a descrição respiram. Vem antes do layout largo, que a
|
||||
desfaz (margin-top: 0): lá cada um tem a sua coluna e o espaço vem do alinhamento pela base. */
|
||||
.ds-section-title + .ds-section-description {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
/* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */
|
||||
@container (min-width: 880px) {
|
||||
.ds-section-meta {
|
||||
@@ -289,6 +302,7 @@ textarea {
|
||||
> .ds-section-description {
|
||||
grid-area: lead;
|
||||
max-width: 62ch;
|
||||
margin-top: 0;
|
||||
font-size: var(--fs-lead);
|
||||
}
|
||||
|
||||
@@ -792,7 +806,7 @@ ol.ds-list {
|
||||
.hero-caption-title {
|
||||
display: block;
|
||||
color: var(--slash);
|
||||
font: 600 12px / 1.4 var(--font-mono);
|
||||
font: 600 12px / 1.4 var(--font-mono-system);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -808,20 +822,41 @@ ol.ds-list {
|
||||
.ds-main {
|
||||
/* rolagem do mergulho: o progresso (--hero-p) vai de 0 a 1 nela */
|
||||
--hero-dive: 110svh;
|
||||
/* Fonte única da altura do mergulho: telas de hero além do mergulho (sem unidade). O CSS abaixo as usa na altura
|
||||
do hero e na margem de Projetos, e HeroField.tsx lê esta variável para saber onde o progresso (--hero-p)
|
||||
acaba: o mergulho é o resto da altura do hero. */
|
||||
--hero-tail: 1.7;
|
||||
}
|
||||
|
||||
@media (min-height: 700px) and (prefers-reduced-motion: no-preference) {
|
||||
/* O palco (100svh, preso) fica parado durante o mergulho e por mais 70svh: nesse trecho Projetos sobe sobre ele, com
|
||||
margin-top de -70svh. A cena acaba de frente para os dados, a última legenda fica até Projetos cobri-la, e
|
||||
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). */
|
||||
/* O palco (100svh, preso) fica parado durante o mergulho e por mais (--hero-tail - 1) tela: nesse trecho Projetos
|
||||
sobe sobre ele, com a margem negativa dessa altura. A cena acaba de frente para os dados, a última legenda se
|
||||
dissolve num degradê no topo de Projetos conforme ele a cobre, e nunca há uma tela vazia entre o mergulho e a
|
||||
seção seguinte (o progresso acaba quando Projetos começa a subir, --hero-tail telas antes do fim do hero). */
|
||||
.hero[data-dive] {
|
||||
height: calc(170svh + var(--hero-dive));
|
||||
height: calc(var(--hero-tail) * 100svh + var(--hero-dive));
|
||||
|
||||
& + .ds-section {
|
||||
position: relative;
|
||||
margin-top: -70svh;
|
||||
margin-top: calc((1 - var(--hero-tail)) * 100svh);
|
||||
box-shadow: 0 -32px 64px -24px rgba(0, 0, 0, 0.8);
|
||||
|
||||
/* Degradê acima da borda de Projetos: a legenda dos dados fica em cena até ele subir e, em vez de ser cortada
|
||||
pela borda, some dentro do degradê à medida que ela se aproxima. Sem janela sem texto antes disso. */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 100%;
|
||||
height: 22svh;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
var(--ink),
|
||||
rgba(10, 10, 10, 0.85) 22%,
|
||||
rgba(10, 10, 10, 0.45) 55%,
|
||||
rgba(10, 10, 10, 0)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legendas do mergulho: cada uma aparece enquanto a câmera atravessa a sua camada (--hero-p) */
|
||||
@@ -868,24 +903,9 @@ ol.ds-list {
|
||||
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
--from: 0.18;
|
||||
--to: 0.49;
|
||||
}
|
||||
|
||||
& li:nth-child(2) {
|
||||
--from: 0.51;
|
||||
--to: 0.75;
|
||||
}
|
||||
|
||||
& li:nth-child(3) {
|
||||
--from: 0.79;
|
||||
--to: 1.07;
|
||||
}
|
||||
|
||||
/* Os intervalos (--from e --to de cada item) vêm de app/components/hero-captions.ts, junto do texto: lá ficam
|
||||
os trechos em que a câmera enquadra cada camada, com folga, e o teste que garante que não há janela sem
|
||||
legenda entre uma e outra. */
|
||||
& .hero-caption-title {
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -43,6 +43,45 @@ describe("useFocusOnNavigation", () => {
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("with a #hash in the URL (from /privacidade to /#contact)", () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
function mountContact() {
|
||||
const contact = document.createElement("section");
|
||||
contact.id = "contact";
|
||||
contact.tabIndex = -1;
|
||||
document.body.append(contact);
|
||||
return contact;
|
||||
}
|
||||
|
||||
it("leaves the focus to the anchor target when the new page has it", () => {
|
||||
const main = mountMain();
|
||||
const contact = mountContact();
|
||||
const focus = jest.spyOn(main, "focus");
|
||||
const { rerender } = render(<Probe pathname="/privacidade" />);
|
||||
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
contact.focus({ preventScroll: true });
|
||||
rerender(<Probe pathname="/" />);
|
||||
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
expect(contact).toHaveFocus();
|
||||
});
|
||||
|
||||
it("still focuses #main-content when the hash points to nothing on the new page", () => {
|
||||
const main = mountMain();
|
||||
mountContact();
|
||||
const { rerender } = render(<Probe pathname="/privacidade" />);
|
||||
|
||||
window.history.replaceState(null, "", "/#nada");
|
||||
rerender(<Probe pathname="/" />);
|
||||
|
||||
expect(main).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("still tells a navigation from the first load when the component remounts (first segment changed)", () => {
|
||||
const main = mountMain();
|
||||
const first = render(<Probe pathname="/" />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { elementForHash } from "@/app/components/cinematic/fragment-nav";
|
||||
|
||||
/**
|
||||
* Caminho já exibido neste documento. Fica fora do componente porque o template da raiz remonta quando o primeiro
|
||||
@@ -17,12 +18,15 @@ export function resetFocusOnNavigation() {
|
||||
* 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.
|
||||
* Quando a URL traz um `#id` que existe na página nova (de /privacidade para /#contact), o foco é do destino da
|
||||
* âncora (quem chega a ele dá o foco); o contêiner não o toma, para o Tab seguir dali.
|
||||
*/
|
||||
export function useFocusOnNavigation(pathname: string) {
|
||||
useEffect(() => {
|
||||
const previous = shownPath;
|
||||
shownPath = pathname;
|
||||
if (previous === null || previous === pathname) return;
|
||||
if (elementForHash(window.location.hash)) return;
|
||||
document.getElementById("main-content")?.focus({ preventScroll: true });
|
||||
}, [pathname]);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
isLowPowerDevice,
|
||||
isMobileViewport,
|
||||
isSaveData,
|
||||
isSoftwareRenderer,
|
||||
isSoftwareWebGL,
|
||||
MOBILE_QUERY,
|
||||
} from "./device";
|
||||
|
||||
@@ -72,3 +74,51 @@ describe("device hints", () => {
|
||||
expect(isLowPowerDevice()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("software WebGL", () => {
|
||||
it.each([
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
"SWIFTSHADER",
|
||||
])("recognizes the software renderer %s", (name) => {
|
||||
expect(isSoftwareRenderer(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x00002504) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
"ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)",
|
||||
"Mesa Intel(R) UHD Graphics 620 (KBL GT2)",
|
||||
"",
|
||||
])("does not mistake the GPU %s for software", (name) => {
|
||||
expect(isSoftwareRenderer(name)).toBe(false);
|
||||
});
|
||||
|
||||
/** Um contexto WebGL de mentira: a extensão de depuração e o nome do renderizador. */
|
||||
function context(renderer: unknown, hasExtension = true) {
|
||||
return {
|
||||
getExtension: jest.fn((name: string) =>
|
||||
hasExtension && name === "WEBGL_debug_renderer_info"
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
),
|
||||
getParameter: jest.fn((parameter: number) =>
|
||||
parameter === 37446 ? renderer : null,
|
||||
),
|
||||
} as unknown as WebGL2RenderingContext;
|
||||
}
|
||||
|
||||
it("reads the unmasked renderer through WEBGL_debug_renderer_info", () => {
|
||||
expect(isSoftwareWebGL(context("llvmpipe (LLVM 15.0.7, 256 bits)"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSoftwareWebGL(context("ANGLE (NVIDIA GeForce RTX 3060)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not count against a browser that hides the extension or returns no name", () => {
|
||||
expect(isSoftwareWebGL(context("llvmpipe", false))).toBe(false);
|
||||
expect(isSoftwareWebGL(context(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -51,3 +51,24 @@ export function isLowPowerDevice() {
|
||||
(typeof hints.deviceMemory === "number" && hints.deviceMemory <= 4)
|
||||
);
|
||||
}
|
||||
|
||||
/** Nomes que os navegadores dão a um renderizador de WebGL que roda na CPU: SwiftShader (Chrome), llvmpipe (Mesa) e o Microsoft Basic Render Driver. */
|
||||
const SOFTWARE_RENDERER = /swiftshader|llvmpipe|basic render/i;
|
||||
|
||||
/** O nome do renderizador (`UNMASKED_RENDERER_WEBGL`) é de um desenho por software? */
|
||||
export const isSoftwareRenderer = (name: string) =>
|
||||
SOFTWARE_RENDERER.test(name);
|
||||
|
||||
/**
|
||||
* O contexto WebGL desenha na CPU (SwiftShader, llvmpipe, Basic Render)? Abre normalmente, mas a cena 3D sairia a
|
||||
* poucos quadros por segundo. Sem a extensão `WEBGL_debug_renderer_info` (Firefox, Safari) não dá para saber, e o
|
||||
* aparelho não conta contra si.
|
||||
*/
|
||||
export function isSoftwareWebGL(
|
||||
context: WebGLRenderingContext | WebGL2RenderingContext,
|
||||
) {
|
||||
const info = context.getExtension("WEBGL_debug_renderer_info");
|
||||
if (!info) return false;
|
||||
const renderer: unknown = context.getParameter(info.UNMASKED_RENDERER_WEBGL);
|
||||
return typeof renderer === "string" && isSoftwareRenderer(renderer);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
desktopFractionAt,
|
||||
getProjectVideo,
|
||||
getShowcaseAssets,
|
||||
getShowcaseConfig,
|
||||
mobileFractionAt,
|
||||
SHOWCASE,
|
||||
type ShowcaseConfig,
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de lib/project-showcase.ts (complemento de project-showcase.test.ts):
|
||||
* getProjectVideo (id da vitrine x id fora dela x chaves herdadas de Object, nos dois aparelhos) e os trechos
|
||||
* getShowcaseConfig/getShowcaseAssets (id da vitrine x id fora dela x chaves herdadas de Object, nos dois aparelhos; o
|
||||
* antigo getProjectVideo saiu do módulo por não ter mais uso) e os trechos
|
||||
* de chapterAnchors/interpolate/chapterFraction que o arquivo principal não exercita.
|
||||
*/
|
||||
|
||||
@@ -23,17 +24,17 @@ function config(
|
||||
return { id: "fixture", accent: "#fff", durations, weight: 1, chapters };
|
||||
}
|
||||
|
||||
describe("getProjectVideo", () => {
|
||||
describe("getShowcaseConfig e getShowcaseAssets", () => {
|
||||
it.each(SHOWCASE.map(({ id }) => id))(
|
||||
"path: showcase project %s -> assets for the requested device",
|
||||
"path: showcase project %s -> its config and the assets of each device",
|
||||
(id) => {
|
||||
expect(getProjectVideo(id, "desktop")).toEqual(
|
||||
getShowcaseAssets(id, "desktop"),
|
||||
expect(getShowcaseConfig(id)?.id).toBe(id);
|
||||
expect(getShowcaseAssets(id, "desktop").poster).toBe(
|
||||
`/videos/${id}/desktop.webp`,
|
||||
);
|
||||
expect(getProjectVideo(id, "mobile")).toEqual(
|
||||
getShowcaseAssets(id, "mobile"),
|
||||
expect(getShowcaseAssets(id, "mobile").poster).toBe(
|
||||
`/videos/${id}/mobile.webp`,
|
||||
);
|
||||
expect(getProjectVideo(id)).toEqual(getShowcaseAssets(id, "desktop"));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -44,22 +45,22 @@ describe("getProjectVideo", () => {
|
||||
"__proto__",
|
||||
"toString",
|
||||
"SEV-MODAINTIMA",
|
||||
])(
|
||||
"path: id %j is not a showcase project -> undefined for both devices",
|
||||
(id) => {
|
||||
expect(getProjectVideo(id)).toBeUndefined();
|
||||
expect(getProjectVideo(id, "mobile")).toBeUndefined();
|
||||
},
|
||||
);
|
||||
])("path: id %j is not a showcase project -> no config", (id) => {
|
||||
expect(getShowcaseConfig(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("path: the two devices differ in size and in poster, not in the project folder", () => {
|
||||
const desktop = getProjectVideo("nutriscan", "desktop");
|
||||
const mobile = getProjectVideo("nutriscan", "mobile");
|
||||
const desktop = getShowcaseAssets("nutriscan", "desktop");
|
||||
const mobile = getShowcaseAssets("nutriscan", "mobile");
|
||||
|
||||
expect(desktop).toMatchObject({ width: 1440, height: 900 });
|
||||
expect(mobile).toMatchObject({ width: 390, height: 844 });
|
||||
expect(desktop?.poster).toBe("/videos/nutriscan/desktop.webp");
|
||||
expect(mobile?.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||
expect(desktop.poster).toBe("/videos/nutriscan/desktop.webp");
|
||||
expect(mobile.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||
expect(desktop.sources.map(({ src }) => src)).toEqual([
|
||||
"/videos/nutriscan/desktop.webm",
|
||||
"/videos/nutriscan/desktop.mp4",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
chapterIndexAt,
|
||||
desktopFractionAt,
|
||||
getOgImagePath,
|
||||
getProjectVideo,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
MAX_CHAPTERS,
|
||||
@@ -99,21 +98,13 @@ describe("showcase chapters", () => {
|
||||
});
|
||||
|
||||
describe("showcase assets", () => {
|
||||
it("returns undefined for projects without a recorded video", () => {
|
||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||
expect(getProjectVideo("reddit-explorer", "mobile")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the version for the device, desktop by default", () => {
|
||||
expect(getProjectVideo("nutriscan")?.poster).toBe(
|
||||
it("picks the version for the device", () => {
|
||||
expect(getShowcaseAssets("nutriscan", "desktop").poster).toBe(
|
||||
"/videos/nutriscan/desktop.webp",
|
||||
);
|
||||
expect(getProjectVideo("nutriscan", "desktop")).toEqual(
|
||||
getShowcaseAssets("nutriscan", "desktop"),
|
||||
);
|
||||
const mobile = getProjectVideo("nutriscan", "mobile");
|
||||
expect(mobile?.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||
expect(mobile?.sources.map((source) => source.src)).toEqual([
|
||||
const mobile = getShowcaseAssets("nutriscan", "mobile");
|
||||
expect(mobile.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||
expect(mobile.sources.map((source) => source.src)).toEqual([
|
||||
"/videos/nutriscan/mobile.webm",
|
||||
"/videos/nutriscan/mobile.mp4",
|
||||
]);
|
||||
|
||||
+3
-11
@@ -77,12 +77,12 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
{
|
||||
id: "sc-plus",
|
||||
accent: "#22c55e",
|
||||
durations: { desktop: 10.4, mobile: 16.4 },
|
||||
durations: { desktop: 14.7, mobile: 22.4 },
|
||||
weight: 0.8,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Áreas e projetos", desktop: 3.4, mobile: 5.6 },
|
||||
{ label: "Contato", desktop: 8.4, mobile: 12.4 },
|
||||
{ label: "Áreas e projetos", desktop: 5.8, mobile: 7.6 },
|
||||
{ label: "Contato", desktop: 12.6, mobile: 19.4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -137,14 +137,6 @@ export function getOgImagePath(id: string) {
|
||||
return `/videos/${id}/og.jpg`;
|
||||
}
|
||||
|
||||
/** Arquivos de um projeto da vitrine para o aparelho (desktop por padrão): é o que as capas dos cards usam. */
|
||||
export function getProjectVideo(
|
||||
id: string,
|
||||
device: Device = "desktop",
|
||||
): ShowcaseAssets | undefined {
|
||||
return byId.has(id) ? getShowcaseAssets(id, device) : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// Capítulos e sincronia entre as duas telas
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 60 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 68 KiB |
Binary file not shown.
+278
-205
@@ -18,6 +18,9 @@
|
||||
* --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),
|
||||
* --color-scheme dark (esquema de cores que o site recebe, `light` ou `dark`: um site que muda de tema por
|
||||
* prefers-color-scheme, mas escreve texto branco sobre um fundo que só escurece no tema escuro, sai com seções
|
||||
* "vazias" no tema claro; sem a opção o navegador usa o padrão dele),
|
||||
* --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
|
||||
@@ -75,6 +78,7 @@ 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 COLOR_SCHEME = args["color-scheme"];
|
||||
|
||||
const VIEWPORTS = {
|
||||
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
|
||||
@@ -99,37 +103,39 @@ function ff(argv) {
|
||||
|
||||
async function dismissPopups(page) {
|
||||
return page.evaluate(() => {
|
||||
const done = [];
|
||||
const roots = document.querySelectorAll(
|
||||
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]',
|
||||
);
|
||||
for (const root of roots) {
|
||||
const SELECTOR =
|
||||
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]';
|
||||
const text = (b) =>
|
||||
(b.innerText || b.getAttribute("aria-label") || "").trim();
|
||||
// visível, com tamanho de aviso e fixo na janela (cookies, newsletter, pop-ups)
|
||||
const isPopup = (root) => {
|
||||
const rect = root.getBoundingClientRect();
|
||||
const style = getComputedStyle(root);
|
||||
if (rect.width < 80 || rect.height < 24) continue;
|
||||
if (
|
||||
const hidden =
|
||||
style.visibility === "hidden" ||
|
||||
style.display === "none" ||
|
||||
Number(style.opacity) === 0
|
||||
)
|
||||
continue;
|
||||
if (!["fixed", "sticky"].includes(style.position)) continue;
|
||||
Number(style.opacity) === 0;
|
||||
const small = rect.width < 80 || rect.height < 24;
|
||||
return !small && !hidden && ["fixed", "sticky"].includes(style.position);
|
||||
};
|
||||
// recusar primeiro; senão, qualquer botão que feche o aviso
|
||||
const pickButton = (root) => {
|
||||
const buttons = [
|
||||
...root.querySelectorAll('button,a[role="button"],[role="button"]'),
|
||||
];
|
||||
const text = (b) =>
|
||||
(b.innerText || b.getAttribute("aria-label") || "").trim();
|
||||
const pick =
|
||||
buttons.find((b) =>
|
||||
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i.test(
|
||||
text(b),
|
||||
),
|
||||
) ||
|
||||
buttons.find((b) =>
|
||||
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i.test(
|
||||
text(b),
|
||||
),
|
||||
);
|
||||
const refuse =
|
||||
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i;
|
||||
const close =
|
||||
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i;
|
||||
return (
|
||||
buttons.find((b) => refuse.test(text(b))) ||
|
||||
buttons.find((b) => close.test(text(b)))
|
||||
);
|
||||
};
|
||||
const done = [];
|
||||
for (const root of document.querySelectorAll(SELECTOR)) {
|
||||
if (!isPopup(root)) continue;
|
||||
const pick = pickButton(root);
|
||||
if (pick) {
|
||||
done.push(text(pick));
|
||||
pick.click();
|
||||
@@ -365,12 +371,8 @@ async function measureBlank(page) {
|
||||
return { step, maxScroll, ratios, factors };
|
||||
}
|
||||
|
||||
async function recordOne(browser, vpName) {
|
||||
const vp = VIEWPORTS[vpName];
|
||||
const rawDir = join(rawRoot, vpName);
|
||||
rmSync(rawDir, { recursive: true, force: true });
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
|
||||
/** O contexto do Chromium que grava o vídeo, com a página aberta e os erros do console recolhidos em `logs`. */
|
||||
async function openRecording(browser, vp, rawDir) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 1,
|
||||
@@ -380,6 +382,7 @@ async function recordOne(browser, vpName) {
|
||||
locale: "pt-BR",
|
||||
timezoneId: "America/Sao_Paulo",
|
||||
reducedMotion: "no-preference",
|
||||
colorScheme: COLOR_SCHEME,
|
||||
recordVideo: { dir: rawDir, size: { width: vp.width, height: vp.height } },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
@@ -400,40 +403,39 @@ async function recordOne(browser, vpName) {
|
||||
m.type() === "error" &&
|
||||
logs.push(`console.error: ${m.text().slice(0, 160)}`),
|
||||
);
|
||||
const tStart = Date.now();
|
||||
return { context, page, logs };
|
||||
}
|
||||
|
||||
/** Abre a página e espera a rede ociosa, as fontes e os pré-carregadores e animações iniciais. */
|
||||
async function openPage(page) {
|
||||
const response = await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 20000 })
|
||||
.catch(() => {});
|
||||
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
||||
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
|
||||
const popups1 = await dismissPopups(page);
|
||||
await sleep(SETTLE_MS);
|
||||
return response;
|
||||
}
|
||||
|
||||
// passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
|
||||
// ociosa; depois volta ao topo (essa parte é cortada do vídeo)
|
||||
await preloadPage(page);
|
||||
await waitImages(page);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await sleep(600);
|
||||
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(() => {
|
||||
/** Esconde as seções (h1 a h3) cujo título tem `text`; devolve os títulos escondidos. */
|
||||
const hideSections = (page, text) =>
|
||||
page.evaluate((needle) => {
|
||||
const hidden = [];
|
||||
for (const heading of document.querySelectorAll("h1, h2, h3")) {
|
||||
const section = heading.closest("section") ?? heading.parentElement;
|
||||
if (!heading.textContent.includes(needle) || !section) continue;
|
||||
section.style.display = "none";
|
||||
hidden.push(heading.textContent.trim());
|
||||
}
|
||||
return hidden;
|
||||
}, text);
|
||||
|
||||
/**
|
||||
* Esconde as imagens que não carregam de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo): aparecem como
|
||||
* ícone de imagem quebrada com o texto alternativo e não vão para o vídeo. Devolve as URLs.
|
||||
*/
|
||||
const hideBrokenImages = (page) =>
|
||||
page.evaluate(() => {
|
||||
const broken = [...document.images].filter(
|
||||
(img) => img.complete && img.naturalWidth === 0 && img.currentSrc,
|
||||
);
|
||||
@@ -441,21 +443,30 @@ async function recordOne(browser, vpName) {
|
||||
return broken.map((img) => img.currentSrc);
|
||||
});
|
||||
|
||||
const blank = await measureBlank(page);
|
||||
/**
|
||||
* Passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
|
||||
* ociosa, fecha avisos e esconde o que não deve aparecer; depois volta ao topo (essa parte é cortada do vídeo).
|
||||
*/
|
||||
async function warmUp(page) {
|
||||
const popups1 = await dismissPopups(page);
|
||||
await preloadPage(page);
|
||||
await waitImages(page);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
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 popups2 = await dismissPopups(page);
|
||||
const images = await imageStatus(page);
|
||||
const hiddenSections = HIDE_TEXT ? await hideSections(page, HIDE_TEXT) : [];
|
||||
const hiddenBrokenImages = await hideBrokenImages(page);
|
||||
return {
|
||||
popupsClosed: [...popups1, ...popups2],
|
||||
images,
|
||||
hiddenSections,
|
||||
hiddenBrokenImages,
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = await page.evaluate(() => ({
|
||||
const pageMetrics = (page) =>
|
||||
page.evaluate(() => ({
|
||||
scrollY: window.scrollY,
|
||||
height: document.documentElement.scrollHeight,
|
||||
view: window.innerHeight,
|
||||
@@ -463,41 +474,43 @@ async function recordOne(browser, vpName) {
|
||||
hasHorizontalOverflow:
|
||||
document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
}));
|
||||
// tempo de rolagem a 1 px/s: cada trecho custa passo / fator; a velocidade base cabe o total em MAX_SECONDS - 2
|
||||
|
||||
/** Velocidade da rolagem (px/s): o tempo de cada trecho custa passo / fator, e o total cabe em MAX_SECONDS - 2. */
|
||||
function scrollPlan(blank) {
|
||||
const unitSeconds = blank.factors.reduce(
|
||||
(sum, factor) => sum + blank.step / factor,
|
||||
0,
|
||||
);
|
||||
const speed = Math.max(SPEED, unitSeconds / (MAX_SECONDS - 2));
|
||||
const expectedScrollSeconds = unitSeconds / speed;
|
||||
const warm = metrics.height;
|
||||
return { speed, expectedScrollSeconds: unitSeconds / speed };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
/** Com `--ready-text`: só segue quando o primeiro <h1> acaba de ganhar esse texto (o começo do slide). */
|
||||
async function waitReadyText(page, logs) {
|
||||
if (!READY_TEXT) return;
|
||||
// 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
|
||||
await flash(page, "rgb(255,0,255)");
|
||||
const t0 = Date.now() - tStart;
|
||||
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
|
||||
const result = await page.evaluate(
|
||||
/** Rola a página até o fim, guiada por tempo, na velocidade de cada trecho; devolve onde e em quanto tempo acabou. */
|
||||
const scrollThrough = (page, plan) =>
|
||||
page.evaluate(
|
||||
({ speedPxS, step, factors }) =>
|
||||
new Promise((resolve) => {
|
||||
// fator de velocidade na posição y: interpolado entre as medidas de cada `step` px
|
||||
@@ -525,27 +538,36 @@ async function recordOne(browser, vpName) {
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}),
|
||||
{ speedPxS: speed, step: blank.step, factors: blank.factors },
|
||||
plan,
|
||||
);
|
||||
|
||||
/** A rolagem gravada entre os marcadores magenta (início) e ciano (fim); devolve a rolagem e o tempo de relógio. */
|
||||
async function recordScroll(page, blank, speed) {
|
||||
await flash(page, "rgb(255,0,255)");
|
||||
const startedAt = Date.now();
|
||||
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
|
||||
const result = await scrollThrough(page, {
|
||||
speedPxS: speed,
|
||||
step: blank.step,
|
||||
factors: blank.factors,
|
||||
});
|
||||
await sleep(1000);
|
||||
const t1 = Date.now() - tStart;
|
||||
const wallSeconds = (Date.now() - startedAt) / 1000;
|
||||
await flash(page, "rgb(0,255,255)");
|
||||
await context.close();
|
||||
return { result, wallSeconds };
|
||||
}
|
||||
|
||||
/** Corta o vídeo cru entre os marcadores (intermediário de alta qualidade, só para os cortes); devolve o arquivo e a duração. */
|
||||
function trimRecording(rawDir) {
|
||||
const rawFile = readdirSync(rawDir).find((f) => f.endsWith(".webm"));
|
||||
const rawPath = join(rawDir, rawFile);
|
||||
const marks = findMarkers(rawPath);
|
||||
const startAt = marks.scrollStart.toFixed(3);
|
||||
const marks = findMarkers(join(rawDir, rawFile));
|
||||
const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3);
|
||||
const wallSeconds = (t1 - t0) / 1000;
|
||||
|
||||
const trimmed = join(rawDir, "trimmed.mp4");
|
||||
// intermediário de alta qualidade, só para os cortes
|
||||
ff([
|
||||
"-ss",
|
||||
startAt,
|
||||
marks.scrollStart.toFixed(3),
|
||||
"-i",
|
||||
rawPath,
|
||||
join(rawDir, rawFile),
|
||||
"-t",
|
||||
dur,
|
||||
"-an",
|
||||
@@ -561,68 +583,70 @@ async function recordOne(browser, vpName) {
|
||||
"yuv420p",
|
||||
trimmed,
|
||||
]);
|
||||
return { trimmed, dur };
|
||||
}
|
||||
|
||||
const sizes = {};
|
||||
const encode = (kind, scale) => {
|
||||
const out = join(outDir, `${vpName}.${kind}`);
|
||||
const vf = scale
|
||||
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
|
||||
: [];
|
||||
const crfs = kind === "mp4" ? [26, 30, 34, 38] : [33, 37, 41, 46];
|
||||
for (const crf of crfs) {
|
||||
if (kind === "mp4") {
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
...vf,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
out,
|
||||
]);
|
||||
} else {
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
...vf,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
"2",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
out,
|
||||
]);
|
||||
}
|
||||
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
|
||||
if (sizes[kind].bytes <= MAX_BYTES) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const kind of ["mp4", "webm"]) {
|
||||
if (!encode(kind, null)) {
|
||||
for (const scale of [0.85, 0.7]) if (encode(kind, scale)) break;
|
||||
}
|
||||
const ENCODER_ARGS = {
|
||||
mp4: (crf) => [
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
],
|
||||
webm: (crf) => [
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
"2",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
],
|
||||
};
|
||||
const ENCODER_CRFS = { mp4: [26, 30, 34, 38], webm: [33, 37, 41, 46] };
|
||||
|
||||
/** Codifica `kind` com qualidade cada vez menor até caber em MAX_BYTES; devolve se coube e guarda o último em `sizes`. */
|
||||
function encodeKind(trimmed, out, kind, scale, sizes) {
|
||||
const vf = scale
|
||||
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
|
||||
: [];
|
||||
for (const crf of ENCODER_CRFS[kind]) {
|
||||
ff(["-i", trimmed, ...vf, ...ENCODER_ARGS[kind](crf), out]);
|
||||
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
|
||||
if (sizes[kind].bytes <= MAX_BYTES) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** MP4 e WebM de até MAX_BYTES cada: se a qualidade mínima não basta, reduz a escala. */
|
||||
function encodeVideos(trimmed, vpName) {
|
||||
const sizes = {};
|
||||
for (const kind of ["mp4", "webm"]) {
|
||||
const out = join(outDir, `${vpName}.${kind}`);
|
||||
if (!encodeKind(trimmed, out, kind, null, sizes))
|
||||
[0.85, 0.7].some((scale) => encodeKind(trimmed, out, kind, scale, sizes));
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function makePoster(vpName) {
|
||||
ff([
|
||||
"-ss",
|
||||
String(POSTER_AT),
|
||||
@@ -636,69 +660,118 @@ async function recordOne(browser, vpName) {
|
||||
"80",
|
||||
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++) {
|
||||
/** O quadro do MP4 pronto no segundo `at`, como PNG. */
|
||||
const frameAt = (vpName, at) =>
|
||||
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;
|
||||
|
||||
/** 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco. */
|
||||
function sampleFrames(vpName, dur) {
|
||||
return Array.from({ length: 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({
|
||||
const { ratio } = inkRatio(frameAt(vpName, at));
|
||||
return {
|
||||
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). */
|
||||
function contactSheet(trimmed, vpName, dur) {
|
||||
const mobile = vpName === "mobile";
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
"-vf",
|
||||
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${vpName === "mobile" ? 195 : 480}:-1,tile=${vpName === "mobile" ? 8 : 4}x2`,
|
||||
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${mobile ? 195 : 480}:-1,tile=${mobile ? 8 : 4}x2`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
join(rawRoot, `${vpName}-contact.png`),
|
||||
]);
|
||||
}
|
||||
|
||||
/** O que `--dry` devolve: só a medição dos trechos em branco. */
|
||||
const dryReport = (vpName, warm, blank) => ({
|
||||
vp: vpName,
|
||||
images: warm.images,
|
||||
hiddenBrokenImages: warm.hiddenBrokenImages,
|
||||
step: blank.step,
|
||||
inkRatios: blank.ratios.map((r) => +r.toFixed(3)),
|
||||
factors: blank.factors.map((f) => +f.toFixed(1)),
|
||||
});
|
||||
|
||||
async function recordOne(browser, vpName) {
|
||||
const rawDir = join(rawRoot, vpName);
|
||||
rmSync(rawDir, { recursive: true, force: true });
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
|
||||
const { context, page, logs } = await openRecording(
|
||||
browser,
|
||||
VIEWPORTS[vpName],
|
||||
rawDir,
|
||||
);
|
||||
const response = await openPage(page);
|
||||
const warm = await warmUp(page);
|
||||
const blank = await measureBlank(page);
|
||||
await sleep(600);
|
||||
if (args.dry === "1") {
|
||||
await context.close();
|
||||
return dryReport(vpName, warm, blank);
|
||||
}
|
||||
|
||||
const metrics = await pageMetrics(page);
|
||||
const plan = scrollPlan(blank);
|
||||
await waitReadyText(page, logs);
|
||||
const { result, wallSeconds } = await recordScroll(page, blank, plan.speed);
|
||||
await context.close();
|
||||
|
||||
const { trimmed, dur } = trimRecording(rawDir);
|
||||
const sizes = encodeVideos(trimmed, vpName);
|
||||
makePoster(vpName);
|
||||
const frames = sampleFrames(vpName, dur);
|
||||
contactSheet(trimmed, vpName, dur);
|
||||
|
||||
return {
|
||||
vp: vpName,
|
||||
status: response?.status(),
|
||||
title: metrics.title,
|
||||
pageHeight: metrics.height,
|
||||
warmHeight: warm,
|
||||
speedPxS: Math.round(speed),
|
||||
warmHeight: metrics.height,
|
||||
speedPxS: Math.round(plan.speed),
|
||||
scrollSeconds: +(result.ms / 1000).toFixed(1),
|
||||
expectedScrollSeconds: +expectedScrollSeconds.toFixed(1),
|
||||
expectedScrollSeconds: +plan.expectedScrollSeconds.toFixed(1),
|
||||
endY: Math.round(result.endY),
|
||||
maxNow: Math.round(result.maxNow),
|
||||
durationS: +dur,
|
||||
wallSeconds: +wallSeconds.toFixed(1),
|
||||
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
|
||||
popupsClosed: [...popups1, ...popups2],
|
||||
popupsClosed: warm.popupsClosed,
|
||||
horizontalOverflow: metrics.hasHorizontalOverflow,
|
||||
images,
|
||||
hiddenBrokenImages,
|
||||
hiddenSections,
|
||||
images: warm.images,
|
||||
hiddenBrokenImages: warm.hiddenBrokenImages,
|
||||
hiddenSections: warm.hiddenSections,
|
||||
frames,
|
||||
sizesKB: Object.fromEntries(
|
||||
Object.entries(sizes).map(([k, v]) => [
|
||||
|
||||
+220
-109
@@ -180,19 +180,26 @@ const checks = []; // {scope, id, group, ok, detail}
|
||||
const add = (scope, group, id, ok, detail = "") =>
|
||||
checks.push({ scope, group, id, ok: Boolean(ok), detail });
|
||||
|
||||
async function auditUrl(url, siteTitles, siteDescs) {
|
||||
const target = local(url);
|
||||
const { res, body, ms } = await get(target);
|
||||
const scope = url.replace(SITE, "") || "/";
|
||||
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
|
||||
if (res.status !== 200) return null;
|
||||
/** Chaves de Open Graph que toda página precisa ter. */
|
||||
const OG_KEYS = [
|
||||
"og:title",
|
||||
"og:description",
|
||||
"og:url",
|
||||
"og:image",
|
||||
"og:type",
|
||||
"og:site_name",
|
||||
"og:locale",
|
||||
];
|
||||
|
||||
/** Compressão do HTML e tempo até o primeiro byte. */
|
||||
function checkTechnical(scope, res, ms) {
|
||||
const encoding = res.headers.get("content-encoding");
|
||||
add(
|
||||
scope,
|
||||
"técnico",
|
||||
"html-comprimido",
|
||||
/br|gzip/.test(res.headers.get("content-encoding") ?? "") ||
|
||||
BASE.startsWith("http://localhost"),
|
||||
res.headers.get("content-encoding") ?? "sem compressão",
|
||||
/br|gzip/.test(encoding ?? "") || BASE.startsWith("http://localhost"),
|
||||
encoding ?? "sem compressão",
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
@@ -201,8 +208,10 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
ms < 800,
|
||||
`${ms} ms (HTML completo)`,
|
||||
);
|
||||
const p = parsePage(body);
|
||||
}
|
||||
|
||||
/** Idioma, viewport e título (tamanho e unicidade no site). */
|
||||
function checkHead(scope, p, siteTitles) {
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
@@ -220,6 +229,10 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
);
|
||||
add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title);
|
||||
siteTitles.add(p.title);
|
||||
}
|
||||
|
||||
/** Descrição (tamanho e unicidade), canônica própria e indexabilidade. */
|
||||
function checkSnippet(scope, url, res, p, siteDescs) {
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
@@ -243,21 +256,69 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
p.canonical === url || p.canonical === `${url}/`,
|
||||
p.canonical ?? "ausente",
|
||||
);
|
||||
const noindex = (value) => /noindex/i.test(value ?? "");
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"indexavel",
|
||||
!/noindex/i.test(p.robots ?? "") &&
|
||||
!/noindex/i.test(res.headers.get("x-robots-tag") ?? ""),
|
||||
!noindex(p.robots) && !noindex(res.headers.get("x-robots-tag")),
|
||||
);
|
||||
}
|
||||
|
||||
/** A hierarquia de títulos pula algum nível (de h1 direto para h3, por exemplo)? */
|
||||
function skipsHeadingLevel(headings) {
|
||||
let previous = 0;
|
||||
for (const h of headings) {
|
||||
if (h.level - previous > 1) return true;
|
||||
previous = h.level;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Links internos (mesmo site, sem protocolo relativo) de uma lista de âncoras. */
|
||||
const isInternalLink = (a) =>
|
||||
a.href &&
|
||||
(a.href.startsWith("/") || a.href.startsWith(SITE)) &&
|
||||
!a.href.startsWith("//");
|
||||
|
||||
/** Links internos suficientes e links externos que abrem em outra aba com noopener. */
|
||||
function checkLinks(scope, p) {
|
||||
const internal = p.anchors.filter(isInternalLink);
|
||||
const destinations = new Set(internal.map((a) => a.href.split("#")[0])).size;
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"links-internos-5+",
|
||||
destinations >= 5,
|
||||
`${destinations} destinos`,
|
||||
);
|
||||
const external = p.anchors.filter((a) => a.target === "_blank");
|
||||
// noreferrer também implica noopener
|
||||
const unsafe = external.filter(
|
||||
(a) => !/noopener|noreferrer/.test(a.rel ?? ""),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"externos-com-noopener",
|
||||
unsafe.length === 0,
|
||||
`${external.length} links _blank; sem rel: ${unsafe
|
||||
.map((a) => a.href)
|
||||
.slice(0, 2)
|
||||
.join(" ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Títulos (um h1, hierarquia), imagens com alt e links. */
|
||||
function checkStructure(scope, p) {
|
||||
const h1 = p.headings.filter((h) => h.level === 1);
|
||||
add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`);
|
||||
let skipped = false;
|
||||
p.headings.reduce((prev, h) => {
|
||||
if (h.level - prev > 1) skipped = true;
|
||||
return h.level;
|
||||
}, 0);
|
||||
add(scope, "on-page", "hierarquia-de-titulos", !skipped);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"hierarquia-de-titulos",
|
||||
!skipsHeadingLevel(p.headings),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
@@ -265,44 +326,25 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
p.imgs.every((i) => i.alt !== undefined),
|
||||
`${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`,
|
||||
);
|
||||
const internal = p.anchors.filter(
|
||||
(a) =>
|
||||
a.href &&
|
||||
(a.href.startsWith("/") || a.href.startsWith(SITE)) &&
|
||||
!a.href.startsWith("//"),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"links-internos-5+",
|
||||
new Set(internal.map((a) => a.href.split("#")[0])).size >= 5,
|
||||
`${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`,
|
||||
);
|
||||
const external = p.anchors.filter((a) => a.target === "_blank");
|
||||
// noreferrer também implica noopener
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"externos-com-noopener",
|
||||
external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")),
|
||||
`${external.length} links _blank; sem rel: ${external
|
||||
.filter((a) => !/noopener|noreferrer/.test(a.rel ?? ""))
|
||||
.map((a) => a.href)
|
||||
.slice(0, 2)
|
||||
.join(" ")}`,
|
||||
);
|
||||
checkLinks(scope, p);
|
||||
}
|
||||
|
||||
const og = p.og,
|
||||
tw = p.tw;
|
||||
const missingOg = [
|
||||
"og:title",
|
||||
"og:description",
|
||||
"og:url",
|
||||
"og:image",
|
||||
"og:type",
|
||||
"og:site_name",
|
||||
"og:locale",
|
||||
].filter((k) => !og[k]);
|
||||
/** A imagem de compartilhamento responde 200 e é uma imagem. */
|
||||
async function checkOgImage(scope, src) {
|
||||
const img = await get(local(src), UA, { method: "GET" }).catch(() => null);
|
||||
add(
|
||||
scope,
|
||||
"social",
|
||||
"og-image-acessivel",
|
||||
img?.res.status === 200 &&
|
||||
/^image\//.test(img.res.headers.get("content-type") ?? ""),
|
||||
`${img?.res.status} ${img?.res.headers.get("content-type")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open Graph completo, cartão do Twitter e a imagem de compartilhamento. */
|
||||
async function checkSocial(scope, p) {
|
||||
const missingOg = OG_KEYS.filter((k) => !p.og[k]);
|
||||
add(
|
||||
scope,
|
||||
"social",
|
||||
@@ -314,24 +356,15 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
scope,
|
||||
"social",
|
||||
"twitter-card",
|
||||
tw["twitter:card"] === "summary_large_image" &&
|
||||
!!tw["twitter:title"] &&
|
||||
!!tw["twitter:image"],
|
||||
p.tw["twitter:card"] === "summary_large_image" &&
|
||||
!!p.tw["twitter:title"] &&
|
||||
!!p.tw["twitter:image"],
|
||||
);
|
||||
if (og["og:image"]) {
|
||||
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(
|
||||
() => null,
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"social",
|
||||
"og-image-acessivel",
|
||||
img?.res.status === 200 &&
|
||||
/^image\//.test(img.res.headers.get("content-type") ?? ""),
|
||||
`${img?.res.status} ${img?.res.headers.get("content-type")}`,
|
||||
);
|
||||
}
|
||||
if (p.og["og:image"]) await checkOgImage(scope, p.og["og:image"]);
|
||||
}
|
||||
|
||||
/** JSON-LD válido, referências que resolvem, tipos proibidos, breadcrumb e Person com sameAs. Devolve os fatos. */
|
||||
function checkStructuredData(scope, p) {
|
||||
const ld = jsonLdFacts(p.ld);
|
||||
add(
|
||||
scope,
|
||||
@@ -373,7 +406,11 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
n.sameAs.length >= 2,
|
||||
),
|
||||
);
|
||||
return ld;
|
||||
}
|
||||
|
||||
/** Conteúdo no HTML sem JavaScript, resposta direta cedo e autor consistente. */
|
||||
function checkAeo(scope, p, ld) {
|
||||
add(
|
||||
scope,
|
||||
"aeo-geo",
|
||||
@@ -398,10 +435,26 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
names.size <= 1 && (scope === "/" ? names.size === 1 : true),
|
||||
[...names].join(","),
|
||||
);
|
||||
}
|
||||
|
||||
async function auditUrl(url, siteTitles, siteDescs) {
|
||||
const { res, body, ms } = await get(local(url));
|
||||
const scope = url.replace(SITE, "") || "/";
|
||||
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
|
||||
if (res.status !== 200) return null;
|
||||
checkTechnical(scope, res, ms);
|
||||
const p = parsePage(body);
|
||||
checkHead(scope, p, siteTitles);
|
||||
checkSnippet(scope, url, res, p, siteDescs);
|
||||
checkStructure(scope, p);
|
||||
await checkSocial(scope, p);
|
||||
const ld = checkStructuredData(scope, p);
|
||||
checkAeo(scope, p, ld);
|
||||
return { scope, p, ld };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
/** Sitemap e robots.txt do site; devolve as URLs do sitemap. */
|
||||
async function checkSiteFiles() {
|
||||
const sm = await get(`${BASE}/sitemap.xml`);
|
||||
const urls = [...sm.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
|
||||
add(
|
||||
@@ -426,7 +479,11 @@ async function main() {
|
||||
!/disallow:\s*\/\s*$/im.test(robots.body),
|
||||
);
|
||||
add("site", "técnico", "http-para-https-ou-local", true);
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** Acesso dos robôs de busca e de IA, e-mail visível para robôs e a política do llms.txt. */
|
||||
async function checkBots() {
|
||||
for (const [name, ua] of AI_BOTS) {
|
||||
const r = await get(`${BASE}/`, ua);
|
||||
add(
|
||||
@@ -457,44 +514,63 @@ async function main() {
|
||||
llms.res.status === 404,
|
||||
`HTTP ${llms.res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Audita cada URL do sitemap, uma depois da outra; páginas que não respondem 200 ficam como `null`. */
|
||||
async function auditPages(urls) {
|
||||
const titles = new Set();
|
||||
const descs = new Set();
|
||||
const pages = [];
|
||||
for (const url of urls) pages.push(await auditUrl(url, titles, descs));
|
||||
return pages;
|
||||
}
|
||||
|
||||
// links internos quebrados (amostra: todos os destinos únicos das páginas auditadas)
|
||||
/** Destinos internos únicos (caminhos) de todas as páginas auditadas. */
|
||||
function internalTargets(pages) {
|
||||
const targets = new Set();
|
||||
for (const pg of pages.filter(Boolean))
|
||||
for (const a of pg.p.anchors) {
|
||||
const h = a.href?.split("#")[0];
|
||||
if (h?.startsWith("/") && !h.startsWith("//")) targets.add(h);
|
||||
}
|
||||
let broken = 0;
|
||||
const brokenList = [];
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Destinos que respondem erro (ou não respondem), com o status. */
|
||||
async function findBroken(targets) {
|
||||
const broken = [];
|
||||
for (const t of targets) {
|
||||
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!r || r.res.status >= 400) {
|
||||
broken++;
|
||||
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
|
||||
}
|
||||
if (!r || r.res.status >= 400)
|
||||
broken.push(`${t} (${r?.res.status ?? "erro"})`);
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
/** Páginas do sitemap (fora a home) que nenhuma página audita linka. */
|
||||
function findOrphans(urls, targets) {
|
||||
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
|
||||
const linked = [...targets].map((t) => t.replace(/\/$/, ""));
|
||||
return [...inSitemap].filter((u) => {
|
||||
const path = u.replace(SITE, "") || "/";
|
||||
return path !== "/" && !linked.includes(path);
|
||||
});
|
||||
}
|
||||
|
||||
/** Links internos quebrados (amostra: todos os destinos únicos das páginas auditadas) e páginas órfãs. */
|
||||
async function checkInternalLinks(urls, pages) {
|
||||
const targets = internalTargets(pages);
|
||||
const brokenList = await findBroken(targets);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"sem-links-internos-quebrados",
|
||||
broken === 0,
|
||||
`${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`,
|
||||
brokenList.length === 0,
|
||||
`${brokenList.length}/${targets.size} quebrados: ${brokenList.join(", ")}`,
|
||||
);
|
||||
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
|
||||
const orphanCandidates = [...inSitemap].filter((u) => {
|
||||
const path = u.replace(SITE, "") || "/";
|
||||
return (
|
||||
path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path)
|
||||
);
|
||||
});
|
||||
const orphanCandidates = findOrphans(urls, targets);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
@@ -502,21 +578,35 @@ async function main() {
|
||||
orphanCandidates.length === 0,
|
||||
orphanCandidates.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
// links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado)
|
||||
const ghLinks = new Set();
|
||||
/** Links únicos para os repositórios do autor encontrados nas páginas auditadas. */
|
||||
function githubLinks(pages) {
|
||||
const links = new Set();
|
||||
for (const pg of pages.filter(Boolean))
|
||||
for (const a of pg.p.anchors)
|
||||
if (
|
||||
/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")
|
||||
)
|
||||
ghLinks.add(a.href.replace(/\/$/, ""));
|
||||
const ghBroken = [];
|
||||
for (const url of ghLinks) {
|
||||
links.add(a.href.replace(/\/$/, ""));
|
||||
return links;
|
||||
}
|
||||
|
||||
/** Repositórios que dão 404 (ou não respondem) para visitantes. */
|
||||
async function findBrokenRepos(links) {
|
||||
const broken = [];
|
||||
for (const url of links) {
|
||||
const r = await get(url, UA, { method: "HEAD" }).catch(() => null);
|
||||
if (!r || r.res.status === 404)
|
||||
ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
|
||||
broken.push(url.replace("https://github.com/evandrodevbr/", ""));
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
/** Links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado). */
|
||||
async function checkGithubLinks(pages) {
|
||||
const ghLinks = githubLinks(pages);
|
||||
const ghBroken = await findBrokenRepos(ghLinks);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
@@ -526,7 +616,10 @@ async function main() {
|
||||
? `404 para visitantes: ${ghBroken.join(", ")}`
|
||||
: `${ghLinks.size} repositórios`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Quantas verificações passaram em cada grupo. */
|
||||
function summarizeGroups() {
|
||||
const groups = {};
|
||||
for (const c of checks) {
|
||||
groups[c.group] ??= { ok: 0, total: 0 };
|
||||
@@ -534,26 +627,25 @@ async function main() {
|
||||
g.total++;
|
||||
if (c.ok) g.ok++;
|
||||
}
|
||||
const out = {
|
||||
base: BASE,
|
||||
at: new Date().toISOString(),
|
||||
urls: urls.length,
|
||||
groups,
|
||||
checks,
|
||||
};
|
||||
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
|
||||
return groups;
|
||||
}
|
||||
|
||||
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
|
||||
for (const [g, v] of Object.entries(groups))
|
||||
console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
|
||||
const failing = checks.filter((c) => !c.ok);
|
||||
console.log(`\nFalhas (${failing.length}):`);
|
||||
/** As falhas agrupadas pelo id da verificação. */
|
||||
function failuresById() {
|
||||
const byId = {};
|
||||
for (const f of failing) {
|
||||
for (const f of checks.filter((c) => !c.ok)) {
|
||||
byId[f.id] ??= [];
|
||||
byId[f.id].push(f);
|
||||
}
|
||||
for (const [id, list] of Object.entries(byId))
|
||||
return byId;
|
||||
}
|
||||
|
||||
function printReport(urls, groups) {
|
||||
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
|
||||
for (const [g, v] of Object.entries(groups))
|
||||
console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
|
||||
console.log(`\nFalhas (${checks.filter((c) => !c.ok).length}):`);
|
||||
for (const [id, list] of Object.entries(failuresById()))
|
||||
console.log(
|
||||
` - ${id} (${list.length}): ${list
|
||||
.slice(0, 4)
|
||||
@@ -564,6 +656,25 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const urls = await checkSiteFiles();
|
||||
await checkBots();
|
||||
const pages = await auditPages(urls);
|
||||
await checkInternalLinks(urls, pages);
|
||||
await checkGithubLinks(pages);
|
||||
|
||||
const groups = summarizeGroups();
|
||||
const out = {
|
||||
base: BASE,
|
||||
at: new Date().toISOString(),
|
||||
urls: urls.length,
|
||||
groups,
|
||||
checks,
|
||||
};
|
||||
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
|
||||
printReport(urls, groups);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
|
||||
Reference in new issue
Block a user