perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -1,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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user