perf(movimento): vitrine e 3D mais leves, WebGL por software lembrado na sessão e suporte ao WebKit
This commit is contained in:
1 parent
9fd1570426
commit
8903f3c57e
28 files changed
+854
-406
No files matched your search
@@ -10,11 +10,13 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isWebKit } from "@/lib/device";
|
||||
import {
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
chapterIndexAtScroll,
|
||||
chapterScrollFraction,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
videoFractionAtScroll,
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
@@ -23,8 +25,9 @@ import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import {
|
||||
CAPTION_OUT_MS,
|
||||
easeToward,
|
||||
localAtProgress,
|
||||
locateInReel,
|
||||
REEL_HOLD,
|
||||
reelHolds,
|
||||
reelOffset,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
@@ -69,9 +72,13 @@ function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||
video.currentTime = target;
|
||||
}
|
||||
|
||||
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
|
||||
/**
|
||||
* O WebKit (Safari, iPhone, iPad) só desenha o primeiro quadro de um vídeo que já tocou uma vez. Só ele toca e pausa:
|
||||
* nos outros navegadores tocar faz o arquivo inteiro ser baixado (os 4 vídeos eram 3,2 MB por visita, contra 63 KB
|
||||
* quando só as buscas da rolagem pedem os trechos que o visitante vê).
|
||||
*/
|
||||
function prime(video: HTMLVideoElement) {
|
||||
if (video.dataset.primed) return;
|
||||
if (video.dataset.primed || !isWebKit()) return;
|
||||
video.dataset.primed = "true";
|
||||
video
|
||||
.play()
|
||||
@@ -118,11 +125,15 @@ const tickState = (tick: number, current: number) => {
|
||||
return tick === current ? "now" : "done";
|
||||
};
|
||||
|
||||
/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */
|
||||
/**
|
||||
* Progresso da rolagem (0 a 1, sem as pausas) de cada projeto quando o projeto `index` está na fração `local` do trecho
|
||||
* dele. É da rolagem, não do vídeo: os capítulos têm rolagem igual (`videoFractionAtScroll` leva ao ponto do vídeo).
|
||||
*/
|
||||
function targetsAt(count: number, index: number, local: number) {
|
||||
const { head, tail } = reelHolds(index, count);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
if (i < index) return 1;
|
||||
return i === index ? videoProgress(local) : 0;
|
||||
return i === index ? videoProgress(local, head, tail) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,12 +244,15 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
||||
state.smooth[index] = next;
|
||||
if (Math.abs(next - target) > 0.0004) moving = true;
|
||||
seekVideo(desktopVideos.current[index], next);
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
videoFractionAtScroll(items[index].config, next),
|
||||
);
|
||||
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
||||
});
|
||||
const current = items[state.active];
|
||||
if (current) {
|
||||
const index = chapterIndexAt(
|
||||
const index = chapterIndexAtScroll(
|
||||
current.config,
|
||||
state.smooth[state.active] ?? 0,
|
||||
);
|
||||
@@ -253,20 +267,26 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
);
|
||||
|
||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||
const syncVideo = useCallback((index: number) => {
|
||||
const state = live.current;
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
state.smooth[index] ?? state.target[index] ?? 0,
|
||||
);
|
||||
}, []);
|
||||
const syncVideo = useCallback(
|
||||
(index: number) => {
|
||||
const state = live.current;
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
videoFractionAtScroll(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? state.target[index] ?? 0,
|
||||
),
|
||||
);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
|
||||
const changeActive = useCallback(
|
||||
(index: number) => {
|
||||
const state = live.current;
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
state.chapter = chapterIndexAtScroll(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
@@ -332,12 +352,9 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
|
||||
const goTo = (index: number) => scrollToReel(index, 0);
|
||||
const goToChapter = (index: number, chapterIndex: number) => {
|
||||
const fraction = chapterFraction(
|
||||
items[index].config,
|
||||
chapterIndex,
|
||||
"desktop",
|
||||
);
|
||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||
const { head, tail } = reelHolds(index, items.length);
|
||||
const progress = chapterScrollFraction(items[index].config, chapterIndex);
|
||||
scrollToReel(index, localAtProgress(progress, head, tail));
|
||||
};
|
||||
|
||||
const current = items[active];
|
||||
|
||||
@@ -15,12 +15,17 @@ jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch: _prefetch,
|
||||
prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: unknown } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => <a {...props}>{children}</a>,
|
||||
>) => (
|
||||
// o que o Link recebeu em `prefetch` aparece no atributo: false = nunca pré-carrega, null = o padrão do Next
|
||||
<a data-prefetch={String(prefetch)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const base = buildShowcaseItems(getFeaturedProjects(null)).find(
|
||||
@@ -73,6 +78,30 @@ describe("ProjectActions sem grouped (a lista)", () => {
|
||||
expect(container.querySelector("[tabindex]")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without grouped", false],
|
||||
["grouped (the pinned reel)", true],
|
||||
])(
|
||||
"path: 'Ver case' %s -> prefetches only when the visitor arrives at it (hover, focus or touch)",
|
||||
(_name, grouped) => {
|
||||
render(
|
||||
<ProjectActions
|
||||
item={withSite}
|
||||
onExpand={jest.fn()}
|
||||
grouped={grouped}
|
||||
/>,
|
||||
);
|
||||
const anchor = link(/Ver case/);
|
||||
|
||||
// um por projeto, e na lista todos à vista: nenhum pedido de pré-carregamento na abertura da página
|
||||
expect(anchor).toHaveAttribute("data-prefetch", "false");
|
||||
|
||||
fireEvent.mouseEnter(anchor);
|
||||
|
||||
expect(anchor).toHaveAttribute("data-prefetch", "null");
|
||||
},
|
||||
);
|
||||
|
||||
it("path: no onExpand (before the page mounts) and no site -> only 'Ver case'", () => {
|
||||
render(<ProjectActions item={withoutSite} />);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||
import { useRovingTabindex } from "./useRovingTabindex";
|
||||
|
||||
@@ -51,14 +51,14 @@ export function ProjectActions({
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Link
|
||||
<HoverPrefetchLink
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/cases/${project.id}`}
|
||||
{...roving("case")}
|
||||
>
|
||||
Ver case
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</Link>
|
||||
</HoverPrefetchLink>
|
||||
{onExpand && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -219,15 +219,18 @@ describe("ProjectLightbox", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps 0:00 / 0:00 until the metadata is there, then reads the duration from the playback tick", () => {
|
||||
it("shows –:– / –:– until the duration is there, then reads it 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",
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe("–:– / –:–");
|
||||
// o leitor de tela também não ouve "0:00 de 0:00"
|
||||
expect(dialog.querySelector(".ds-lb-range")).toHaveAttribute(
|
||||
"aria-valuetext",
|
||||
"duração ainda não carregada",
|
||||
);
|
||||
|
||||
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||
|
||||
@@ -33,6 +33,15 @@ const formatTime = (seconds: number) => {
|
||||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** A duração do vídeo já chegou? Antes dos metadados ela é 0 (ou NaN), e "0:00 / 0:00" parece um vídeo vazio. */
|
||||
const isKnown = (duration: number) => Number.isFinite(duration) && duration > 0;
|
||||
|
||||
/** "0:07 / 0:30"; "–:– / –:–" enquanto a duração não carrega. */
|
||||
const clock = (elapsed: number, duration: number) =>
|
||||
isKnown(duration)
|
||||
? `${formatTime(elapsed)} / ${formatTime(duration)}`
|
||||
: "–:– / –:–";
|
||||
|
||||
/**
|
||||
* Vídeo ampliado: janela modal nativa (<dialog>: foco preso, Esc fecha, foco volta ao botão que abriu),
|
||||
* com troca desktop/celular que mantém o ponto do site, capítulos clicáveis e controles próprios.
|
||||
@@ -353,7 +362,7 @@ export function ProjectLightbox({
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ds-lb-time" aria-hidden="true">
|
||||
{formatTime(fraction * duration)} / {formatTime(duration)}
|
||||
{clock(fraction * duration, duration)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -366,7 +375,11 @@ export function ProjectLightbox({
|
||||
seekTo(Number(event.target.value) / 1000)
|
||||
}
|
||||
aria-label="Posição do vídeo"
|
||||
aria-valuetext={`${formatTime(fraction * duration)} de ${formatTime(duration)}`}
|
||||
aria-valuetext={
|
||||
isKnown(duration)
|
||||
? `${formatTime(fraction * duration)} de ${formatTime(duration)}`
|
||||
: "duração ainda não carregada"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Reflect.deleteProperty(navigator, "userAgent");
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
@@ -535,22 +536,61 @@ describe("ProjectReel: vídeos", () => {
|
||||
expect(videos()[0].currentTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: first frame decoded -> primed once (play then pause) even if loadeddata fires twice", async () => {
|
||||
it("path: Chrome (anything but WebKit) -> the video never plays: playing downloads the whole file, scrubbing only asks for what is seen", async () => {
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
||||
expect(video.dataset.primed).toBe("true");
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
|
||||
expect(video.dataset.primed).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"iPhone (Safari)",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
||||
],
|
||||
[
|
||||
"iPhone (Chrome, also WebKit)",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1",
|
||||
],
|
||||
[
|
||||
"Safari on the Mac",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||||
],
|
||||
])(
|
||||
"path: %s (WebKit) -> primed once (play then pause) even if loadeddata fires twice",
|
||||
async (_name, userAgent) => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: userAgent,
|
||||
});
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
||||
expect(video.dataset.primed).toBe("true");
|
||||
},
|
||||
);
|
||||
|
||||
it("path: priming is refused by the browser -> swallowed, no pause", async () => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
||||
});
|
||||
jest
|
||||
.mocked(HTMLMediaElement.prototype.play)
|
||||
.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
||||
@@ -621,9 +661,9 @@ describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () =
|
||||
|
||||
scrollTo(0);
|
||||
|
||||
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo", "todo"]);
|
||||
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo"]);
|
||||
expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent(
|
||||
"01/03",
|
||||
"01/02",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +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 } from "@/lib/project-showcase";
|
||||
import {
|
||||
getShowcaseConfig,
|
||||
videoFractionAtScroll,
|
||||
} from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { CAPTION_OUT_MS, reelOffset, videoProgress } from "./showcase-math";
|
||||
@@ -290,11 +291,18 @@ describe("ProjectReel", () => {
|
||||
const layer = activeLayer();
|
||||
expect(layer.querySelector(".ds-reel-title")).toBeNull();
|
||||
expect(caption()).toHaveTextContent("Lavação 047");
|
||||
const p = videoProgress(0.5);
|
||||
// a rolagem do projeto é repartida por igual entre os capítulos: o meio dela é onde o 2º capítulo começa no vídeo
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
if (!lavacao) throw new Error("projeto fora da vitrine");
|
||||
const p = videoFractionAtScroll(lavacao, videoProgress(0.5));
|
||||
const desktop = layer.querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
|
||||
expect(p).toBeCloseTo(
|
||||
lavacao.chapters[1].desktop / lavacao.durations.desktop,
|
||||
9,
|
||||
);
|
||||
// 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();
|
||||
@@ -312,7 +320,7 @@ describe("ProjectReel", () => {
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).toMatch(/^0\d\/03$/);
|
||||
).toMatch(/^0\d\/02$/);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
caption().querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
@@ -512,16 +520,16 @@ describe("ProjectReel", () => {
|
||||
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
|
||||
});
|
||||
|
||||
it("limits each project to three chapters, in the index and in the caption", () => {
|
||||
it("limits each project to two chapters, in the index and in the caption", () => {
|
||||
renderReel();
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer"))
|
||||
expect(
|
||||
layer.querySelectorAll(".ds-reel-ticks li").length,
|
||||
).toBeLessThanOrEqual(3);
|
||||
).toBeLessThanOrEqual(2);
|
||||
const chapters = screen.getByRole("list", {
|
||||
name: "Trechos de SEV Moda Íntima",
|
||||
});
|
||||
expect(within(chapters).getAllByRole("button")).toHaveLength(3);
|
||||
expect(within(chapters).getAllByRole("button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
|
||||
@@ -586,7 +594,9 @@ describe("ProjectReel", () => {
|
||||
it("scrolls to a chapter inside the project when its link is pressed", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ }));
|
||||
fireEvent.click(
|
||||
within(nav).getByRole("button", { name: /Coleção completa/ }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1200);
|
||||
});
|
||||
@@ -595,6 +605,11 @@ describe("ProjectReel", () => {
|
||||
const last = calls[calls.length - 1][0] as { top: number };
|
||||
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
|
||||
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
||||
// o 2º capítulo começa na metade da rolagem do projeto (que vai de REEL_HOLD a 1 - REEL_HOLD do trecho dele)
|
||||
expect(last.top).toBeCloseTo(
|
||||
reelOffset(weights, 0, 0.5) * SCROLLABLE + 2,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -729,10 +744,10 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).not.toBe("01/03");
|
||||
).not.toBe("01/02");
|
||||
});
|
||||
|
||||
it("never moves the caption text: no inline transform, and the CSS only fades it", () => {
|
||||
it("never moves the caption text: no inline transform, while it leaves and when it comes back with the new content", () => {
|
||||
renderReel();
|
||||
moveTo(1, 60);
|
||||
|
||||
@@ -740,17 +755,7 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
||||
for (const element of caption().querySelectorAll("*"))
|
||||
expect((element as HTMLElement).style.transform).toBe("");
|
||||
|
||||
const css = ["reel-list.css", "pinned-reel.css"]
|
||||
.map((file) => fs.readFileSync(path.join(__dirname, file), "utf8"))
|
||||
.join("\n");
|
||||
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(rule).toContain("opacity");
|
||||
expect(rule).not.toMatch(/transform|translate/);
|
||||
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(layer).toContain("opacity");
|
||||
expect(layer).not.toMatch(/transform|translate/);
|
||||
|
||||
// comportamento: a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
|
||||
// a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
|
||||
expect(caption()).toHaveAttribute("data-leaving");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||
@@ -777,8 +782,8 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => {
|
||||
it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => {
|
||||
renderReel();
|
||||
const buttons = within(index()).getAllByRole("button");
|
||||
// 5 projetos e os 3 trechos do projeto em cena: 8 botões, uma parada
|
||||
expect(buttons).toHaveLength(5 + 3);
|
||||
// 5 projetos e os 2 trechos do projeto em cena: 7 botões, uma parada
|
||||
expect(buttons).toHaveLength(5 + 2);
|
||||
expect(stops()).toHaveLength(1);
|
||||
expect(stops()[0]).toHaveAttribute("aria-current", "true");
|
||||
expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima");
|
||||
|
||||
@@ -124,36 +124,4 @@ describe("carregamento sob demanda", () => {
|
||||
expect(mockLoads.pinned).toBe(1);
|
||||
expect(container.querySelector(".ds-reel-placeholder")).toBeNull();
|
||||
});
|
||||
|
||||
it("4. the page code never imports the heavy modules by name: only the *Lazy files do, with import()", () => {
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
const read = (file: string) =>
|
||||
fs.readFileSync(path.join(__dirname, file), "utf8");
|
||||
|
||||
expect(read("ShowcaseProvider.tsx")).not.toMatch(
|
||||
/from "\.\/ProjectLightbox"/,
|
||||
);
|
||||
expect(read("ProjectReel.tsx")).not.toMatch(/from "\.\/PinnedReel"/);
|
||||
expect(read("ProjectLightboxLazy.tsx")).toMatch(
|
||||
/import\("\.\/ProjectLightbox"\)/,
|
||||
);
|
||||
expect(read("PinnedReelLazy.tsx")).toMatch(/import\("\.\/PinnedReel"\)/);
|
||||
// e o CSS de cada um viaja com o componente dele, não com a página
|
||||
expect(read("PinnedReel.tsx")).toMatch(/import "\.\/pinned-reel\.css"/);
|
||||
expect(read("ProjectLightbox.tsx")).toMatch(/import "\.\/lightbox\.css"/);
|
||||
// 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(
|
||||
/^\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"/);
|
||||
});
|
||||
});
|
||||
@@ -337,6 +337,38 @@ html.ds-lb-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* tamanhos do degradê da fileira de etapas (celular): sem rolagem a fileira não anima e os dois ficam em 0 */
|
||||
@property --ds-fade-start {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --ds-fade-end {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
/* o progresso é o da rolagem da própria fileira: sem rolar, só a borda direita esmaece; no meio, as duas; no fim,
|
||||
só a esquerda */
|
||||
@keyframes ds-lb-edges {
|
||||
0% {
|
||||
--ds-fade-start: 0px;
|
||||
--ds-fade-end: 28px;
|
||||
}
|
||||
10% {
|
||||
--ds-fade-start: 28px;
|
||||
}
|
||||
90% {
|
||||
--ds-fade-end: 28px;
|
||||
}
|
||||
100% {
|
||||
--ds-fade-start: 28px;
|
||||
--ds-fade-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.ds-lb {
|
||||
padding: 0;
|
||||
@@ -353,6 +385,19 @@ html.ds-lb-open {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 2px;
|
||||
/* degradê na borda que ainda tem etapas escondidas: some sozinho quando a fileira cabe ou chega ao fim */
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--ds-fade-start),
|
||||
#000 calc(100% - var(--ds-fade-end)),
|
||||
transparent
|
||||
);
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: ds-lb-edges linear both;
|
||||
animation-timeline: scroll(self inline);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-chapters::-webkit-scrollbar {
|
||||
|
||||
@@ -223,6 +223,8 @@
|
||||
.ds-reel-item {
|
||||
flex: 0 0 min(82%, 340px);
|
||||
scroll-snap-align: start;
|
||||
/* um deslize rápido para em cada cartão, em vez de pular projetos */
|
||||
scroll-snap-stop: always;
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
easeToward,
|
||||
localAtProgress,
|
||||
locateInReel,
|
||||
REEL_HOLD,
|
||||
REEL_LAST_TAIL,
|
||||
reelHolds,
|
||||
reelStarts,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
@@ -41,6 +45,34 @@ describe("videoProgress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoProgress with a shorter pause at the end", () => {
|
||||
it("lets the video run until the tail starts", () => {
|
||||
expect(videoProgress(0.9, 0.1, 0.03)).toBeCloseTo((0.9 - 0.1) / 0.87, 9);
|
||||
expect(videoProgress(0.97, 0.1, 0.03)).toBe(1);
|
||||
expect(videoProgress(1, 0.1, 0.03)).toBe(1);
|
||||
expect(videoProgress(0.1, 0.1, 0.03)).toBe(0);
|
||||
});
|
||||
|
||||
it("localAtProgress is its inverse inside the moving part", () => {
|
||||
for (const progress of [0, 0.25, 0.5, 1]) {
|
||||
const local = localAtProgress(progress, 0.1, 0.03);
|
||||
expect(videoProgress(local, 0.1, 0.03)).toBeCloseTo(progress, 9);
|
||||
}
|
||||
expect(localAtProgress(0)).toBe(REEL_HOLD);
|
||||
expect(localAtProgress(1)).toBeCloseTo(1 - REEL_HOLD, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reelHolds", () => {
|
||||
it("keeps the full pause everywhere but at the end of the last project, which has nothing to wait for", () => {
|
||||
expect(reelHolds(0, 4)).toEqual({ head: REEL_HOLD, tail: REEL_HOLD });
|
||||
expect(reelHolds(2, 4)).toEqual({ head: REEL_HOLD, tail: REEL_HOLD });
|
||||
expect(reelHolds(3, 4)).toEqual({ head: REEL_HOLD, tail: REEL_LAST_TAIL });
|
||||
expect(REEL_LAST_TAIL).toBeLessThan(REEL_HOLD);
|
||||
expect(reelHolds(0, 1).tail).toBe(REEL_LAST_TAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("easeToward", () => {
|
||||
it("moves toward the target and never overshoots", () => {
|
||||
const first = easeToward(0, 1, 1 / 60);
|
||||
|
||||
@@ -38,6 +38,20 @@ export function reelStarts(weights: number[]): number[] {
|
||||
/** Fração do trecho de cada projeto em que o vídeo fica parado, no começo e no fim. */
|
||||
export const REEL_HOLD = 0.1;
|
||||
|
||||
/**
|
||||
* Pausa no fim do último projeto: depois dele não há troca a esperar, e o palco já se solta da página. Fica só o
|
||||
* bastante para o último quadro chegar antes de o palco subir (era a mesma pausa de 10%, com a rolagem ainda presa).
|
||||
*/
|
||||
export const REEL_LAST_TAIL = 0.03;
|
||||
|
||||
/** As pausas do projeto `index` de `count`: no começo e no fim do trecho dele. */
|
||||
export function reelHolds(index: number, count: number) {
|
||||
return {
|
||||
head: REEL_HOLD,
|
||||
tail: index === count - 1 ? REEL_LAST_TAIL : REEL_HOLD,
|
||||
};
|
||||
}
|
||||
|
||||
/** Avanço total (0 a 1) em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||
export function reelOffset(weights: number[], index: number, local: number) {
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
@@ -49,11 +63,24 @@ export function reelOffset(weights: number[], index: number, local: number) {
|
||||
* O trecho de cada projeto tem uma pausa no começo e outra no fim: o vídeo só anda no meio.
|
||||
* Isso dá tempo de ler o título antes de o site começar a rolar e de descansar antes da troca.
|
||||
*/
|
||||
export function videoProgress(local: number, hold = REEL_HOLD): number {
|
||||
const span = 1 - hold * 2;
|
||||
export function videoProgress(
|
||||
local: number,
|
||||
hold = REEL_HOLD,
|
||||
tail = hold,
|
||||
): number {
|
||||
const span = 1 - hold - tail;
|
||||
return Math.min(1, Math.max(0, (local - hold) / span));
|
||||
}
|
||||
|
||||
/** O contrário de `videoProgress`: onde, no trecho do projeto (0 a 1), o avanço `progress` do vídeo acontece. */
|
||||
export function localAtProgress(
|
||||
progress: number,
|
||||
hold = REEL_HOLD,
|
||||
tail = hold,
|
||||
) {
|
||||
return hold + progress * (1 - hold - tail);
|
||||
}
|
||||
|
||||
/** Aproxima `current` de `target` de forma suave, independente da taxa de quadros (`tau` em segundos). */
|
||||
export function easeToward(
|
||||
current: number,
|
||||
|
||||
Reference in new issue
Block a user