perf(movimento): sem tela vazia, passagens em CSS e vitrine leve

Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:50 -03:00
1 parent 10f170abf4
commit 318a764190
77 files changed
+4293 -4951

No files matched your search

+188 -30
View File
@@ -1,9 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { MOBILE_QUERY } from "@/lib/device";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
import { ProjectReel } from "./ProjectReel";
import { getShowcaseConfig } from "@/lib/project-showcase";
import { CAPTION_OUT_MS, ProjectReel } from "./ProjectReel";
import { ShowcaseProvider } from "./ShowcaseProvider";
import { reelOffset, videoProgress } from "./showcase-math";
import { PINNED_QUERY } from "./useShowcaseMedia";
@@ -169,6 +171,10 @@ function scrollTo(progress: number) {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(1500);
});
// a legenda leva CAPTION_OUT_MS para trocar de projeto
act(() => {
jest.advanceTimersByTime(CAPTION_OUT_MS + 20);
});
}
const sources = () =>
@@ -179,6 +185,9 @@ const sources = () =>
const activeLayer = () =>
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
/** A legenda do projeto em cena: um bloco só, fora das camadas. */
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
describe("ProjectReel", () => {
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
reducedMotion = true;
@@ -244,7 +253,7 @@ describe("ProjectReel", () => {
expect(
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
).toBeInTheDocument();
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
expect(caption()).toHaveTextContent("SEV Moda Íntima");
});
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
@@ -258,7 +267,7 @@ describe("ProjectReel", () => {
expect(layers[0]).not.toHaveAttribute("inert");
});
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
it("moves to the next project and syncs the laptop video to the scroll; the phone is just a poster", () => {
renderReel();
readyVideos();
@@ -267,18 +276,20 @@ describe("ProjectReel", () => {
scrollTo(t);
const layer = activeLayer();
expect(layer).toHaveTextContent("Lavação 047");
const lavacao = getShowcaseConfig("lavacao-047");
if (!lavacao) throw new Error("config ausente");
expect(layer.querySelector(".ds-reel-title")).toBeNull();
expect(caption()).toHaveTextContent("Lavação 047");
const p = videoProgress(0.5);
const desktop = layer.querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
const phone = layer.querySelector(
".ds-phone-screen video",
) as HTMLVideoElement;
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
expect(layer.querySelector(".ds-phone video")).toBeNull();
expect(
(layer.querySelector(".ds-phone-screen") as HTMLElement).style
.backgroundImage,
).toContain("lavacao-047/mobile.webp");
});
it("names the chapter on screen and counts them", () => {
@@ -286,15 +297,14 @@ describe("ProjectReel", () => {
readyVideos();
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
const layer = activeLayer();
const lavacao = getShowcaseConfig("lavacao-047");
expect(lavacao).toBeDefined();
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
/^0\d\/03$/,
);
expect(
caption().querySelector(".ds-reel-chapter-count")?.textContent,
).toMatch(/^0\d\/03$/);
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
expect(labels).toContain(
layer.querySelector(".ds-reel-chapter strong")?.textContent,
caption().querySelector(".ds-reel-chapter strong")?.textContent,
);
});
@@ -309,7 +319,7 @@ describe("ProjectReel", () => {
expect(first.currentTime).toBe(0);
scrollTo(1);
expect(activeLayer()).toHaveTextContent("AS Serviços");
expect(caption()).toHaveTextContent("AS Serviços");
const last = activeLayer().querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
@@ -336,9 +346,8 @@ describe("ProjectReel", () => {
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
).toBe(true);
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
).toBe(true);
// o celular da moldura é só pôster: o vídeo de celular nunca é baixado na vitrine fixa
expect(loaded.some((src) => src?.includes("scrub-mobile"))).toBe(false);
// o vizinho (Lavação) e os demais ficam sem nenhum vídeo
expect(loaded.some((src) => src?.includes("lavacao-047"))).toBe(false);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
@@ -378,7 +387,7 @@ describe("ProjectReel", () => {
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("does not download the phone video while the phone frame is not on screen", () => {
it("never downloads the phone video, whether or not the phone frame is on screen (it is only a poster)", () => {
phoneWidth = 0;
renderReel();
scrollTo(0);
@@ -487,8 +496,7 @@ describe("ProjectReel", () => {
it("offers Ver case (outline), Abrir site (text link, only with a live site) and Ampliar vídeo, none of them lime", () => {
renderReel();
const layer = activeLayer();
const actions = layer.querySelector(".ds-reel-actions") as HTMLElement;
const actions = caption().querySelector(".ds-reel-actions") as HTMLElement;
expect(
within(actions).getByRole("link", { name: /Ver case/ }),
).toHaveAttribute("href", "/cases/sev-modaintima");
@@ -502,20 +510,17 @@ describe("ProjectReel", () => {
expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull();
// sem demoUrl não há "Abrir site"
const asServicos = [...document.querySelectorAll(".ds-reel-layer")].at(-1);
expect(asServicos).toBeDefined();
scrollTo(1);
expect(caption()).toHaveTextContent("AS Serviços");
expect(
within(asServicos as HTMLElement).queryByRole("link", {
name: /Abrir site/,
hidden: true,
}),
within(caption()).queryByRole("link", { name: /Abrir site/ }),
).toBeNull();
});
it("opens the enlarged video from the pinned scene", () => {
renderReel();
fireEvent.click(
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
within(caption()).getByRole("button", { name: /Ampliar vídeo/ }),
);
expect(
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
@@ -551,3 +556,156 @@ describe("ProjectReel", () => {
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
});
});
describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
/** Move a rolagem para o meio do trecho do projeto e deixa passar só `ms`. */
function moveTo(index: number, ms: number) {
reelTop = HEADER - reelOffset(weights, index, 0.5) * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(ms);
});
}
const titles = () =>
[...document.querySelectorAll(".ds-reel-title")].map(
(title) => title.textContent,
);
it("has one caption for the whole pinned scene: title, counter and buttons exist once, outside the layers", () => {
renderReel();
expect(document.querySelectorAll(".ds-reel-caption")).toHaveLength(1);
expect(document.querySelectorAll(".ds-reel-title")).toHaveLength(1);
expect(document.querySelectorAll(".ds-reel-chapter-count")).toHaveLength(1);
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
expect(document.querySelectorAll(".ds-reel-layer")).toHaveLength(5);
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
expect(layer.closest(".ds-reel-caption")).toBeNull();
expect(layer.querySelector(".ds-reel-caption")).toBeNull();
expect(layer.querySelector("h3, button, a")).toBeNull();
}
// os aparelhos e a legenda dividem o mesmo bloco, a legenda abaixo
const set = document.querySelector(".ds-reel-set") as HTMLElement;
expect(set.querySelector(".ds-reel-devices")).toBeInTheDocument();
expect(set.lastElementChild).toBe(caption());
});
it("each layer holds only the devices: the laptop with its video and the phone", () => {
renderReel();
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
expect(layer.querySelectorAll(".ds-bframe")).toHaveLength(1);
expect(layer.querySelectorAll(".ds-phone")).toHaveLength(1);
expect(layer.querySelectorAll(".ds-reel-screen video")).toHaveLength(1);
}
});
it("fades the old caption out first, with its old content and no second caption, then brings the new one in", () => {
renderReel();
expect(caption()).toHaveTextContent("SEV Moda Íntima");
expect(caption()).not.toHaveAttribute("data-leaving");
// o projeto muda; só uns milissegundos depois (a legenda ainda está saindo)
moveTo(1, 60);
expect(activeLayer()).toHaveAttribute("data-state", "active");
expect(activeLayer().querySelector(".ds-bframe-url")?.textContent).not.toBe(
"sevmodaintima.com.br",
);
expect(caption()).toHaveAttribute("data-leaving");
expect(titles()).toEqual(["SEV Moda Íntima"]);
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
// passou o tempo de saída: o conteúdo novo entra, e a legenda volta
act(() => {
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
});
expect(titles()).toEqual(["Lavação 047"]);
expect(caption()).not.toHaveAttribute("data-leaving");
});
it("leaves in 120 ms: before that the old caption is still there, right after it the new one", () => {
expect(CAPTION_OUT_MS).toBe(120);
renderReel();
moveTo(1, 20);
act(() => {
jest.advanceTimersByTime(CAPTION_OUT_MS - 30);
});
expect(titles()).toEqual(["SEV Moda Íntima"]);
act(() => {
jest.advanceTimersByTime(60);
});
expect(titles()).toEqual(["Lavação 047"]);
});
it("at no moment of the change are two project titles (or two sets of buttons) on screen", () => {
renderReel();
const seen: number[] = [];
for (const [index, ms] of [
[1, 16],
[1, 50],
[1, 80],
[2, 16],
[2, 100],
[2, 200],
[3, 10],
[0, 10],
[0, 300],
] as const) {
moveTo(index, ms);
seen.push(
document.querySelectorAll(".ds-reel-title").length,
document.querySelectorAll(".ds-reel-actions").length,
document.querySelectorAll(".ds-reel-chapter-count").length,
);
}
expect(new Set(seen)).toEqual(new Set([1]));
});
it("keeps coming back to the project in scene when the scroll goes back before the caption finished leaving", () => {
renderReel();
moveTo(1, 40);
moveTo(0, 40);
act(() => {
jest.advanceTimersByTime(400);
});
expect(titles()).toEqual(["SEV Moda Íntima"]);
expect(caption()).not.toHaveAttribute("data-leaving");
});
it("changes the chapter inside the same project in place, without fading the caption", () => {
renderReel();
readyVideos();
scrollTo(reelOffset(weights, 0, 0.9)); // fim do primeiro projeto: outro trecho
expect(caption()).not.toHaveAttribute("data-leaving");
expect(titles()).toEqual(["SEV Moda Íntima"]);
expect(
caption().querySelector(".ds-reel-chapter-count")?.textContent,
).not.toBe("01/03");
});
it("never moves the caption text: no inline transform, and the CSS only fades it", () => {
renderReel();
moveTo(1, 60);
expect(caption().getAttribute("style")).toBeNull();
for (const element of caption().querySelectorAll("*"))
expect((element as HTMLElement).style.transform).toBe("");
const css = fs.readFileSync(path.join(__dirname, "showcase.css"), "utf8");
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
expect(rule).toContain("opacity");
expect(rule).not.toMatch(/transform|translate/);
expect(rule).toContain("0.12s");
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
expect(layer).toContain("opacity");
expect(layer).not.toMatch(/transform|translate/);
});
});