feat(showcase): moldura de navegador, um video por vez e video ampliado nos cards

- cards com moldura de navegador (endereco real) e brilho na cor do site no card que toca
- um video por vez: o do card sob o mouse/foco, ou o mais perto do centro da tela; nada baixa
  antes de tocar, e um botao pausa os videos que se mexem sozinhos
- janela ampliada em <dialog> nativo: troca computador/celular mantendo o ponto do site,
  capitulos clicaveis, controles proprios, setas entre projetos e foco devolvido ao fechar
- dados e capitulos de cada projeto em lib/project-showcase.ts (substitui project-videos.ts)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Sonnet 5.5 committed 2026-10-08 16:36:53 -03:00
1 parent 60dc2f1878
commit a1a1e91135
18 files changed
+3564 -155

No files matched your search

@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
describe("displayUrl", () => {
it("shows the site address without the protocol, www or trailing slash", () => {
expect(displayUrl({ demoUrl: "https://www.example.com.br/" })).toEqual({
text: "example.com.br",
secure: true,
});
expect(displayUrl({ demoUrl: "https://app.example.com/painel/" })).toEqual({
text: "app.example.com/painel",
secure: true,
});
});
it("marks plain http as not secure", () => {
expect(displayUrl({ demoUrl: "http://localhost:3000" })).toEqual({
text: "localhost:3000",
secure: false,
});
});
it("falls back to the code address when there is no live demo", () => {
expect(
displayUrl({ demoUrl: null, githubUrl: "https://github.com/user/repo" }),
).toEqual({ text: "github.com/user/repo", secure: true });
});
it("skips an address that does not parse and uses the next one", () => {
expect(
displayUrl({
demoUrl: "isso não é uma url",
githubUrl: "https://github.com/a/b",
}),
).toEqual({ text: "github.com/a/b", secure: true });
});
it("returns null when there is nothing to show", () => {
expect(displayUrl({})).toBeNull();
expect(displayUrl({ demoUrl: "", githubUrl: null })).toBeNull();
});
});
describe("BrowserFrame", () => {
it("draws the three dots, the address and the screen content", () => {
const { container } = render(
<BrowserFrame url={{ text: "site.com", secure: true }}>
<p>conteúdo</p>
</BrowserFrame>,
);
expect(screen.getByText("site.com")).toBeInTheDocument();
expect(screen.getByText("conteúdo")).toBeInTheDocument();
expect(container.querySelectorAll(".ds-bframe-dots i")).toHaveLength(3);
expect(container.querySelector(".ds-bframe-url svg")).not.toBeNull();
});
it("keeps the decorative bar out of the accessibility tree", () => {
const { container } = render(
<BrowserFrame url={{ text: "site.com", secure: true }}>
<p>x</p>
</BrowserFrame>,
);
expect(container.querySelector(".ds-bframe-bar")).toHaveAttribute(
"aria-hidden",
"true",
);
});
it("shows a neutral label and no lock when the address is unknown or not secure", () => {
const { container, rerender } = render(
<BrowserFrame url={null}>
<p>x</p>
</BrowserFrame>,
);
expect(screen.getByText("pré-visualização")).toBeInTheDocument();
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
rerender(
<BrowserFrame url={{ text: "localhost", secure: false }}>
<p>x</p>
</BrowserFrame>,
);
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { CSSProperties, ReactNode } from "react";
/** Endereço mostrado na barra: o do site publicado, ou o do código quando não há demonstração. */
export function displayUrl(project: {
demoUrl?: string | null;
githubUrl?: string | null;
}): { text: string; secure: boolean } | null {
for (const raw of [project.demoUrl, project.githubUrl]) {
if (!raw) continue;
try {
const url = new URL(raw);
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return {
text: `${url.host.replace(/^www\./, "")}${path}`,
secure: url.protocol === "https:",
};
} catch {}
}
return null;
}
/**
* Janela de navegador em volta da captura: três pontos, barra com o endereço real e a tela.
* A barra é decoração (aria-hidden); o endereço também aparece como texto no próprio card.
*/
export function BrowserFrame({
url,
children,
className = "",
style,
}: {
url: { text: string; secure: boolean } | null;
children: ReactNode;
className?: string;
style?: CSSProperties;
}) {
return (
<div className={`ds-bframe ${className}`.trim()} style={style}>
<div className="ds-bframe-bar" aria-hidden="true">
<span className="ds-bframe-dots">
<i />
<i />
<i />
</span>
<span className="ds-bframe-url">
{url?.secure && (
<svg
viewBox="0 0 12 12"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
aria-hidden="true"
>
<rect x="2.5" y="5.5" width="7" height="5" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
)}
<span>{url?.text ?? "pré-visualização"}</span>
</span>
</div>
{children}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { CSSProperties, ReactNode } from "react";
/** Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta). */
export function PhoneFrame({
children,
poster,
className = "",
style,
}: {
children: ReactNode;
poster?: string;
className?: string;
style?: CSSProperties;
}) {
return (
<div className={`ds-phone ${className}`.trim()} style={style}>
<div
className="ds-phone-screen"
style={poster ? { backgroundImage: `url(${poster})` } : undefined}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,393 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { StrictMode } from "react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import {
chapterFraction,
getShowcaseConfig,
mobileFractionAt,
} from "@/lib/project-showcase";
import { ShowcaseProvider, useShowcaseCard } from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
function Card({ id }: { id: string }) {
const { frameRef, open } = useShowcaseCard(id);
return (
<article className="ds-card">
<div ref={frameRef} />
<button type="button" onClick={open}>
ampliar {id}
</button>
</article>
);
}
const projects = getFeaturedProjects(null);
const lavacao = getShowcaseConfig("lavacao-047");
function setup(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");
}
/** jsdom não carrega mídia: o teste dá ao <video> uma duração e deixa o componente reagir como no navegador. */
function loadVideo(dialog: HTMLElement, duration = 30) {
const video = dialog.querySelector("video") as HTMLVideoElement;
Object.defineProperty(video, "duration", {
configurable: true,
value: duration,
});
let time = 0;
Object.defineProperty(video, "currentTime", {
configurable: true,
get: () => time,
set: (value: number) => {
time = value;
},
});
act(() => {
video.dispatchEvent(new Event("loadedmetadata"));
});
return video;
}
beforeAll(() => {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener() {},
removeEventListener() {},
}));
// jsdom ainda não implementa o <dialog> modal
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(() => {
reducedMotion = false;
jest
.spyOn(HTMLMediaElement.prototype, "play")
.mockImplementation(function play(this: HTMLMediaElement) {
// jsdom não toca nada: o teste mantém `paused` e os eventos como o navegador faria
Object.defineProperty(this, "paused", {
configurable: true,
value: false,
});
this.dispatchEvent(new Event("play"));
return Promise.resolve();
});
jest
.spyOn(HTMLMediaElement.prototype, "pause")
.mockImplementation(function pause(this: HTMLMediaElement) {
Object.defineProperty(this, "paused", {
configurable: true,
value: true,
});
this.dispatchEvent(new Event("pause"));
});
});
afterEach(() => {
jest.restoreAllMocks();
document.documentElement.classList.remove("ds-lb-open");
});
describe("ProjectLightbox", () => {
it("opens a modal dialog for the clicked project, locks page scroll and puts focus on close", () => {
const dialog = setup();
expect(dialog).toHaveAttribute("open");
expect(dialog).toHaveAccessibleName("Lavação 047");
expect(dialog).toHaveAttribute("data-lenis-prevent");
expect(document.documentElement).toHaveClass("ds-lb-open");
expect(
within(dialog).getByRole("button", { name: "Fechar" }),
).toHaveFocus();
expect(within(dialog).getByText("02 / 05")).toBeInTheDocument();
});
it("shows the desktop video in a browser frame by default and the phone version on request", () => {
const dialog = setup();
const toggle = within(dialog).getByRole("group", {
name: "Versão do site",
});
expect(
within(toggle).getByRole("button", { name: "Computador" }),
).toHaveAttribute("aria-pressed", "true");
expect(dialog.querySelector(".ds-lb-frame")).not.toBeNull();
expect(dialog.querySelector('source[src$="desktop.webm"]')).not.toBeNull();
fireEvent.click(within(toggle).getByRole("button", { name: "Celular" }));
expect(
within(toggle).getByRole("button", { name: "Celular" }),
).toHaveAttribute("aria-pressed", "true");
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
expect(dialog.querySelector('source[src$="mobile.webm"]')).not.toBeNull();
});
it("keeps the same point of the site when switching devices", () => {
if (!lavacao) throw new Error("config ausente");
const dialog = setup();
const desktop = loadVideo(dialog, lavacao.durations.desktop);
// meio do vídeo desktop
desktop.currentTime = lavacao.durations.desktop * 0.5;
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
const phone = loadVideo(dialog, lavacao.durations.mobile);
expect(phone.currentTime).toBeCloseTo(
mobileFractionAt(lavacao, 0.5) * lavacao.durations.mobile,
5,
);
});
it("jumps to a chapter and marks it as current", () => {
if (!lavacao) throw new Error("config ausente");
const dialog = setup();
const video = loadVideo(dialog, lavacao.durations.desktop);
fireEvent.click(
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
);
expect(video.currentTime).toBeCloseTo(
chapterFraction(lavacao, 2, "desktop") * lavacao.durations.desktop,
5,
);
expect(
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
).toHaveAttribute("aria-current", "true");
});
it("toggles play and pause with an accessible name", () => {
const dialog = setup();
loadVideo(dialog);
const play = within(dialog).getByRole("button", { name: "Reproduzir" });
fireEvent.click(play);
expect(
within(dialog).getByRole("button", { name: "Pausar" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
expect(
within(dialog).getByRole("button", { name: "Reproduzir" }),
).toBeInTheDocument();
});
it("seeks with the range slider", () => {
const dialog = setup();
const video = loadVideo(dialog, 30);
fireEvent.change(
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
{ target: { value: "500" } },
);
expect(video.currentTime).toBeCloseTo(15, 5);
});
it("moves between projects and disables the arrows at both ends", () => {
const dialog = setup("sev-modaintima");
expect(
within(dialog).getByRole("button", { name: "Projeto anterior" }),
).toBeDisabled();
fireEvent.click(
within(dialog).getByRole("button", { name: "Próximo projeto" }),
);
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
fireEvent.keyDown(screen.getByRole("dialog"), { key: "ArrowLeft" });
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
});
it("offers the site and the code when they exist", () => {
const dialog = setup("lavacao-047");
expect(
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
expect(
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
).toBeNull();
});
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
setup();
fireEvent.click(screen.getByRole("button", { name: "Fechar" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.documentElement).not.toHaveClass("ds-lb-open");
cleanup();
const dialog = setup();
fireEvent.pointerDown(dialog);
fireEvent.click(dialog); // clique no fundo (o próprio <dialog>)
expect(screen.queryByRole("dialog")).toBeNull();
cleanup();
const again = setup();
act(() => {
again.removeAttribute("open"); // o Esc nativo fecha o <dialog> e só então dispara `close`
again.dispatchEvent(new Event("close"));
});
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.documentElement).not.toHaveClass("ds-lb-open");
});
it("does not autoplay for users who prefer reduced motion", () => {
reducedMotion = true;
const dialog = setup();
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
});
});
describe("ProjectLightbox: revisão", () => {
it("survives React StrictMode in development: the double effect must not close the window it just opened", () => {
// o `close` real é assíncrono: o teste reproduz isso para pegar o evento atrasado
HTMLDialogElement.prototype.close = function close(
this: HTMLDialogElement,
) {
this.removeAttribute("open");
queueMicrotask(() => this.dispatchEvent(new Event("close")));
};
jest.useFakeTimers();
try {
render(
<StrictMode>
<ShowcaseProvider projects={projects}>
<Card id="lavacao-047" />
</ShowcaseProvider>
</StrictMode>,
);
fireEvent.click(
screen.getByRole("button", { name: "ampliar lavacao-047" }),
);
act(() => {
jest.advanceTimersByTime(50);
});
expect(screen.getByRole("dialog")).toHaveAttribute("open");
} finally {
jest.useRealTimers();
HTMLDialogElement.prototype.close = function close(
this: HTMLDialogElement,
) {
this.removeAttribute("open");
this.dispatchEvent(new Event("close"));
};
}
});
it("ignores a late close event from a dialog that is open again", () => {
const dialog = setup();
act(() => {
dialog.dispatchEvent(new Event("close"));
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("does not close when a press starts inside the panel and is released on the backdrop", () => {
const dialog = setup();
fireEvent.pointerDown(
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
);
fireEvent.click(dialog);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("stays paused after switching device when the visitor had paused", () => {
const dialog = setup();
loadVideo(dialog);
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
expect(
within(dialog).getByRole("button", { name: "Reproduzir" }),
).toBeInTheDocument();
});
it("keeps playing after switching device when the video was playing", () => {
const dialog = setup();
loadVideo(dialog);
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
expect(dialog.querySelector("video")).toHaveAttribute("autoplay");
});
it("keeps the position when the visitor flips the device twice before the new video loads", () => {
if (!lavacao) throw new Error("config ausente");
const dialog = setup();
const desktop = loadVideo(dialog, lavacao.durations.desktop);
desktop.currentTime = lavacao.durations.desktop * 0.5;
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
// o vídeo do celular ainda não carregou (sem duração): volta de novo para o computador
fireEvent.click(within(dialog).getByRole("button", { name: "Computador" }));
const back = loadVideo(dialog, lavacao.durations.desktop);
expect(back.currentTime / lavacao.durations.desktop).toBeCloseTo(0.5, 1);
});
it("moves focus to the opposite arrow when the pressed one reaches its end", () => {
const dialog = setup("nutriscan");
// anterior, anterior: chega ao primeiro e o botão "Anterior" fica desabilitado
let current = within(dialog).getByRole("button", {
name: "Projeto anterior",
});
current.focus();
fireEvent.click(current);
current = within(screen.getByRole("dialog")).getByRole("button", {
name: "Projeto anterior",
});
current.focus();
fireEvent.click(current);
const after = within(screen.getByRole("dialog"));
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
expect(
after.getByRole("button", { name: "Projeto anterior" }),
).toBeDisabled();
expect(
after.getByRole("button", { name: "Próximo projeto" }),
).toHaveFocus();
});
it("announces which project is on screen", () => {
const dialog = setup("nutriscan");
expect(dialog.querySelector("[aria-live='polite']")).toHaveTextContent(
"NutriScan, projeto 3 de 5",
);
});
it("ignores arrow keys combined with modifiers, which belong to the browser", () => {
const dialog = setup("lavacao-047");
fireEvent.keyDown(dialog, { key: "ArrowRight", altKey: true });
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
});
});
+400
View File
@@ -0,0 +1,400 @@
"use client";
import {
type ChangeEvent,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import {
chapterFraction,
chapterIndexAt,
type Device,
desktopFractionAt,
getShowcaseAssets,
mobileFractionAt,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import type { ShowcaseItem } from "./ShowcaseProvider";
const formatTime = (seconds: number) => {
const whole = Math.max(0, Math.floor(seconds));
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
/**
* 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.
*/
export function ProjectLightbox({
items,
index,
onClose,
onNavigate,
}: {
items: ShowcaseItem[];
index: number;
onClose: () => void;
onNavigate: (index: number) => void;
}) {
const { config, project } = items[index];
const reducedMotion = useReducedMotion();
const titleId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const previousRef = useRef<HTMLButtonElement>(null);
const nextRef = useRef<HTMLButtonElement>(null);
const pendingFraction = useRef<number | null>(null);
const pressedOnBackdrop = useRef(false);
const [device, setDevice] = useState<Device>(() =>
window.matchMedia("(max-width: 639px)").matches ? "mobile" : "desktop",
);
// o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento
const [autoplay, setAutoplay] = useState(
() => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
const wantsToPlay = useRef(autoplay);
const [playing, setPlaying] = useState(false);
const [fraction, setFraction] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
if (!reducedMotion) return;
wantsToPlay.current = false;
setAutoplay(false);
}, [reducedMotion]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
closeRef.current?.focus();
// travar a rolagem da página tira a barra de rolagem clássica: o espaço dela é devolvido para nada pular
const root = document.documentElement;
root.style.setProperty(
"--ds-scrollbar",
`${Math.max(0, window.innerWidth - root.clientWidth)}px`,
);
root.classList.add("ds-lb-open");
return () => {
root.classList.remove("ds-lb-open");
root.style.removeProperty("--ds-scrollbar");
// sem dialog.close() aqui: no StrictMode do dev a limpeza roda no meio da abertura e o evento `close`
// (assíncrono) chegaria depois de reaberto; sair do DOM já basta para soltar a janela modal
};
}, []);
// o marcador acompanha o vídeo quadro a quadro enquanto ele toca (timeupdate só chega umas 4 vezes por segundo)
useEffect(() => {
if (!playing) return;
let id = 0;
const tick = () => {
const current = videoRef.current;
if (current?.duration)
setFraction(current.currentTime / current.duration);
id = requestAnimationFrame(tick);
};
id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}, [playing]);
const assets = getShowcaseAssets(config.id, device);
const desktopFraction =
device === "desktop" ? fraction : desktopFractionAt(config, fraction);
const chapter = chapterIndexAt(config, desktopFraction);
const url = displayUrl(project);
const canOpenSite =
project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown");
const switchDevice = (next: Device) => {
if (next === device) return;
const video = videoRef.current;
// se o novo vídeo ainda não tem duração (duplo clique), vale a posição já gravada no estado
const current = video?.duration
? video.currentTime / video.duration
: fraction;
pendingFraction.current =
next === "mobile"
? mobileFractionAt(config, current)
: desktopFractionAt(config, current);
setFraction(pendingFraction.current);
setPlaying(false);
setAutoplay(wantsToPlay.current);
setDevice(next);
};
const navigate = (next: number) => {
if (next < 0 || next >= items.length) return;
pendingFraction.current = null;
setFraction(0);
setPlaying(false);
setAutoplay(wantsToPlay.current);
// o botão que acabou de chegar ao limite fica desabilitado: o foco passa para o do outro lado
const active = document.activeElement;
if (active === previousRef.current && next === 0) nextRef.current?.focus();
if (active === nextRef.current && next === items.length - 1)
previousRef.current?.focus();
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);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
wantsToPlay.current = video.paused;
if (video.paused) video.play().catch(() => {});
else video.pause();
};
const seekTo = useCallback((next: number) => {
const video = videoRef.current;
if (!video?.duration) return;
video.currentTime = Math.min(1, Math.max(0, next)) * video.duration;
setFraction(next);
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLDialogElement>) => {
if (event.target instanceof HTMLInputElement) return;
// Alt+Seta é "voltar/avançar" do navegador
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
if (event.key === "ArrowLeft") navigate(index - 1);
if (event.key === "ArrowRight") navigate(index + 1);
};
// clique no fundo escuro fecha, mas só se o toque também começou nele (arrastar a barra de posição e soltar fora não conta)
const onBackdropPointerDown = (event: PointerEvent<HTMLDialogElement>) => {
pressedOnBackdrop.current = event.target === event.currentTarget;
};
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>) => {
if (event.target === event.currentTarget && pressedOnBackdrop.current)
dialogRef.current?.close();
pressedOnBackdrop.current = false;
};
const video = (
// biome-ignore lint/a11y/useMediaCaption: demonstração sem áudio; a descrição está no título e nos capítulos
<video
key={`${config.id}-${device}`}
ref={videoRef}
className="ds-lb-video"
autoPlay={autoplay}
muted
loop
playsInline
preload="auto"
poster={assets.poster}
width={assets.width}
height={assets.height}
onLoadedMetadata={handleMetadata}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={handleTime}
aria-label={`Vídeo de ${project.title}, versão ${device === "desktop" ? "para computador" : "para celular"}`}
>
{assets.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
return (
<dialog
ref={dialogRef}
className="ds-lb"
aria-labelledby={titleId}
data-lenis-prevent
style={{ "--accent": config.accent } as CSSProperties}
onClose={(event) => {
// um `close` atrasado de uma janela que já foi reaberta (StrictMode) não pode fechar a nova
if (!event.currentTarget.open) onClose();
}}
onPointerDown={onBackdropPointerDown}
onClick={onBackdropClick}
onKeyDown={onKeyDown}
>
<div className="ds-lb-panel">
<p className="sr-only" aria-live="polite">
{`${project.title}, projeto ${index + 1} de ${items.length}`}
</p>
<header className="ds-lb-head">
<div className="ds-lb-heading">
<p className="ds-lb-count">
{String(index + 1).padStart(2, "0")} /{" "}
{String(items.length).padStart(2, "0")}
</p>
<h3 id={titleId} className="ds-lb-title">
{project.title}
</h3>
</div>
<fieldset className="ds-lb-toggle">
<legend className="sr-only">Versão do site</legend>
{(["desktop", "mobile"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={device === option}
onClick={() => switchDevice(option)}
>
{option === "desktop" ? "Computador" : "Celular"}
</button>
))}
</fieldset>
<div className="ds-lb-nav">
<button
ref={previousRef}
type="button"
onClick={() => navigate(index - 1)}
disabled={index === 0}
aria-label="Projeto anterior"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M10 3 5 8l5 5" />
</svg>
</button>
<button
ref={nextRef}
type="button"
onClick={() => navigate(index + 1)}
disabled={index === items.length - 1}
aria-label="Próximo projeto"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="m6 3 5 5-5 5" />
</svg>
</button>
<button
ref={closeRef}
type="button"
className="ds-lb-close"
onClick={() => dialogRef.current?.close()}
aria-label="Fechar"
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="m3 3 10 10M13 3 3 13" />
</svg>
</button>
</div>
</header>
<div className="ds-lb-stage" data-device={device}>
{device === "desktop" ? (
<BrowserFrame url={url} className="ds-lb-frame">
<div
className="ds-lb-screen"
style={{ backgroundImage: `url(${assets.poster})` }}
>
{video}
</div>
</BrowserFrame>
) : (
<PhoneFrame className="ds-lb-phone" poster={assets.poster}>
{video}
</PhoneFrame>
)}
</div>
<div className="ds-lb-controls">
<button
type="button"
className="ds-lb-play"
onClick={togglePlay}
aria-label={playing ? "Pausar" : "Reproduzir"}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
{playing ? (
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
) : (
<path d="M4 2.5v11l9-5.5z" />
)}
</svg>
</button>
<span className="ds-lb-time" aria-hidden="true">
{formatTime(fraction * duration)} / {formatTime(duration)}
</span>
<input
type="range"
className="ds-lb-range"
min={0}
max={1000}
step={1}
value={Math.round(fraction * 1000)}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
seekTo(Number(event.target.value) / 1000)
}
aria-label="Posição do vídeo"
aria-valuetext={`${formatTime(fraction * duration)} de ${formatTime(duration)}`}
/>
</div>
<ol className="ds-lb-chapters" aria-label="Trechos do site">
{config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
aria-current={i === chapter ? "true" : undefined}
onClick={() => seekTo(chapterFraction(config, i, device))}
>
<span aria-hidden="true">{String(i + 1).padStart(2, "0")}</span>{" "}
{entry.label}
</button>
</li>
))}
</ol>
<div className="ds-lb-links">
{canOpenSite && (
<a
className="ds-link ds-link-accent"
href={project.demoUrl ?? undefined}
target="_blank"
rel="noreferrer"
>
Abrir projeto
<span className="sr-only"> (abre em nova aba)</span>
</a>
)}
{project.githubUrl && (
<a
className="ds-link ds-link-muted"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
>
Ver codigo
<span className="sr-only"> (abre em nova aba)</span>
</a>
)}
</div>
</div>
</dialog>
);
}
@@ -0,0 +1,177 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { ProjectMedia } from "./ProjectMedia";
import { ShowcaseProvider } from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
jest.mock("./ProjectLightbox", () => ({
ProjectLightbox: () => <div role="dialog" aria-label="ampliado" />,
}));
const projects = getFeaturedProjects(null);
const nutriscan = projects.find((project) => project.id === "nutriscan");
if (!nutriscan) throw new Error("projeto ausente");
type Callback = IntersectionObserverCallback;
const callbacks: Callback[] = [];
const Original = global.IntersectionObserver;
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
callbacks.length = 0;
global.IntersectionObserver = class {
constructor(callback: Callback) {
callbacks.push(callback);
}
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
global.IntersectionObserver = Original;
});
function renderCard() {
const view = render(
<ShowcaseProvider projects={projects}>
<article className="ds-card">
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
</article>
</ShowcaseProvider>,
);
const frame = view.container.querySelector(".ds-frame") as HTMLElement;
frame.getBoundingClientRect = () =>
({
top: 300,
height: 300,
bottom: 600,
left: 0,
right: 400,
width: 400,
x: 0,
y: 300,
}) as DOMRect;
return { ...view, frame };
}
function show(frame: HTMLElement) {
act(() => {
for (const callback of callbacks)
callback(
[
{
target: frame,
intersectionRatio: 1,
isIntersecting: true,
} as unknown as IntersectionObserverEntry,
],
{} as IntersectionObserver,
);
jest.advanceTimersByTime(40);
});
}
describe("ProjectMedia", () => {
it("frames the video with the project's address and a button that enlarges it", () => {
renderCard();
expect(screen.getByText("nutriscan.evandro.dev.br")).toBeInTheDocument();
const expand = screen.getByRole("button", {
name: "Ampliar o vídeo de NutriScan",
});
expect(expand).toHaveAttribute("aria-haspopup", "dialog");
fireEvent.click(expand);
expect(
screen.getByRole("dialog", { name: "ampliado" }),
).toBeInTheDocument();
});
it("plays only while the card is the active one", () => {
const { frame, container } = renderCard();
const video = container.querySelector("video") as HTMLVideoElement;
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"false",
);
expect(video.querySelectorAll("source")).toHaveLength(0);
show(frame);
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"true",
);
expect(video.querySelectorAll("source")).toHaveLength(2);
});
it("lets the visitor pause the videos that play on their own, and resume them", () => {
const { frame, container } = renderCard();
show(frame);
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"true",
);
fireEvent.click(
screen.getByRole("button", { name: "Pausar os vídeos dos projetos" }),
);
act(() => {
jest.advanceTimersByTime(40);
});
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"false",
);
fireEvent.click(
screen.getByRole("button", { name: "Retomar os vídeos dos projetos" }),
);
act(() => {
jest.advanceTimersByTime(40);
});
expect(container.querySelector(".ds-frame")).toHaveAttribute(
"data-active",
"true",
);
});
it("has no pause button when nothing plays on its own (reduced motion)", () => {
reducedMotion = true;
renderCard();
expect(
screen.queryByRole("button", { name: /vídeos dos projetos/ }),
).toBeNull();
});
it("renders nothing for a project without a recorded video", () => {
const { container } = render(
<ShowcaseProvider projects={projects}>
<ProjectMedia project={{ id: "twins-lab", title: "Twins Lab" }} />
</ShowcaseProvider>,
);
expect(container.querySelector(".ds-frame")).toBeNull();
});
it("does nothing, without crashing, when used outside the provider", () => {
const { container } = render(
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />,
);
fireEvent.click(
screen.getByRole("button", { name: "Ampliar o vídeo de NutriScan" }),
);
expect(container.querySelector(".ds-frame")).not.toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
"use client";
import type { CSSProperties } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { getProjectVideo, getShowcaseConfig } from "@/lib/project-showcase";
import { ProjectVideo } from "../ProjectVideo";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { useShowcaseCard } from "./ShowcaseProvider";
/**
* Capa do card com vídeo: o vídeo dentro de uma moldura de navegador, com brilho da cor do site quando é o
* card que toca, e o botão que amplia (a tela inteira é o botão).
*/
export function ProjectMedia({
project,
}: {
project: {
id: string;
title: string;
demoUrl?: string | null;
githubUrl?: string | null;
};
}) {
const { frameRef, active, open, userPaused, toggleAutoplay } =
useShowcaseCard(project.id);
const reducedMotion = useReducedMotion();
const video = getProjectVideo(project.id);
const config = getShowcaseConfig(project.id);
if (!video || !config) return null;
return (
<div
ref={frameRef}
className="ds-frame"
data-active={active}
style={{ "--accent": config.accent } as CSSProperties}
>
<BrowserFrame url={displayUrl(project)}>
<div
className="ds-frame-screen"
style={{ backgroundImage: `url(${video.poster})` }}
>
<ProjectVideo video={video} title={project.title} active={active} />
<button
type="button"
className="ds-frame-expand"
onClick={open}
aria-haspopup="dialog"
aria-label={`Ampliar o vídeo de ${project.title}`}
>
<span className="ds-frame-chip" aria-hidden="true">
Ampliar
</span>
</button>
{!reducedMotion && (
<button
type="button"
className="ds-frame-pause"
onClick={toggleAutoplay}
aria-label={
userPaused
? "Retomar os vídeos dos projetos"
: "Pausar os vídeos dos projetos"
}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
{userPaused ? (
<path d="M4 2.5v11l9-5.5z" />
) : (
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
)}
</svg>
</button>
)}
</div>
</BrowserFrame>
</div>
);
}
@@ -0,0 +1,277 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import {
buildShowcaseItems,
ShowcaseProvider,
useShowcaseCard,
} from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
jest.mock("./ProjectLightbox", () => ({
ProjectLightbox: ({
index,
onClose,
}: {
index: number;
onClose: () => void;
}) => (
<div role="dialog" aria-label="ampliado">
aberto:{index}
<button type="button" onClick={onClose}>
fechar
</button>
</div>
),
}));
type FakeObserver = {
callback: IntersectionObserverCallback;
targets: Set<Element>;
};
let observers: FakeObserver[] = [];
const OriginalObserver = global.IntersectionObserver;
class FakeIntersectionObserver {
private readonly state: FakeObserver;
constructor(callback: IntersectionObserverCallback) {
this.state = { callback, targets: new Set() };
observers.push(this.state);
}
observe(element: Element) {
this.state.targets.add(element);
}
unobserve(element: Element) {
this.state.targets.delete(element);
}
disconnect() {
this.state.targets.clear();
}
takeRecords() {
return [];
}
}
function setVisibility(element: Element, ratio: number) {
for (const observer of observers) {
if (!observer.targets.has(element)) continue;
act(() => {
observer.callback(
[
{
target: element,
intersectionRatio: ratio,
isIntersecting: ratio > 0,
} as IntersectionObserverEntry,
],
observer as unknown as IntersectionObserver,
);
});
}
}
function place(element: Element, top: number) {
element.getBoundingClientRect = () =>
({
top,
height: 300,
bottom: top + 300,
left: 0,
right: 400,
width: 400,
x: 0,
y: top,
}) as DOMRect;
}
function flush() {
act(() => {
jest.advanceTimersByTime(40);
});
}
function Card({ id }: { id: string }) {
const { frameRef, active, open } = useShowcaseCard(id);
return (
<article className="ds-card">
<div ref={frameRef} data-testid={id} data-active={active} />
<button type="button" onClick={open}>
ampliar {id}
</button>
</article>
);
}
const projects = getFeaturedProjects(null);
function setup() {
const view = render(
<ShowcaseProvider projects={projects}>
<Card id="sev-modaintima" />
<Card id="lavacao-047" />
<Card id="nutriscan" />
</ShowcaseProvider>,
);
const frames = {
sev: screen.getByTestId("sev-modaintima"),
lavacao: screen.getByTestId("lavacao-047"),
nutri: screen.getByTestId("nutriscan"),
};
// tela de 768px de altura (jsdom): o meio fica em 384
place(frames.sev, 100); // centro 250
place(frames.lavacao, 230); // centro 380
place(frames.nutri, 900); // fora da tela
return { view, frames };
}
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
observers = [];
global.IntersectionObserver =
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
jest.useRealTimers();
global.IntersectionObserver = OriginalObserver;
});
const isActive = (element: Element) =>
element.getAttribute("data-active") === "true";
describe("ShowcaseProvider", () => {
it("lists only projects that have a recorded showcase, in showcase order", () => {
const items = buildShowcaseItems(projects);
expect(items.map((item) => item.config.id)).toEqual([
"sev-modaintima",
"lavacao-047",
"nutriscan",
"sc-plus",
"as-servicos",
]);
expect(
buildShowcaseItems(projects.filter((p) => p.id === "twins-lab")),
).toEqual([]);
});
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
const { frames } = setup();
flush();
expect(Object.values(frames).some(isActive)).toBe(false);
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
expect(isActive(frames.lavacao)).toBe(true);
expect(isActive(frames.sev)).toBe(false);
});
it("does not count a card with less than a third on screen", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.2);
setVisibility(frames.lavacao, 0.1);
flush();
expect(Object.values(frames).some(isActive)).toBe(false);
});
it("hands the video to the card under the mouse and takes it back when the mouse leaves", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
const sevCard = frames.sev.closest(".ds-card") as HTMLElement;
fireEvent.pointerEnter(sevCard, { pointerType: "mouse" });
flush();
expect(isActive(frames.sev)).toBe(true);
expect(isActive(frames.lavacao)).toBe(false);
fireEvent.pointerLeave(sevCard, { pointerType: "mouse" });
flush();
expect(isActive(frames.lavacao)).toBe(true);
});
it("ignores touch pointers: there is no hover on a phone", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
fireEvent.pointerEnter(frames.sev.closest(".ds-card") as HTMLElement, {
pointerType: "touch",
});
flush();
expect(isActive(frames.sev)).toBe(false);
expect(isActive(frames.lavacao)).toBe(true);
});
it("follows keyboard focus inside a card", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
fireEvent.focusIn(
screen.getByRole("button", { name: "ampliar sev-modaintima" }),
);
flush();
expect(isActive(frames.sev)).toBe(true);
});
it("re-evaluates on scroll", () => {
const { frames } = setup();
setVisibility(frames.sev, 0.8);
setVisibility(frames.lavacao, 0.8);
flush();
expect(isActive(frames.lavacao)).toBe(true);
place(frames.sev, 230);
place(frames.lavacao, 600);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
flush();
expect(isActive(frames.sev)).toBe(true);
});
it("pauses every card while the enlarged window is open, and resumes after", () => {
const { frames } = setup();
setVisibility(frames.lavacao, 0.8);
flush();
expect(isActive(frames.lavacao)).toBe(true);
fireEvent.click(
screen.getByRole("button", { name: "ampliar lavacao-047" }),
);
flush();
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
"aberto:1",
);
expect(isActive(frames.lavacao)).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
flush();
expect(screen.queryByRole("dialog")).toBeNull();
expect(isActive(frames.lavacao)).toBe(true);
});
it("never plays a card for users who prefer reduced motion", () => {
reducedMotion = true;
const { frames } = setup();
setVisibility(frames.lavacao, 1);
flush();
expect(isActive(frames.lavacao)).toBe(false);
});
it("stops listening when it unmounts", () => {
const { view, frames } = setup();
setVisibility(frames.lavacao, 1);
view.unmount();
expect(observers.every((observer) => observer.targets.size === 0)).toBe(
true,
);
});
});
@@ -0,0 +1,251 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
import { ProjectLightbox } from "./ProjectLightbox";
import { pickActiveCard } from "./showcase-math";
import "./showcase.css";
export interface ShowcaseItem {
config: ShowcaseConfig;
project: PortfolioProjectView;
}
interface ShowcaseApi {
register(id: string, element: HTMLElement): () => void;
setHover(id: string, hovered: boolean): void;
open(id: string): void;
/** Pausa (ou retoma) os vídeos que tocam sozinhos nos cards: quem não quer movimento na página tem como desligar. */
toggleAutoplay(): void;
items: ShowcaseItem[];
}
interface ActiveState {
activeId: string | null;
userPaused: boolean;
}
const ApiContext = createContext<ShowcaseApi | null>(null);
const ActiveContext = createContext<ActiveState>({
activeId: null,
userPaused: false,
});
/** Só aparece como card com vídeo o projeto que existe na lista e tem vídeo gravado. */
export function buildShowcaseItems(
projects: PortfolioProjectView[],
): ShowcaseItem[] {
const items: ShowcaseItem[] = [];
for (const config of SHOWCASE) {
const project = projects.find((candidate) => candidate.id === config.id);
if (project) items.push({ config, project });
}
return items;
}
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
export function useShowcaseItems() {
return useContext(ApiContext)?.items ?? [];
}
/** Abre a janela ampliada de um projeto (nada acontece fora do provedor). */
export function useShowcaseOpen() {
const api = useContext(ApiContext);
return useCallback((id: string) => api?.open(id), [api]);
}
/**
* Um vídeo por vez nos cards: toca o card sob o mouse (ou com foco); sem mouse sobre nenhum, o mais perto do
* centro da tela. Também guarda qual card está ampliado e mostra a janela ampliada.
*/
export function ShowcaseProvider({
projects,
children,
}: {
projects: PortfolioProjectView[];
children: ReactNode;
}) {
const reducedMotion = useReducedMotion();
const items = useMemo(() => buildShowcaseItems(projects), [projects]);
const [activeId, setActiveId] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [userPaused, setUserPaused] = useState(false);
const cards = useRef(
new Map<string, { el: HTMLElement; visible: boolean }>(),
);
const hovered = useRef<string | null>(null);
const paused = useRef(false);
const queued = useRef(false);
const frame = useRef(0);
const observer = useRef<IntersectionObserver | null>(null);
const evaluate = useCallback(() => {
queued.current = false;
if (paused.current || document.hidden) {
setActiveId(null);
return;
}
const middle = window.innerHeight / 2;
const candidates = [...cards.current].map(([id, { el, visible }]) => {
// só quem está à vista entra na disputa: os outros nem têm o layout lido a cada quadro de rolagem
if (!visible) return { id, visible, distance: Number.POSITIVE_INFINITY };
const rect = el.getBoundingClientRect();
return {
id,
visible,
distance: Math.abs(rect.top + rect.height / 2 - middle),
};
});
setActiveId(pickActiveCard(candidates, hovered.current));
}, []);
const schedule = useCallback(() => {
if (queued.current) return;
queued.current = true;
frame.current = requestAnimationFrame(evaluate);
}, [evaluate]);
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
useEffect(() => {
paused.current = reducedMotion || openId !== null || userPaused;
schedule();
}, [schedule, openId, reducedMotion, userPaused]);
useEffect(() => {
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
document.addEventListener("visibilitychange", schedule);
return () => {
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
document.removeEventListener("visibilitychange", schedule);
cancelAnimationFrame(frame.current);
queued.current = false;
observer.current?.disconnect();
observer.current = null;
};
}, [schedule]);
const api = useMemo<ShowcaseApi>(
() => ({
items,
register(id, element) {
if (!observer.current && typeof IntersectionObserver !== "undefined") {
observer.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
for (const card of cards.current.values()) {
// um card só vale como "à vista" com pelo menos um terço dele na tela
if (card.el === entry.target)
card.visible = entry.intersectionRatio >= 0.33;
}
}
schedule();
},
{ threshold: [0, 0.33, 0.66, 1] },
);
}
cards.current.set(id, { el: element, visible: false });
observer.current?.observe(element);
return () => {
observer.current?.unobserve(element);
cards.current.delete(id);
if (hovered.current === id) hovered.current = null;
schedule();
};
},
setHover(id, isHovered) {
if (isHovered) hovered.current = id;
else if (hovered.current === id) hovered.current = null;
schedule();
},
open(id) {
setOpenId(id);
},
toggleAutoplay() {
setUserPaused((paused) => !paused);
},
}),
[items, schedule],
);
const activeState = useMemo(
() => ({ activeId, userPaused }),
[activeId, userPaused],
);
const openIndex = openId
? items.findIndex((item) => item.config.id === openId)
: -1;
return (
<ApiContext.Provider value={api}>
<ActiveContext.Provider value={activeState}>
{children}
{openIndex >= 0 && (
<ProjectLightbox
items={items}
index={openIndex}
onClose={() => setOpenId(null)}
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
/>
)}
</ActiveContext.Provider>
</ApiContext.Provider>
);
}
/** Liga um card à vitrine: devolve a ref da moldura, se ele é o card que toca agora e como ampliá-lo. */
export function useShowcaseCard(id: string) {
const api = useContext(ApiContext);
const { activeId, userPaused } = useContext(ActiveContext);
const frameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = frameRef.current;
if (!api || !element) return;
const unregister = api.register(id, element);
const card = element.closest<HTMLElement>(".ds-card") ?? element;
const enter = (event: PointerEvent) => {
// no toque não existe "passar o mouse": só o mouse e a caneta mandam na vitrine
if (event.pointerType !== "touch") api.setHover(id, true);
};
const leave = () => api.setHover(id, false);
const focusIn = () => api.setHover(id, true);
const focusOut = (event: FocusEvent) => {
if (!card.contains(event.relatedTarget as Node | null))
api.setHover(id, false);
};
card.addEventListener("pointerenter", enter);
card.addEventListener("pointerleave", leave);
card.addEventListener("focusin", focusIn);
card.addEventListener("focusout", focusOut);
return () => {
unregister();
card.removeEventListener("pointerenter", enter);
card.removeEventListener("pointerleave", leave);
card.removeEventListener("focusin", focusIn);
card.removeEventListener("focusout", focusOut);
};
}, [api, id]);
return {
frameRef,
active: activeId === id,
userPaused,
toggleAutoplay: useCallback(() => api?.toggleAutoplay(), [api]),
open: useCallback(() => api?.open(id), [api, id]),
};
}
@@ -0,0 +1,104 @@
import {
easeToward,
locateInReel,
pickActiveCard,
reelStarts,
videoProgress,
} from "./showcase-math";
const card = (id: string, visible: boolean, distance: number) => ({
id,
visible,
distance,
});
describe("pickActiveCard", () => {
it("plays the visible card closest to the middle of the screen", () => {
expect(
pickActiveCard(
[card("a", true, 300), card("b", true, 80), card("c", false, 5)],
null,
),
).toBe("b");
});
it("lets the hovered card win over a closer one, but only while it is visible", () => {
const cards = [card("a", true, 10), card("b", true, 400)];
expect(pickActiveCard(cards, "b")).toBe("b");
expect(
pickActiveCard([card("a", true, 10), card("b", false, 0)], "b"),
).toBe("a");
});
it("keeps the first card on a tie, so the left one of a row wins", () => {
expect(
pickActiveCard(
[card("left", true, 120), card("right", true, 120.3)],
null,
),
).toBe("left");
});
it("plays nothing when no card is on screen", () => {
expect(pickActiveCard([card("a", false, 0)], null)).toBeNull();
expect(pickActiveCard([], null)).toBeNull();
});
});
describe("locateInReel", () => {
const weights = [1, 2, 1];
it("maps overall progress to a project and a position inside it", () => {
expect(locateInReel(weights, 0)).toEqual({ index: 0, local: 0 });
expect(locateInReel(weights, 0.125)).toEqual({ index: 0, local: 0.5 });
const middle = locateInReel(weights, 0.5);
expect(middle.index).toBe(1);
expect(middle.local).toBeCloseTo(0.5);
expect(locateInReel(weights, 1)).toEqual({ index: 2, local: 1 });
});
it("clamps progress outside the pinned range", () => {
expect(locateInReel(weights, -3)).toEqual({ index: 0, local: 0 });
expect(locateInReel(weights, 7)).toEqual({ index: 2, local: 1 });
});
it("copes with an empty reel", () => {
expect(locateInReel([], 0.4)).toEqual({ index: 0, local: 0 });
});
it("gives each project the share of the scroll that its weight asks for", () => {
expect(reelStarts(weights)).toEqual([0, 0.25, 0.75]);
});
});
describe("videoProgress", () => {
it("holds still at the start and at the end of each project's stretch", () => {
expect(videoProgress(0)).toBe(0);
expect(videoProgress(0.1)).toBe(0);
expect(videoProgress(0.5)).toBeCloseTo(0.5);
expect(videoProgress(0.9)).toBe(1);
expect(videoProgress(1)).toBe(1);
});
});
describe("easeToward", () => {
it("moves toward the target and never overshoots", () => {
const first = easeToward(0, 1, 1 / 60);
expect(first).toBeGreaterThan(0);
expect(first).toBeLessThan(1);
expect(easeToward(0.5, 0.5, 1)).toBe(0.5);
expect(easeToward(0, 1, 10)).toBeCloseTo(1, 5);
});
it("does not depend on the frame rate", () => {
let sixty = 0;
for (let i = 0; i < 60; i += 1) sixty = easeToward(sixty, 1, 1 / 60);
let thirty = 0;
for (let i = 0; i < 30; i += 1) thirty = easeToward(thirty, 1, 1 / 30);
expect(sixty).toBeCloseTo(thirty, 6);
});
it("stands still when no time has passed", () => {
expect(easeToward(0.2, 1, 0)).toBe(0.2);
});
});
+91
View File
@@ -0,0 +1,91 @@
/** Contas puras da vitrine (sem DOM), separadas para serem testadas uma a uma. */
export interface CardCandidate {
id: string;
visible: boolean;
/** Distância do centro do card ao centro da tela, em pixels. */
distance: number;
}
/**
* Qual card toca: o que está sob o mouse (se visível); senão o visível mais perto do centro da tela.
* Empate fica com o primeiro da lista (ordem do DOM: esquerda antes de direita).
*/
export function pickActiveCard(
candidates: CardCandidate[],
hoveredId: string | null,
): string | null {
const visible = candidates.filter((candidate) => candidate.visible);
if (hoveredId && visible.some((candidate) => candidate.id === hoveredId))
return hoveredId;
let best: CardCandidate | null = null;
for (const candidate of visible) {
if (!best || candidate.distance < best.distance - 0.5) best = candidate;
}
return best?.id ?? null;
}
export interface ReelPosition {
/** Projeto em cena. */
index: number;
/** Quanto do trecho do projeto já passou (0 a 1). */
local: number;
}
/** Em que projeto e em que ponto dele a rolagem está, dado o avanço total `t` (0 a 1) e o peso de cada trecho. */
export function locateInReel(weights: number[], t: number): ReelPosition {
const total = weights.reduce((sum, weight) => sum + weight, 0);
if (weights.length === 0 || total <= 0) return { index: 0, local: 0 };
const position = Math.min(1, Math.max(0, t)) * total;
let start = 0;
for (let index = 0; index < weights.length; index += 1) {
const end = start + weights[index];
if (position < end || index === weights.length - 1) {
const local = (position - start) / weights[index];
return { index, local: Math.min(1, Math.max(0, local)) };
}
start = end;
}
return { index: weights.length - 1, local: 1 };
}
/** Início do trecho de cada projeto, como fração (0 a 1) do avanço total. */
export function reelStarts(weights: number[]): number[] {
const total = weights.reduce((sum, weight) => sum + weight, 0);
let acc = 0;
return weights.map((weight) => {
const start = total > 0 ? acc / total : 0;
acc += weight;
return start;
});
}
/** 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;
/** 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);
if (total <= 0 || index < 0 || index >= weights.length) return 0;
return reelStarts(weights)[index] + (local * weights[index]) / total;
}
/**
* 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;
return Math.min(1, Math.max(0, (local - hold) / span));
}
/** Aproxima `current` de `target` de forma suave, independente da taxa de quadros (`tau` em segundos). */
export function easeToward(
current: number,
target: number,
deltaSeconds: number,
tau = 0.07,
): number {
const factor = 1 - Math.exp(-Math.max(0, deltaSeconds) / tau);
return current + (target - current) * factor;
}
File diff suppressed because it is too large. Load diff