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:
Evandro Fonseca Junior committed 2026-10-14 19:14:48 -03:00
1 parent 9e2d65a926
commit 204ebd5fa5
49 files changed
+2197 -606

No files matched your search

+42 -12
View File
@@ -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"]);
});
});
+38 -3
View File
@@ -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);
+21 -12
View File
@@ -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", () => {
+2 -2
View File
@@ -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;
+125 -22
View File
@@ -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);
});
});
});
-1
View File
@@ -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
+11 -1
View File
@@ -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"/);
});
});
+6 -1
View File
@@ -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;
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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;