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

+33 -79
View File
@@ -1,11 +1,5 @@
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { act, render, screen } from "@testing-library/react";
import {
getProjectVideo,
PROJECT_VIDEO_IDS,
type ProjectVideoAsset,
} from "@/lib/project-videos";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { ProjectVideo } from "./ProjectVideo";
let reducedMotion = false;
@@ -13,59 +7,24 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
const video: ProjectVideoAsset = {
poster: "/videos/demo/desktop.webp",
width: 1440,
height: 900,
sources: [
{ src: "/videos/demo/desktop.webm", type: 'video/webm; codecs="vp9"' },
{ src: "/videos/demo/desktop.mp4", type: "video/mp4" },
],
};
type Callback = IntersectionObserverCallback;
let callbacks: Callback[] = [];
const originalObserver = global.IntersectionObserver;
const video = getShowcaseAssets("nutriscan", "desktop");
beforeEach(() => {
reducedMotion = false;
callbacks = [];
// observador controlável: o teste decide quando o card entra e sai da tela
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(() => {
global.IntersectionObserver = originalObserver;
jest.restoreAllMocks();
});
function setVisible(isIntersecting: boolean) {
act(() => {
for (const callback of callbacks) {
callback(
[{ isIntersecting } as IntersectionObserverEntry],
{} as IntersectionObserver,
);
}
});
}
describe("ProjectVideo", () => {
it("renders a muted looping inline video that downloads nothing before it is visible", () => {
const { container } = render(<ProjectVideo video={video} title="Demo" />);
it("renders a muted looping inline video that downloads nothing until it is active", () => {
const { container } = render(
<ProjectVideo video={video} title="Demo" active={false} />,
);
const element = container.querySelector("video");
expect(element).not.toBeNull();
@@ -75,29 +34,47 @@ describe("ProjectVideo", () => {
expect(element).toHaveAttribute("preload", "none");
expect(element).toHaveAttribute("poster", video.poster);
expect(element?.muted).toBe(true);
expect(element).toHaveAttribute("data-ready", "false");
expect(container.querySelectorAll("source")).toHaveLength(0);
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
});
it("attaches the sources and plays once the card enters the viewport, then pauses when it leaves", () => {
const { container } = render(<ProjectVideo video={video} title="Demo" />);
it("attaches the sources and plays when it becomes the active card, then pauses when it stops being", () => {
const { container, rerender } = render(
<ProjectVideo video={video} title="Demo" active={false} />,
);
setVisible(true);
rerender(<ProjectVideo video={video} title="Demo" active />);
const sources = [...container.querySelectorAll("source")];
expect(sources.map((source) => source.getAttribute("src"))).toEqual([
"/videos/demo/desktop.webm",
"/videos/demo/desktop.mp4",
]);
expect(sources.map((source) => source.getAttribute("src"))).toEqual(
video.sources.map((source) => source.src),
);
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
setVisible(false);
rerender(<ProjectVideo video={video} title="Demo" active={false} />);
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
// sai de cena, mas o arquivo já baixado fica: voltar a tocar não baixa de novo
expect(container.querySelectorAll("source")).toHaveLength(2);
});
it("fades the video in once it is playing", () => {
const { container } = render(
<ProjectVideo video={video} title="Demo" active />,
);
const element = container.querySelector("video") as HTMLVideoElement;
act(() => {
element.dispatchEvent(new Event("playing"));
});
expect(element).toHaveAttribute("data-ready", "true");
});
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
reducedMotion = true;
const { container } = render(<ProjectVideo video={video} title="Demo" />);
const { container } = render(
<ProjectVideo video={video} title="Demo" active />,
);
expect(container.querySelector("video")).toBeNull();
expect(
@@ -106,26 +83,3 @@ describe("ProjectVideo", () => {
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
});
});
describe("getProjectVideo", () => {
it("returns undefined for projects without a recorded video", () => {
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
});
it.each(PROJECT_VIDEO_IDS)(
"has webm, mp4 and poster on disk for %s, each within 3 MB",
(id) => {
const asset = getProjectVideo(id);
expect(asset).toBeDefined();
const files = [
asset?.poster,
...(asset?.sources.map((s) => s.src) ?? []),
];
for (const file of files) {
const path = join(process.cwd(), "public", file ?? "");
expect(existsSync(path)).toBe(true);
expect(statSync(path).size).toBeLessThanOrEqual(3 * 1024 * 1024);
}
},
);
});