Compare commits

...
Author SHA1 Message Date
Evandro Fonseca Junior 3756905a1b fix(transicao): a home nao pisca mais duas vezes ao abrir
CI / verify (push) Failing after 6m28s
A hidratacao e a revelacao de Suspense tambem sao transicoes do React, e o <ViewTransition name="page"> do template animava a pagina inteira (page-out/page-in) nas duas, logo na chegada. Com update="none", so a troca de pagina anima.
2026-10-14 01:13:44 -03:00
Evandro Fonseca Junior 838b96349f chore: notas de agentes e videos extras
AGENTS.md/CLAUDE.md e videos-extras/ (rolagem da Adriana Imoveis e cortes 1080x1350 para redes sociais).
2026-10-14 00:42:50 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 d61ec7479f fix(showcase): video ampliado ao lado dos controles tambem em janelas de desktop curtas
CI / verify (push) Failing after 5m54s
Em 1280x600 o video ficava com 352x250; com os controles em coluna ele passa a 835x553.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:39:34 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 01bbebcf52 docs(videos): script de recodificacao para rolagem controlada e README da vitrine
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:01 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 ba05cdfe61 perf(videos): cache de um dia, com revalidacao em segundo plano, para /videos
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:01 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 ec760330d4 feat(og): capa do projeto como fundo da imagem de compartilhamento dos cases
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 540fe9f87a feat(showcase): vitrine animada presa na rolagem, com capitulos e celular sincronizado
Abaixo dos cards, uma cena sticky em que o site de cada projeto rola dentro de um notebook
(com um celular ao lado) conforme a pagina rola. Cada projeto tem um trecho com pausa no
comeco e no fim; capitulos nomeiam o que esta na tela, levam ao ponto do video e mantem
notebook e celular no mesmo trecho do site. So o projeto em cena e os vizinhos baixam video
(nada com economizar dados ligado, e o notebook escondido no celular nao baixa). Um bloco da
mesma altura segura o lugar antes de montar, para saltos como #contact cairem certo.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:37:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 a1a1e91135 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]>
2026-10-08 16:36:53 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 60dc2f1878 feat(videos): versoes para rolagem controlada (quadro-chave a cada 6 quadros) e capas de compartilhamento
scrub-desktop.mp4 e scrub-mobile.mp4 de cada projeto deixam a imagem acompanhar a
rolagem sem travar; og.jpg e a capa 1200x630 das imagens Open Graph. Ate 3 MB cada.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:36:53 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 c0493f1e77 feat(github): usar o ultimo retrato da contagem quando nao ha token ou a API falha
data/github-stats.json guarda 68 repositorios proprios (25 publicos e 43
privados, sem forks) conferidos em 2026-10-08. Com token valido a contagem
ao vivo vale mais; o retrato evita que o hero fique sem o numero.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:26:57 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 942f6bb45c feat(home): tirar o selo 'com demonstracao online' do hero
O lugar passa a ser da contagem de projetos desenvolvidos do GitHub.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 15:26:57 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 a67892613b feat(projects): video de rolagem na capa dos cards, carregado so quando o card aparece
video autoplay muted loop playsinline preload=none com poster; as fontes entram
via IntersectionObserver e o video pausa fora da tela. Com prefers-reduced-motion
aparece so o poster.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:17 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 ca9c0604b8 feat(projects): incluir a Lavacao 047 no portfolio
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:17 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 84786cf1d7 feat(videos): rolagem de AS Servicos (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 f08d4e799c feat(videos): rolagem de SC Plus (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2fd16c09e2 feat(videos): rolagem de NutriScan (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 778227577a feat(videos): rolagem de Lavacao 047 (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:16 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 1f34cfe4f1 feat(videos): rolagem de SEV Moda Intima (desktop 1440x900 e mobile 390x844)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:15 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 b74044b529 chore(videos): script que grava a rolagem de uma pagina e gera MP4, WebM e poster
Playwright headless com GPU, rolagem em velocidade constante (ate 30 s), corte
por quadros-marcador e saida com ate 3 MB por video.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 11:33:15 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 168ee9a180 fix(home): arredondar para baixo o 'Mais de N' dos projetos do GitHub
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 10:28:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 0350e7e883 feat(home): trocar o texto fixo de projetos publicados pela contagem real do GitHub
O hero mostra 'Mais de X projetos desenvolvidos' com o detalhamento de
publicos e privados; sem a contagem (token ausente ou API fora) o item some.
A home passa a revalidar uma vez por dia. README documenta o token.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 04:12:03 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 4ba955e2c3 feat(github): contar repositorios proprios (publicos e privados) pela API no servidor
Consulta /user/repos com token fine-grained somente leitura de metadados
(GITHUB_STATS_TOKEN), pagina, exclui forks e arquivados e devolve null se
o token faltar ou a API falhar. Renovacao diaria via revalidate de 86400 s.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 04:12:03 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 b2f3899dc2 fix(mobile): sem tremido nas transicoes com rolagem rapida no celular
CI / verify (push) Failing after 5m47s
A secao que sai fica presa com position: sticky (o compositor do navegador a
mantem em sincronia com a rolagem) em vez de ser deslocada pelo JavaScript.
No toque, a secao que entra sobe com o dedo: a trava de posicao pelo GSAP
chegava um quadro atrasada na rolagem nativa e tremia. Cada passagem mantem
um efeito proprio sem disputar a posicao. Folga de visibilidade cobre o
quadro de atraso. Posicoes das cenas somam alturas (sticky altera offsetTop).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 03:08:42 -03:00
95 changed files with 5945 additions and 210 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+24
View File
@@ -41,6 +41,30 @@ corepack [email protected] start
| `pnpm test:watch` | Executa Jest em modo watch. |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
## Contagem de projetos do GitHub
O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHub (`lib/github-stats.ts`), consultada só no servidor e renovada uma vez por dia (`revalidate = 86400` em `app/page.tsx`). Conta repositórios próprios, públicos e privados, sem forks e sem arquivados.
Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece.
## Vitrine de vídeos dos projetos
Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`.
- **Cards.** O vídeo de rolagem fica numa moldura de navegador com o endereço real do site. Só um vídeo toca por vez: o do card sob o mouse (ou com foco) e, sem mouse, o mais perto do centro da tela. Nada é baixado até o card tocar pela primeira vez, e um botão pausa os vídeos que se mexem sozinhos. Clicar na moldura abre o vídeo ampliado (`<dialog>` nativo, com troca computador/celular que mantém o ponto do site, capítulos clicáveis e controles próprios).
- **Vitrine animada.** Logo abaixo dos cards, uma cena presa na rolagem (`position: sticky`) em que o site de cada projeto rola dentro de um notebook, com um celular ao lado, conforme o visitante rola a página. Os capítulos de cada projeto (`chapters` em `lib/project-showcase.ts`, marcados nos quadros dos vídeos) nomeiam o trecho na tela e mantêm notebook e celular no mesmo ponto do site. Só o projeto em cena e os vizinhos baixam vídeo, e nada baixa com "economizar dados" ligado. Com `prefers-reduced-motion` ou sem JavaScript ela some e os cards continuam mostrando os posters.
- **Capa de compartilhamento.** `/cases/<id>` usa o poster do projeto como fundo da imagem Open Graph (`lib/og-card.tsx`), quando existe.
Arquivos de cada projeto em `public/videos/<id>/`:
| Arquivo | Para quê |
| --- | --- |
| `desktop.{webm,mp4,webp}` e `mobile.{webm,mp4,webp}` | cards e vídeo ampliado (até 3 MB cada vídeo) |
| `scrub-desktop.mp4` e `scrub-mobile.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem |
| `og.jpg` | capa 1200x630 de compartilhamento |
Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs`; para gerar os `scrub-*.mp4`, `scripts/encode-scrub-video.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho de cada script). Depois de gravar, marque os capítulos do projeto em `lib/project-showcase.ts`.
## Stack
| Área | Pacotes usados |
+7 -1
View File
@@ -1,6 +1,11 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
import {
OG_CONTENT_TYPE,
OG_SIZE,
OgCard,
readProjectOgBackground,
} from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
function getCaseStudy(slug: string) {
@@ -46,6 +51,7 @@ export default async function Image({
eyebrow="Case de engenharia de software"
title={caseStudy.title}
description={caseStudy.summary}
backgroundImage={readProjectOgBackground(caseStudy.id)}
/>,
OG_SIZE,
);
+37 -1
View File
@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero from "./Hero";
import Hero, { roundedRepoFloor } from "./Hero";
jest.mock("motion/react", () => {
const React = require("react");
@@ -81,4 +81,40 @@ describe("Hero", () => {
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
});
it("shows the real GitHub repo count with the public/private breakdown", () => {
render(<Hero stats={{ repos: { total: 68, public: 25, private: 43 } }} />);
expect(screen.getByText("projetos desenvolvidos")).toBeInTheDocument();
expect(screen.getByText("Mais de")).toBeInTheDocument();
expect(screen.getByText("65")).toBeInTheDocument();
expect(screen.getByText("25 públicos · 43 privados")).toBeInTheDocument();
expect(screen.queryByText(/projetos publicados/i)).not.toBeInTheDocument();
expect(screen.queryByText(/demonstração online/i)).not.toBeInTheDocument();
});
it("rounds the repo count down so 'mais de' is always true", () => {
expect(roundedRepoFloor(68)).toBe(65);
expect(roundedRepoFloor(66)).toBe(65);
expect(roundedRepoFloor(65)).toBe(60);
expect(roundedRepoFloor(6)).toBe(5);
expect(roundedRepoFloor(5)).toBe(0);
});
it("hides the repo count while the total is too small to round", () => {
render(<Hero stats={{ repos: { total: 4, public: 2, private: 2 } }} />);
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
});
it("omits the repo count when the GitHub lookup is unavailable", () => {
render(<Hero stats={{ years: 5 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
});
});
+45 -10
View File
@@ -17,20 +17,45 @@ const SOCIAL_LINKS = [
},
];
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
/**
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json;
* repos vem da API do GitHub (lib/github-stats.ts) e some quando nem a consulta nem o último retrato estão disponíveis.
*/
export type HeroStats = {
years?: number;
projects?: number;
demos?: number;
repos?: { total: number; public: number; private: number };
};
function statItems(stats?: HeroStats) {
type StatItem = {
value: string;
label: string;
prefix?: string;
detail?: string;
};
/** "Mais de N" só é verdade se N for estritamente menor que o total: arredonda para baixo, em múltiplos de 5. */
export function roundedRepoFloor(total: number) {
return Math.floor((total - 1) / 5) * 5;
}
function statItems(stats?: HeroStats): StatItem[] {
if (!stats) return [];
return [
stats.years ? [String(stats.years), "anos de experiência"] : null,
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
].filter((item): item is [string, string] => item !== null);
const { repos } = stats;
const repoFloor = repos ? roundedRepoFloor(repos.total) : 0;
const items: Array<StatItem | null> = [
stats.years
? { value: String(stats.years), label: "anos de experiência" }
: null,
repos && repoFloor > 0
? {
prefix: "Mais de",
value: String(repoFloor),
label: "projetos desenvolvidos",
detail: `${repos.public} públicos · ${repos.private} privados`,
}
: null,
];
return items.filter((item): item is StatItem => item !== null);
}
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
@@ -125,12 +150,22 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(([value, label]) => (
{items.map(({ prefix, value, label, detail }) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
{detail && (
<span className="block text-[12px] text-[rgba(244,242,237,.55)]">
{detail}
</span>
)}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{prefix && (
<span className="mr-sp-2 text-[13px] font-normal tracking-normal text-[rgba(244,242,237,.64)]">
{prefix}
</span>
)}
{value}
</dd>
</div>
+11 -1
View File
@@ -1,6 +1,13 @@
import Image from "next/image";
import { getProjectVideo } from "@/lib/project-showcase";
import { ProjectMedia } from "./showcase/ProjectMedia";
type CoverProject = { id: string; title: string };
type CoverProject = {
id: string;
title: string;
demoUrl?: string | null;
githubUrl?: string | null;
};
/** Capturas de tela publicadas dos projetos que têm interface pública. */
const SCREENSHOTS: Record<string, string | undefined> = {
@@ -31,8 +38,11 @@ export function ProjectCover({
project: CoverProject;
priority?: boolean;
}) {
const video = getProjectVideo(project.id);
const src = SCREENSHOTS[project.id];
if (video) return <ProjectMedia project={project} />;
if (src) {
return (
<div className="ds-cover">
+85
View File
@@ -0,0 +1,85 @@
import { act, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { ProjectVideo } from "./ProjectVideo";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
const video = getShowcaseAssets("nutriscan", "desktop");
beforeEach(() => {
reducedMotion = false;
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("ProjectVideo", () => {
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();
expect(element).toHaveAttribute("autoplay");
expect(element).toHaveAttribute("loop");
expect(element).toHaveAttribute("playsinline");
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 when it becomes the active card, then pauses when it stops being", () => {
const { container, rerender } = render(
<ProjectVideo video={video} title="Demo" active={false} />,
);
rerender(<ProjectVideo video={video} title="Demo" active />);
const sources = [...container.querySelectorAll("source")];
expect(sources.map((source) => source.getAttribute("src"))).toEqual(
video.sources.map((source) => source.src),
);
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
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" active />,
);
expect(container.querySelector("video")).toBeNull();
expect(
screen.getByRole("img", { name: "Captura de tela de Demo" }),
).toBeInTheDocument();
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
});
});
+84
View File
@@ -0,0 +1,84 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { ShowcaseAssets } from "@/lib/project-showcase";
/**
* Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que
* está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o
* card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece.
* O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
*/
export function ProjectVideo({
video,
title,
active,
}: {
video: ShowcaseAssets;
title: string;
active: boolean;
}) {
const reducedMotion = useReducedMotion();
const ref = useRef<HTMLVideoElement>(null);
const [attached, setAttached] = useState(false);
const [ready, setReady] = useState(false);
const playing = active && !reducedMotion;
useEffect(() => {
if (playing) setAttached(true);
}, [playing]);
useEffect(() => {
// as fontes entram depois do primeiro render: o elemento precisa ler a lista nova
if (attached) ref.current?.load();
}, [attached]);
useEffect(() => {
const element = ref.current;
if (!element || !attached) return;
if (playing) {
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
element.play().catch(() => {});
} else {
element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar
}
}, [attached, playing]);
if (reducedMotion) {
return (
<Image
src={video.poster}
alt={`Captura de tela de ${title}`}
width={video.width}
height={video.height}
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
/>
);
}
return (
<video
ref={ref}
autoPlay
muted
loop
playsInline
preload="none"
poster={video.poster}
width={video.width}
height={video.height}
data-ready={ready}
onPlaying={() => setReady(true)}
aria-label={`Demonstração em vídeo de ${title}`}
disablePictureInPicture
disableRemotePlayback
>
{attached &&
video.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}
+124 -112
View File
@@ -8,6 +8,8 @@ import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { ProjectCover } from "./ProjectCover";
import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel";
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
export default function Projects({
projects,
@@ -36,129 +38,139 @@ export default function Projects({
dark
disableChildrenAnimation
>
<div className="ds-grid-fluid">
{projects.map((project) => {
const caseStudy = caseStudies.find(
(entry) => entry.id === project.id,
);
return (
<motion.article
key={project.id}
className="ds-card h-full"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: "some" }}
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<ProjectCover project={project} />
<div className="flex flex-wrap items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
)}
<ShowcaseProvider projects={projects}>
<div className="ds-grid-fluid">
{projects.map((project) => {
const caseStudy = caseStudies.find(
(entry) => entry.id === project.id,
);
return (
<motion.article
key={project.id}
className="ds-card h-full"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: "some" }}
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<ProjectCover project={project} />
<div className="flex flex-wrap items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
)}
</div>
<MagneticButton
as="span"
className="ds-tag"
strength={0.15}
wrapperClassName="shrink-0 max-sm:ml-auto"
>
{project.status}
</MagneticButton>
</div>
<MagneticButton
as="span"
className="ds-tag"
strength={0.15}
wrapperClassName="shrink-0 max-sm:ml-auto"
>
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">
{caseStudy?.summary ?? project.description}
</p>
{caseStudy && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">O desafio</p>
<p className="ds-copy">{caseStudy.impact}</p>
</div>
)}
{caseStudy?.role && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">Minha participação</p>
<p className="ds-copy">{caseStudy.role}</p>
</div>
)}
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
<p className="ds-copy">
{caseStudy?.summary ?? project.description}
</p>
{caseStudy && (
<MagneticButton
as="a"
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
strength={0.2}
>
Entender a solução
</MagneticButton>
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">O desafio</p>
<p className="ds-copy">{caseStudy.impact}</p>
</div>
)}
{project.githubUrl && (
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
>
Ver codigo
</MagneticButton>
{caseStudy?.role && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">Minha participação</p>
<p className="ds-copy">{caseStudy.role}</p>
</div>
)}
{project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown") && (
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
<div
className="mt-sp-6 flex flex-wrap gap-sp-3"
data-reel="top"
>
{caseStudy && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
strength={0.25}
strength={0.2}
>
Abrir projeto
{project.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
)}
Entender a solução
</MagneticButton>
)}
{project.demoAvailability === "unavailable" && (
<span className="ds-link ds-link-accent" aria-disabled="true">
Demo indisponível na última verificação
</span>
)}
</div>
</motion.article>
);
})}
</div>
{project.githubUrl && (
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
>
Ver codigo
</MagneticButton>
)}
{project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown") && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
{project.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
)}
</MagneticButton>
)}
{project.demoAvailability === "unavailable" && (
<span
className="ds-link ds-link-accent"
aria-disabled="true"
>
Demo indisponível na última verificação
</span>
)}
</div>
</motion.article>
);
})}
</div>
<ProjectReel />
</ShowcaseProvider>
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton
+125 -74
View File
@@ -6,19 +6,26 @@ type Transition = (
tl: gsap.core.Timeline,
prev: HTMLElement,
next: HTMLElement,
touch: boolean,
) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
* da rolagem (rolar para cima volta o "vídeo").
* Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada
* para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada
* "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais.
*
* Quem segura as seções paradas durante uma passagem:
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências.
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
@@ -28,7 +35,7 @@ const visibleCenter = (el: HTMLElement) => () =>
`50% ${el.offsetHeight - vh() / 2}px`;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
export function pageTop(el: HTMLElement) {
function pageTop(el: HTMLElement) {
let top = 0;
for (
let node: HTMLElement | null = el;
@@ -39,60 +46,66 @@ export function pageTop(el: HTMLElement) {
return top;
}
// a seção que sai fica parada na tela enquanto a próxima entra
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
tl.fromTo(prev, { y: 0 }, { y: vh }, 0);
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next) => {
holdPrev(tl, prev);
(tl, prev, next, touch) => {
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
if (touch) {
tl.fromTo(
next,
{
opacity: 0.3,
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
},
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
);
return;
}
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(
prev,
{ autoAlpha: 1 },
{ autoAlpha: 0, duration: 0.4 },
0,
).fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
const center = () => `at 50% ${vh() / 2}px`;
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
(tl, prev, next, touch) => {
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
)
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () =>
`circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
ease: "power1.in",
},
0,
);
);
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
const radius = () =>
touch
? Math.hypot(window.innerWidth / 2, vh())
: Math.hypot(window.innerWidth / 2, vh() / 2);
if (!touch) holdNext(tl, next);
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
tl.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${radius()}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ xPercent: 0, opacity: 1 },
@@ -100,23 +113,24 @@ const TRANSITIONS: Transition[] = [
0,
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
},
// IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece.
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
(tl, prev, next) => {
holdPrev(tl, prev);
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
next,
{ yPercent: 0, y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
(tl, prev, next, touch) => {
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
if (!touch)
tl.fromTo(
next,
{ y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
},
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ opacity: 1, xPercent: 0 },
@@ -130,9 +144,8 @@ const TRANSITIONS: Transition[] = [
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
@@ -142,9 +155,9 @@ const TRANSITIONS: Transition[] = [
.fromTo(
next,
{
scale: 0.55,
scale: touch ? 0.8 : 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => `50% ${vh() / 2}px`,
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
@@ -180,15 +193,35 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
const touch = window.matchMedia(
"(hover: none) and (pointer: coarse)",
).matches;
const scenes = Array.from(main.children) as HTMLElement[];
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
const sceneTops = () => {
let top = pageTop(main);
return scenes.map((scene) => {
const current = top;
top += scene.offsetHeight;
return current;
});
};
const stick = () => {
for (const scene of scenes)
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
};
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, {
position: "relative",
position: "sticky",
zIndex: index === 0 ? 30 : index + 1,
});
});
stick();
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
@@ -197,8 +230,8 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => pageTop(next) - vh(),
end: () => pageTop(next),
start: () => sceneTops()[index + 1] - vh(),
end: () => sceneTops()[index + 1],
scrub: true,
invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
@@ -207,23 +240,26 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
onEnter: () => next.classList.add("is-drawing"),
},
});
transition(tl, prev, next);
transition(tl, prev, next, touch);
});
}, main);
// Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na
// tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior;
// depois não aparece atrás de transparências, e o navegador não pinta o que está coberto.
// As posições são lidas a cada quadro (offsetTop é barato com o layout já calculado): guardá-las
// deixava a visibilidade errada quando fontes, imagens ou o bloco "Experiência" mudavam a altura.
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = scenes.map(pageTop);
const tops = sceneTops();
scenes.forEach((scene, index) => {
const from = index === 0 ? -Infinity : tops[index] - vh();
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
const from =
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
const to =
index === scenes.length - 1
? Infinity
: tops[index + 1] + vh() * 0.25;
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility)
scene.style.visibility = visibility;
@@ -232,8 +268,12 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
const queueSync = () => {
if (!frame) frame = requestAnimationFrame(syncVisibility);
};
const onRefresh = () => {
stick();
queueSync();
};
syncVisibility();
ScrollTrigger.addEventListener("refresh", queueSync);
ScrollTrigger.addEventListener("refresh", onRefresh);
window.addEventListener("scroll", queueSync, { passive: true });
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
@@ -279,18 +319,26 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
if (!target) return;
event.preventDefault();
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
// alvos internos descontam o cabeçalho fixo
const scene = scenes.find((candidate) => candidate.contains(target));
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
const index = scenes.findIndex((candidate) =>
candidate.contains(target),
);
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = index < 0 ? 0 : sceneTops()[index];
const top =
!scene || scene === scenes[0]
index <= 0
? 0
: Math.max(
pageTop(scene),
scene === target ? 0 : pageTop(target) - offset,
sceneTop,
scenes[index] === target
? 0
: sceneTop +
pageTop(target) -
pageTop(scenes[index]) -
offset,
);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
@@ -307,13 +355,16 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", queueSync);
ScrollTrigger.removeEventListener("refresh", onRefresh);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) scene.style.visibility = "";
for (const scene of scenes) {
scene.style.visibility = "";
scene.style.top = "";
}
gsap.ticker.remove(tick);
lenis.destroy();
};
@@ -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,374 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
import { ProjectReel } from "./ProjectReel";
import { ShowcaseProvider } from "./ShowcaseProvider";
import { reelOffset, videoProgress } from "./showcase-math";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
const PIN = 800;
const CONTAINER = 7000;
const SCROLLABLE = CONTAINER - PIN;
const HEADER = 60;
let reelTop = HEADER;
const projects = getFeaturedProjects(null);
const weights = [
"sev-modaintima",
"lavacao-047",
"nutriscan",
"sc-plus",
"as-servicos",
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
const originalRect = Element.prototype.getBoundingClientRect;
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetHeight",
);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get(this: HTMLElement) {
if (this.classList.contains("ds-reel")) return CONTAINER;
if (this.classList.contains("ds-reel-pin")) return PIN;
return 0;
},
});
Element.prototype.getBoundingClientRect = function rect(this: Element) {
if (this.classList.contains("ds-reel"))
return {
top: reelTop,
bottom: reelTop + CONTAINER,
height: CONTAINER,
left: 0,
right: 1000,
width: 1000,
x: 0,
y: reelTop,
} as DOMRect;
return originalRect.call(this);
};
window.scrollTo = jest.fn();
});
afterAll(() => {
Element.prototype.getBoundingClientRect = originalRect;
if (originalOffsetHeight)
Object.defineProperty(
HTMLElement.prototype,
"offsetHeight",
originalOffsetHeight,
);
});
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
reelTop = HEADER;
(window.scrollTo as jest.Mock).mockClear();
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
function renderReel() {
const view = render(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
act(() => {
jest.advanceTimersByTime(50);
});
return view;
}
/** jsdom não carrega mídia: cada <video> da vitrine ganha duração e prontidão para aceitar buscas. */
function readyVideos() {
for (const video of document.querySelectorAll("video")) {
Object.defineProperty(video, "readyState", {
configurable: true,
value: 4,
});
Object.defineProperty(video, "duration", { configurable: true, value: 30 });
let time = 0;
Object.defineProperty(video, "currentTime", {
configurable: true,
get: () => time,
set: (value: number) => {
time = value;
},
});
}
}
function scrollTo(progress: number) {
reelTop = HEADER - progress * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(1500);
});
}
const sources = () =>
[...document.querySelectorAll(".ds-reel-layer video")].map((video) =>
video.getAttribute("src"),
);
const activeLayer = () =>
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
describe("ProjectReel", () => {
it("renders nothing for users who prefer reduced motion", () => {
reducedMotion = true;
renderReel();
expect(
screen.queryByRole("region", { name: /Vitrine animada/ }),
).toBeNull();
});
it("lists every project by name, starting on the first with its chapters", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
for (const name of [
"SEV Moda Íntima",
"Lavação 047",
"NutriScan",
"SC Plus Aquicultura",
"AS Serviços",
])
expect(within(nav).getByRole("button", { name })).toBeInTheDocument();
expect(
within(nav).getByRole("button", { name: "SEV Moda Íntima" }),
).toHaveAttribute("aria-current", "true");
expect(
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
).toBeInTheDocument();
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
});
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
renderReel();
const layers = [...document.querySelectorAll(".ds-reel-layer")];
expect(layers).toHaveLength(5);
for (const layer of layers.slice(1)) {
expect(layer).toHaveAttribute("aria-hidden", "true");
expect(layer).toHaveAttribute("inert");
}
expect(layers[0]).not.toHaveAttribute("inert");
});
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
renderReel();
readyVideos();
// meio do trecho da Lavação (segundo projeto)
const t = reelOffset(weights, 1, 0.5);
scrollTo(t);
const layer = activeLayer();
expect(layer).toHaveTextContent("Lavação 047");
const lavacao = getShowcaseConfig("lavacao-047");
if (!lavacao) throw new Error("config ausente");
const p = videoProgress(0.5);
const desktop = layer.querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
const phone = layer.querySelector(
".ds-phone-screen video",
) as HTMLVideoElement;
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
});
it("names the chapter on screen and counts them", () => {
renderReel();
readyVideos();
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
const layer = activeLayer();
const lavacao = getShowcaseConfig("lavacao-047");
expect(lavacao).toBeDefined();
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
/^0\d\/06$/,
);
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
expect(labels).toContain(
layer.querySelector(".ds-reel-chapter strong")?.textContent,
);
});
it("holds the first and last frame at the ends of the pinned range", () => {
renderReel();
readyVideos();
scrollTo(0);
const first = document.querySelector(
'.ds-reel-layer[data-state="active"] .ds-reel-screen video',
) as HTMLVideoElement;
expect(first.currentTime).toBe(0);
scrollTo(1);
expect(activeLayer()).toHaveTextContent("AS Serviços");
const last = activeLayer().querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
expect(last.currentTime).toBeCloseTo(30, 1);
});
it("downloads nothing while the page is far from the reel", () => {
reelTop = 6000;
renderReel();
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(400);
});
expect(sources().every((src) => src === null)).toBe(true);
});
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
let loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
).toBe(true);
expect(
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
).toBe(true);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
scrollTo(1);
act(() => {
jest.advanceTimersByTime(400);
});
loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("as-servicos/scrub-desktop.mp4")),
).toBe(true);
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("only downloads where the scroll settles, not every project it flies past", () => {
renderReel();
scrollTo(0);
// atravessa vários projetos em menos de 300 ms
for (const project of [1, 2, 3]) {
reelTop = HEADER - reelOffset(weights, project, 0.5) * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(60);
});
}
act(() => {
jest.advanceTimersByTime(400);
});
const loaded = sources().filter(Boolean);
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(true); // vizinho do projeto onde parou
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("does not download the hidden laptop video on a phone-sized screen", () => {
const original = window.matchMedia;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query.includes("max-width: 639px"),
media: query,
addEventListener() {},
removeEventListener() {},
}));
try {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
const loaded = sources().filter(Boolean);
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(
true,
);
expect(loaded.some((src) => src?.includes("scrub-desktop.mp4"))).toBe(
false,
);
} finally {
window.matchMedia = original;
}
});
it("respects the data saver: no scroll video is downloaded", () => {
Object.defineProperty(navigator, "connection", {
configurable: true,
value: { saveData: true },
});
try {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
expect(sources().every((src) => src === null)).toBe(true);
} finally {
Object.defineProperty(navigator, "connection", {
configurable: true,
value: undefined,
});
}
});
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
const html = renderToString(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
expect(html).toContain("ds-reel-placeholder");
expect(html).toContain("--reel-sum");
expect(html).toContain('aria-hidden="true"');
expect(html).not.toContain("<video");
});
it("scrolls to the start of a project when its button is pressed", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
fireEvent.click(within(nav).getByRole("button", { name: /NutriScan/ }));
act(() => {
jest.advanceTimersByTime(1200);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
const last = calls[calls.length - 1][0] as { top: number };
const expected =
HEADER - HEADER + reelOffset(weights, 2, 0) * SCROLLABLE + 2;
expect(last.top).toBeCloseTo(expected, 0);
});
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/ }));
act(() => {
jest.advanceTimersByTime(1200);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
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);
});
});
+568
View File
@@ -0,0 +1,568 @@
"use client";
import {
type CSSProperties,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import {
chapterFraction,
chapterIndexAt,
getScrubSource,
getShowcaseAssets,
mobileFractionAt,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider";
import {
easeToward,
locateInReel,
REEL_HOLD,
reelOffset,
videoProgress,
} from "./showcase-math";
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
let headerCache = { width: -1, height: 60 };
const headerHeight = () => {
if (headerCache.width !== window.innerWidth)
headerCache = {
width: window.innerWidth,
height:
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
"--site-header-height",
),
) || 60,
};
return headerCache.height;
};
/** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */
function usePhoneOnly() {
const [phoneOnly, setPhoneOnly] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(max-width: 639px)");
const update = () => setPhoneOnly(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return phoneOnly;
}
/** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */
const saveDataOn = () =>
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection
?.saveData === true;
/** 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;
const target = Math.min(
video.duration - 0.001,
Math.max(0, fraction * video.duration),
);
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
video.currentTime = target;
}
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
function prime(video: HTMLVideoElement) {
if (video.dataset.primed) return;
video.dataset.primed = "true";
video
.play()
.then(() => video.pause())
.catch(() => {});
}
let stopScroll = () => {};
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
function scrollToSmoothly(top: number) {
stopScroll();
const from = window.scrollY;
const distance = top - from;
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
const started = performance.now();
let id = 0;
const stop = () => {
cancelAnimationFrame(id);
window.removeEventListener("wheel", stop);
window.removeEventListener("touchstart", stop);
};
stopScroll = stop;
window.addEventListener("wheel", stop, { passive: true });
window.addEventListener("touchstart", stop, { passive: true });
const step = (now: number) => {
const t = Math.min(1, (now - started) / duration);
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
if (t < 1) id = requestAnimationFrame(step);
else stop();
};
id = requestAnimationFrame(step);
}
/**
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
* mostram o mesmo trecho do site ao mesmo tempo.
*
* Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel.
*/
export function ProjectReel() {
const items = useShowcaseItems();
const openProject = useShowcaseOpen();
const reducedMotion = useReducedMotion();
const [mounted, setMounted] = useState(false);
const [active, setActive] = useState(0);
const [chapter, setChapter] = useState(0);
const [near, setNear] = useState(false);
const [settled, setSettled] = useState(0);
const [saveData, setSaveData] = useState(false);
const phoneOnly = usePhoneOnly();
const containerRef = useRef<HTMLDivElement>(null);
const pinRef = useRef<HTMLDivElement>(null);
const indexRef = useRef<HTMLOListElement>(null);
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
const phoneVideos = useRef<Array<HTMLVideoElement | null>>([]);
const bars = useRef<Array<HTMLElement | null>>([]);
const live = useRef({
target: [] as number[],
smooth: [] as number[],
active: 0,
chapter: 0,
raf: 0,
last: 0,
near: false,
});
const weights = useMemo(
() => items.map((item) => item.config.weight),
[items],
);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
useEffect(() => {
setSaveData(saveDataOn());
setMounted(true);
}, []);
// só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por
// um projeto no clique do índice não pode baixar todos os do caminho)
useEffect(() => {
const timer = setTimeout(() => setSettled(active), 300);
return () => clearTimeout(timer);
}, [active]);
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
useEffect(() => {
const list = indexRef.current;
const button = list?.children[active]?.querySelector("button");
if (
!list ||
!button ||
list.scrollWidth <= list.clientWidth ||
typeof list.scrollTo !== "function"
)
return;
list.scrollTo({
left: Math.max(0, button.offsetLeft - 16),
behavior: "smooth",
});
}, [active]);
const isAttached = useCallback(
(index: number) => near && !saveData && Math.abs(index - settled) <= 1,
[near, saveData, settled],
);
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
useEffect(() => {
items.forEach((_, index) => {
if (isAttached(index)) return;
for (const video of [
desktopVideos.current[index],
phoneVideos.current[index],
])
if (video && video.networkState !== 0 && !video.getAttribute("src"))
video.load();
});
}, [items, isAttached]);
const frame = useCallback(
(now: number) => {
const state = live.current;
const seconds = state.last ? (now - state.last) / 1000 : 0;
state.last = now;
let moving = false;
items.forEach((item, index) => {
if (Math.abs(index - state.active) > 1) return;
const before = state.smooth[index] ?? 0;
const next = easeToward(before, state.target[index] ?? 0, seconds);
state.smooth[index] = next;
if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true;
seekVideo(desktopVideos.current[index], next);
seekVideo(
phoneVideos.current[index],
mobileFractionAt(item.config, next),
);
const bar = bars.current[index];
if (bar) bar.style.setProperty("--p", next.toFixed(4));
});
const current = items[state.active];
if (current) {
const index = chapterIndexAt(
current.config,
state.smooth[state.active] ?? 0,
);
if (index !== state.chapter) {
state.chapter = index;
setChapter(index);
}
}
state.raf = moving ? requestAnimationFrame(frame) : 0;
},
[items],
);
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
const syncVideos = useCallback(
(index: number) => {
const item = items[index];
const at = live.current.smooth[index] ?? live.current.target[index] ?? 0;
seekVideo(desktopVideos.current[index], at);
if (item)
seekVideo(
phoneVideos.current[index],
mobileFractionAt(item.config, at),
);
},
[items],
);
const measure = useCallback(() => {
const container = containerRef.current;
const pin = pinRef.current;
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
if (!container || !pin || items.length === 0 || !container.offsetHeight)
return;
const state = live.current;
const rect = container.getBoundingClientRect();
const scrollable = rect.height - pin.offsetHeight;
const progress =
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
const { index, local } = locateInReel(weights, progress);
state.target = items.map((_, i) =>
i < index ? 1 : i === index ? videoProgress(local) : 0,
);
const near =
rect.top < window.innerHeight * 2 && rect.bottom > -window.innerHeight;
if (near !== state.near) {
state.near = near;
setNear(near);
}
if (index !== state.active) {
state.active = index;
state.chapter = chapterIndexAt(
items[index].config,
state.smooth[index] ?? 0,
);
setActive(index);
setChapter(state.chapter);
}
if (state.near && !state.raf) {
state.last = 0;
state.raf = requestAnimationFrame(frame);
}
}, [frame, items, weights]);
useEffect(() => {
if (!mounted || reducedMotion || items.length === 0) return;
let pending = 0;
const schedule = () => {
if (!pending)
pending = requestAnimationFrame(() => {
pending = 0;
measure();
});
};
schedule();
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
const state = live.current;
return () => {
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
cancelAnimationFrame(pending);
cancelAnimationFrame(state.raf);
state.raf = 0;
};
}, [mounted, reducedMotion, items.length, measure]);
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
const scrollToReel = (index: number, local: number) => {
const container = containerRef.current;
const pin = pinRef.current;
if (!container || !pin) return;
const scrollable = container.offsetHeight - pin.offsetHeight;
const top = container.getBoundingClientRect().top + window.scrollY;
// 2px além: garante que a conta caia dentro do trecho
scrollToSmoothly(
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
);
};
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));
};
if (items.length === 0 || (mounted && reducedMotion)) return null;
// antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine
// aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com
// prefers-reduced-motion e em telas baixas
if (!mounted)
return (
<div
className="ds-reel ds-reel-placeholder"
aria-hidden="true"
style={{ "--reel-sum": weightSum } as CSSProperties}
/>
);
const current = items[active];
return (
<section
className="ds-reel"
aria-label="Vitrine animada dos projetos"
style={{ "--reel-sum": weightSum } as CSSProperties}
ref={containerRef}
>
<div className="ds-reel-pin" ref={pinRef}>
<div className="ds-reel-grid">
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
<p className="ds-reel-hint">
<span aria-hidden="true" className="ds-reel-hint-line" />
Em movimento
<span className="ds-reel-hint-sub">Role para avançar</span>
</p>
<ol ref={indexRef}>
{items.map((item, index) => (
<li key={item.config.id}>
<button
type="button"
aria-current={index === active ? "true" : undefined}
aria-label={item.project.title}
onClick={() => goTo(index)}
>
<span className="ds-reel-num">
{String(index + 1).padStart(2, "0")}
</span>
<span className="ds-reel-name">{item.project.title}</span>
<i
className="ds-reel-bar"
aria-hidden="true"
ref={(element) => {
bars.current[index] = element;
}}
/>
</button>
{index === active && (
<ol
className="ds-reel-chapters"
aria-label={`Trechos de ${item.project.title}`}
>
{item.config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
aria-current={i === chapter ? "true" : undefined}
onClick={() => goToChapter(index, i)}
>
<span aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
{entry.label}
</button>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</nav>
<div className="ds-reel-stage">
{items.map((item, index) => {
const { config, project } = item;
const isActive = index === active;
const attach = isAttached(index);
const desktop = getShowcaseAssets(config.id, "desktop");
const phone = getShowcaseAssets(config.id, "mobile");
return (
<div
key={config.id}
className="ds-reel-layer"
data-state={
isActive ? "active" : index < active ? "before" : "after"
}
style={{ "--accent": config.accent } as CSSProperties}
aria-hidden={isActive ? undefined : true}
inert={!isActive}
>
<div className="ds-reel-set">
<div className="ds-reel-devices">
<BrowserFrame
url={displayUrl(project)}
className="ds-reel-laptop"
>
<div
className="ds-reel-screen"
style={{ backgroundImage: `url(${desktop.poster})` }}
>
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
<video
ref={(element) => {
desktopVideos.current[index] = element;
}}
src={
attach && !phoneOnly
? getScrubSource(config.id, "desktop")
: undefined
}
poster={desktop.poster}
muted
playsInline
preload={attach && !phoneOnly ? "auto" : "none"}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={() => syncVideos(index)}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</div>
</BrowserFrame>
<PhoneFrame
className="ds-reel-phone"
poster={phone.poster}
>
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
<video
ref={(element) => {
phoneVideos.current[index] = element;
}}
src={
attach
? getScrubSource(config.id, "mobile")
: undefined
}
poster={phone.poster}
muted
playsInline
preload={attach ? "auto" : "none"}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={() => syncVideos(index)}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</PhoneFrame>
</div>
<div className="ds-reel-caption">
<p className="ds-kicker">
{project.category} · {project.status}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && (
<p className="ds-reel-sub">{project.subtitle}</p>
)}
<div className="ds-reel-chapter">
<span className="ds-reel-chapter-count">
{String((isActive ? chapter : 0) + 1).padStart(
2,
"0",
)}
/{String(config.chapters.length).padStart(2, "0")}
</span>
<strong>
{config.chapters[isActive ? chapter : 0].label}
</strong>
<ol className="ds-reel-ticks" aria-hidden="true">
{config.chapters.map((entry, tick) => (
<li
key={entry.label}
data-state={
!isActive || tick > chapter
? "todo"
: tick === chapter
? "now"
: "done"
}
/>
))}
</ol>
</div>
<div className="ds-reel-actions">
<button
type="button"
className="ds-link ds-link-accent"
onClick={() => openProject(config.id)}
aria-haspopup="dialog"
>
Ampliar vídeo
</button>
{project.demoUrl &&
project.demoAvailability !== "unavailable" && (
<a
className="ds-link ds-link-muted"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir projeto
<span className="sr-only">
{" "}
(abre em nova aba)
</span>
</a>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
<p className="sr-only" aria-live="polite">
{current
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
: ""}
</p>
</div>
</section>
);
}
@@ -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
+8 -2
View File
@@ -1284,7 +1284,12 @@ html.lenis {
border: 1px solid var(--border);
background: var(--ink);
& img {
& video {
display: block;
}
& img,
& video {
width: 100%;
height: 100%;
object-fit: cover;
@@ -1292,7 +1297,8 @@ html.lenis {
transition: transform 0.7s var(--ease-out);
}
.ds-card:hover & img {
.ds-card:hover & img,
.ds-card:hover & video {
transform: scale(1.035);
}
}
+9 -6
View File
@@ -1,6 +1,10 @@
import { loadGithubRepoCounts } from "@/lib/github-stats";
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import CinematicHome from "./components/cinematic/CinematicHome";
/** A contagem do GitHub é consultada no servidor e renovada uma vez por dia. */
export const revalidate = 86_400;
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
function yearsOfExperience() {
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
@@ -8,16 +12,15 @@ function yearsOfExperience() {
}
export default async function Home() {
const projects = await loadFeaturedProjects();
const [projects, repos] = await Promise.all([
loadFeaturedProjects(),
loadGithubRepoCounts(),
]);
return (
<CinematicHome
heroStats={{
years: yearsOfExperience(),
projects: projects.length,
demos: projects.filter(
(project) =>
project.demoUrl && project.demoAvailability !== "unavailable",
).length,
repos: repos ?? undefined,
}}
projects={projects}
experiences={portfolio.experiences}
+10 -2
View File
@@ -1,6 +1,14 @@
import { ViewTransition } from "react";
/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */
/**
* O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css).
* update="none": a hidratação e a revelação de Suspense também são transições do React e animariam a página
* inteira na chegada (a tela piscava duas vezes); só a troca de página anima.
*/
export default function Template({ children }: { children: React.ReactNode }) {
return <ViewTransition name="page">{children}</ViewTransition>;
return (
<ViewTransition name="page" update="none">
{children}
</ViewTransition>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"checkedAt": "2026-10-08T18:25:53Z",
"total": 68,
"public": 25,
"private": 43
}
+126
View File
@@ -0,0 +1,126 @@
import {
countOwnRepos,
GITHUB_STATS_REVALIDATE_SECONDS,
loadGithubRepoCounts,
loadGithubStatsSnapshot,
} from "./github-stats";
const repo = (
overrides: Partial<{ private: boolean; fork: boolean; archived: boolean }>,
) => ({ private: false, fork: false, archived: false, ...overrides });
function jsonResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}
const noSnapshot = async () => null;
const snapshot = { total: 68, public: 25, private: 43 };
describe("countOwnRepos", () => {
it("counts public and private repos and ignores forks and archived ones", () => {
const counts = countOwnRepos([
repo({}),
repo({}),
repo({ private: true }),
repo({ fork: true }),
repo({ private: true, fork: true }),
repo({ archived: true }),
repo({ private: true, archived: true }),
]);
expect(counts).toEqual({ total: 3, public: 2, private: 1 });
});
});
describe("loadGithubRepoCounts", () => {
it("returns null without a token and without a snapshot, and never calls the API", async () => {
const fetchImpl = jest.fn();
await expect(
loadGithubRepoCounts(undefined, fetchImpl, noSnapshot),
).resolves.toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it("falls back to the last known snapshot without a token", async () => {
const fetchImpl = jest.fn();
await expect(
loadGithubRepoCounts(undefined, fetchImpl, async () => snapshot),
).resolves.toEqual(snapshot);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("prefers the live count over the snapshot", async () => {
const fetchImpl = jest
.fn()
.mockResolvedValue(jsonResponse([repo({}), repo({ private: true })]));
await expect(
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
).resolves.toEqual({ total: 2, public: 1, private: 1 });
});
it("sends the token only to the GitHub API and revalidates daily", async () => {
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse([repo({})]));
await loadGithubRepoCounts("secret-token", fetchImpl, noSnapshot);
const [url, init] = fetchImpl.mock.calls[0];
expect(new URL(url).origin).toBe("https://api.github.com");
expect(url).toContain("affiliation=owner");
expect(url).toContain("visibility=all");
expect(init.headers.authorization).toBe("Bearer secret-token");
expect(init.next.revalidate).toBe(GITHUB_STATS_REVALIDATE_SECONDS);
expect(GITHUB_STATS_REVALIDATE_SECONDS).toBe(86_400);
});
it("walks every page before counting", async () => {
const firstPage = Array.from({ length: 100 }, () => repo({}));
const secondPage = [repo({ private: true }), repo({ fork: true })];
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(firstPage))
.mockResolvedValueOnce(jsonResponse(secondPage));
await expect(
loadGithubRepoCounts("token", fetchImpl, noSnapshot),
).resolves.toEqual({
total: 101,
public: 100,
private: 1,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("uses the snapshot when the API rejects the token", async () => {
const fetchImpl = jest
.fn()
.mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401));
await expect(
loadGithubRepoCounts("bad", fetchImpl, async () => snapshot),
).resolves.toEqual(snapshot);
});
it("uses the snapshot on network failure instead of breaking the page", async () => {
const fetchImpl = jest.fn().mockRejectedValue(new Error("offline"));
await expect(
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
).resolves.toEqual(snapshot);
});
});
describe("loadGithubStatsSnapshot", () => {
it("reads a coherent snapshot from data/github-stats.json", async () => {
const counts = await loadGithubStatsSnapshot();
expect(counts).not.toBeNull();
expect(counts?.total).toBe((counts?.public ?? 0) + (counts?.private ?? 0));
});
});
+109
View File
@@ -0,0 +1,109 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
/**
* Contagem de repositórios próprios no GitHub (públicos + privados), consultada só no servidor.
* O token (GITHUB_STATS_TOKEN, fine-grained, somente leitura de metadados) nunca recebe prefixo NEXT_PUBLIC_
* e este módulo só é importado por componentes de servidor: ele não chega ao navegador.
*
* Sem token, ou se a API falhar, vale o último retrato conhecido em data/github-stats.json.
*/
export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60;
const REPOS_ENDPOINT = "https://api.github.com/user/repos";
const PAGE_SIZE = 100;
const MAX_PAGES = 20;
const REQUEST_TIMEOUT_MS = 10_000;
export interface GithubRepoCounts {
total: number;
public: number;
private: number;
}
interface RepoSummary {
private: boolean;
fork: boolean;
archived: boolean;
}
type CachedRequestInit = RequestInit & { next: { revalidate: number } };
type FetchLike = (url: string, init: CachedRequestInit) => Promise<Response>;
/** Só repositórios meus: forks e arquivados ficam de fora. */
export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts {
let publicCount = 0;
let privateCount = 0;
for (const repo of repos) {
if (repo.fork || repo.archived) continue;
if (repo.private) privateCount += 1;
else publicCount += 1;
}
return {
total: publicCount + privateCount,
public: publicCount,
private: privateCount,
};
}
async function fetchGithubRepoCounts(
token: string | undefined,
fetchImpl: FetchLike,
): Promise<GithubRepoCounts | null> {
if (!token) return null;
const repos: RepoSummary[] = [];
try {
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url = `${REPOS_ENDPOINT}?affiliation=owner&visibility=all&per_page=${PAGE_SIZE}&page=${page}`;
const response = await fetchImpl(url, {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "evandro-dev-portfolio",
"x-github-api-version": "2022-11-28",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
next: { revalidate: GITHUB_STATS_REVALIDATE_SECONDS },
});
if (!response.ok) return null;
const batch = (await response.json()) as RepoSummary[];
if (!Array.isArray(batch)) return null;
repos.push(
...batch.map(({ private: isPrivate, fork, archived }) => ({
private: isPrivate,
fork,
archived,
})),
);
if (batch.length < PAGE_SIZE) return countOwnRepos(repos);
}
return null;
} catch {
return null;
}
}
/** Último retrato conhecido (data/github-stats.json); `null` se o arquivo faltar ou estiver incoerente. */
export async function loadGithubStatsSnapshot(): Promise<GithubRepoCounts | null> {
try {
const path = resolve(process.cwd(), "data/github-stats.json");
const raw = JSON.parse(await readFile(path, "utf8")) as GithubRepoCounts;
const counts = [raw.total, raw.public, raw.private];
if (!counts.every((n) => Number.isInteger(n) && n >= 0)) return null;
if (raw.total !== raw.public + raw.private) return null;
return { total: raw.total, public: raw.public, private: raw.private };
} catch {
return null;
}
}
export async function loadGithubRepoCounts(
token: string | undefined = process.env.GITHUB_STATS_TOKEN,
fetchImpl: FetchLike = fetch,
readSnapshot: () => Promise<GithubRepoCounts | null> = loadGithubStatsSnapshot,
): Promise<GithubRepoCounts | null> {
return (await fetchGithubRepoCounts(token, fetchImpl)) ?? readSnapshot();
}
+36
View File
@@ -0,0 +1,36 @@
import { render } from "@testing-library/react";
import { OgCard, readProjectOgBackground } from "./og-card";
describe("readProjectOgBackground", () => {
it("returns the recorded cover of a showcase project as a JPEG data URI", () => {
const uri = readProjectOgBackground("nutriscan");
expect(uri).toMatch(/^data:image\/jpeg;base64,/);
expect((uri ?? "").length).toBeGreaterThan(5_000);
});
it("returns undefined for projects without a recorded cover", () => {
expect(readProjectOgBackground("twins-lab")).toBeUndefined();
expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined();
});
});
describe("OgCard", () => {
const props = { eyebrow: "Case", title: "Título", description: "Resumo." };
it("keeps the plain dark card when there is no background", () => {
const { container } = render(<OgCard {...props} />);
// só a marca
expect(container.querySelectorAll("img")).toHaveLength(1);
});
it("puts the project cover behind the text with a darkening gradient", () => {
const { container } = render(
<OgCard {...props} backgroundImage="data:image/jpeg;base64,AAAA" />,
);
const images = [...container.querySelectorAll("img")];
expect(images).toHaveLength(2);
expect(images[0]).toHaveAttribute("src", "data:image/jpeg;base64,AAAA");
expect(images[0]).toHaveAttribute("alt", "");
expect(container.innerHTML).toContain("linear-gradient");
});
});
+37 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase";
const brandMark = `data:image/png;base64,${readFileSync(
join(process.cwd(), "public/brand/mark-dark.png"),
@@ -8,14 +9,25 @@ const brandMark = `data:image/png;base64,${readFileSync(
export const OG_SIZE = { width: 1200, height: 630 } as const;
export const OG_CONTENT_TYPE = "image/png";
/** Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), como data URI; `undefined` se não existir. */
export function readProjectOgBackground(id: string): string | undefined {
if (!PROJECT_VIDEO_IDS.includes(id)) return undefined;
const path = join(process.cwd(), "public", getOgImagePath(id));
if (!existsSync(path)) return undefined;
return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`;
}
export function OgCard({
eyebrow,
title,
description,
backgroundImage,
}: {
eyebrow: string;
title: string;
description: string;
/** Imagem de fundo (data URI), escurecida da esquerda para a direita para o texto continuar legível. */
backgroundImage?: string;
}) {
return (
<div
@@ -32,6 +44,30 @@ export function OgCard({
fontFamily: "Arial, sans-serif",
}}
>
{backgroundImage && (
<>
{/* biome-ignore lint/performance/noImgElement: o ImageResponse (satori) só desenha <img> */}
<img
src={backgroundImage}
alt=""
width={OG_SIZE.width}
height={OG_SIZE.height}
style={{ position: "absolute", top: 0, left: 0 }}
/>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: OG_SIZE.width,
height: OG_SIZE.height,
display: "flex",
background:
"linear-gradient(90deg, rgba(5,5,5,0.97) 0%, rgba(5,5,5,0.9) 48%, rgba(5,5,5,0.58) 100%)",
}}
/>
</>
)}
<img
src={brandMark}
alt="Evandro.dev.br"
+94
View File
@@ -0,0 +1,94 @@
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import {
chapterAnchors,
chapterFraction,
chapterIndexAt,
desktopFractionAt,
getOgImagePath,
getProjectVideo,
getScrubSource,
getShowcaseAssets,
mobileFractionAt,
PROJECT_VIDEO_IDS,
SHOWCASE,
} from "./project-showcase";
const MAX_BYTES = 3 * 1024 * 1024;
const publicFile = (path: string) => join(process.cwd(), "public", path);
describe("showcase chapters", () => {
it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => {
const [first] = config.chapters;
expect(first.desktop).toBe(0);
expect(first.mobile).toBe(0);
config.chapters.forEach((chapter, index) => {
expect(chapter.label.trim()).not.toBe("");
expect(chapter.desktop).toBeLessThan(config.durations.desktop);
expect(chapter.mobile).toBeLessThan(config.durations.mobile);
if (index > 0) {
expect(chapter.desktop).toBeGreaterThan(
config.chapters[index - 1].desktop,
);
expect(chapter.mobile).toBeGreaterThan(
config.chapters[index - 1].mobile,
);
}
});
expect(config.weight).toBeGreaterThan(0);
});
it.each(SHOWCASE)("$id keeps the two timelines in sync", (config) => {
const anchors = chapterAnchors(config);
expect(anchors[0]).toEqual([0, 0]);
expect(anchors[anchors.length - 1]).toEqual([1, 1]);
for (const fraction of [0, 0.13, 0.5, 0.77, 1]) {
const mobile = mobileFractionAt(config, fraction);
expect(mobile).toBeGreaterThanOrEqual(0);
expect(mobile).toBeLessThanOrEqual(1);
expect(desktopFractionAt(config, mobile)).toBeCloseTo(fraction, 9);
}
config.chapters.forEach((_, index) => {
expect(
mobileFractionAt(config, chapterFraction(config, index, "desktop")),
).toBeCloseTo(chapterFraction(config, index, "mobile"), 9);
});
});
it("finds the chapter on screen", () => {
const lavacao = SHOWCASE.find((config) => config.id === "lavacao-047");
expect(lavacao).toBeDefined();
if (!lavacao) return;
expect(chapterIndexAt(lavacao, 0)).toBe(0);
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(1);
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(2);
expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1);
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
expect(chapterIndexAt(lavacao, -1)).toBe(0);
});
});
describe("showcase assets", () => {
it("returns undefined for projects without a recorded video", () => {
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
});
it.each(PROJECT_VIDEO_IDS)(
"has every file on disk for %s, each video within 3 MB",
(id) => {
const files = [
...(["desktop", "mobile"] as const).flatMap((device) => {
const assets = getShowcaseAssets(id, device);
return [assets.poster, ...assets.sources.map((s) => s.src)];
}),
getScrubSource(id, "desktop"),
getScrubSource(id, "mobile"),
getOgImagePath(id),
];
for (const file of files) {
expect(existsSync(publicFile(file))).toBe(true);
expect(statSync(publicFile(file)).size).toBeLessThanOrEqual(MAX_BYTES);
}
},
);
});
+211
View File
@@ -0,0 +1,211 @@
/**
* Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos.
*
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
* - og.jpg: capa 1200x630 das imagens de compartilhamento.
*/
export interface ShowcaseChapter {
label: string;
/** Segundo do vídeo desktop em que o trecho começa. */
desktop: number;
/** Segundo do vídeo mobile em que o mesmo trecho começa (as páginas têm alturas diferentes). */
mobile: number;
}
export interface ShowcaseConfig {
id: string;
/** Cor de destaque do site, usada só no brilho ao redor do vídeo. */
accent: string;
/** Duração dos vídeos gravados, em segundos (os capítulos são medidos nelas). */
durations: { desktop: number; mobile: number };
/** Comprimento da rolagem deste projeto na vitrine, em alturas de tela. */
weight: number;
chapters: readonly ShowcaseChapter[];
}
/** Ordem da vitrine = ordem dos cards na home. Capítulos marcados nos quadros dos próprios vídeos. */
export const SHOWCASE: readonly ShowcaseConfig[] = [
{
id: "sev-modaintima",
accent: "#c2303a",
durations: { desktop: 17.9, mobile: 30.1 },
weight: 1,
chapters: [
{ label: "Abertura", desktop: 0, mobile: 0 },
{ label: "Categorias", desktop: 2.7, mobile: 1.9 },
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
{ label: "Nossa essência", desktop: 10.7, mobile: 8.6 },
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
],
},
{
id: "lavacao-047",
accent: "#4aa3ff",
durations: { desktop: 30.07, mobile: 30.1 },
weight: 1.4,
chapters: [
{ label: "Abertura", desktop: 0, mobile: 0 },
{ label: "Tabela de preços", desktop: 1.5, mobile: 1.9 },
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
{ label: "Como funciona", desktop: 13.5, mobile: 13.2 },
{ label: "Galeria e mapa", desktop: 18.5, mobile: 17.5 },
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
],
},
{
id: "nutriscan",
accent: "#2f9e5f",
durations: { desktop: 15.67, mobile: 26.4 },
weight: 0.9,
chapters: [
{ label: "Busca", desktop: 0, mobile: 0 },
{ label: "Por que confiar", desktop: 2.3, mobile: 3 },
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
],
},
{
id: "sc-plus",
accent: "#22c55e",
durations: { desktop: 13, mobile: 19.67 },
weight: 0.85,
chapters: [
{ label: "Abertura", desktop: 0, mobile: 0 },
{ label: "Sobre", desktop: 1.5, mobile: 2.2 },
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
{ label: "Apoiadores e participação", desktop: 7.5, mobile: 12.8 },
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
],
},
{
id: "as-servicos",
accent: "#2563eb",
durations: { desktop: 5.07, mobile: 7.7 },
weight: 0.6,
chapters: [
{ label: "Três áreas", desktop: 0, mobile: 0 },
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
{ label: "Contato direto", desktop: 2.4, mobile: 4.4 },
],
},
];
export const PROJECT_VIDEO_IDS = SHOWCASE.map((config) => config.id);
const byId = new Map(SHOWCASE.map((config) => [config.id, config]));
export function getShowcaseConfig(id: string): ShowcaseConfig | undefined {
return byId.get(id);
}
export type Device = "desktop" | "mobile";
export interface ShowcaseAssets {
poster: string;
width: number;
height: number;
sources: Array<{ src: string; type: string }>;
}
export function getShowcaseAssets(id: string, device: Device) {
const base = `/videos/${id}/${device}`;
return {
poster: `${base}.webp`,
width: device === "desktop" ? 1440 : 390,
height: device === "desktop" ? 900 : 844,
sources: [
{ src: `${base}.webm`, type: 'video/webm; codecs="vp9"' },
{ src: `${base}.mp4`, type: "video/mp4" },
],
} satisfies ShowcaseAssets;
}
export function getScrubSource(id: string, device: Device) {
return `/videos/${id}/scrub-${device}.mp4`;
}
export function getOgImagePath(id: string) {
return `/videos/${id}/og.jpg`;
}
/** Vídeo desktop de um projeto da vitrine: é o que as capas dos cards tocam. */
export function getProjectVideo(id: string): ShowcaseAssets | undefined {
return byId.has(id) ? getShowcaseAssets(id, "desktop") : undefined;
}
// ---------------------------------------------------------------------------------------------------------
// Capítulos e sincronia entre as duas telas
// ---------------------------------------------------------------------------------------------------------
/** Pontos [fração do desktop, fração do mobile] que dizem onde cada capítulo começa em cada vídeo. */
export function chapterAnchors(
config: ShowcaseConfig,
): Array<[number, number]> {
const { desktop, mobile } = config.durations;
const points: Array<[number, number]> = [[0, 0]];
for (const chapter of config.chapters) {
const point: [number, number] = [
chapter.desktop / desktop,
chapter.mobile / mobile,
];
const last = points[points.length - 1];
if (point[0] > last[0] && point[1] > last[1]) points.push(point);
}
points.push([1, 1]);
return points;
}
function interpolate(
points: Array<[number, number]>,
value: number,
from: 0 | 1,
): number {
const to = from === 0 ? 1 : 0;
const x = Math.min(1, Math.max(0, value));
for (let i = 1; i < points.length; i += 1) {
const [a, b] = [points[i - 1], points[i]];
if (x <= b[from]) {
const span = b[from] - a[from];
const ratio = span === 0 ? 0 : (x - a[from]) / span;
return a[to] + ratio * (b[to] - a[to]);
}
}
return 1;
}
/** Fração do vídeo mobile que mostra o mesmo ponto do site que a fração `fraction` do vídeo desktop. */
export function mobileFractionAt(config: ShowcaseConfig, fraction: number) {
return interpolate(chapterAnchors(config), fraction, 0);
}
/** O contrário: fração do desktop para uma fração do vídeo mobile. */
export function desktopFractionAt(config: ShowcaseConfig, fraction: number) {
return interpolate(chapterAnchors(config), fraction, 1);
}
/** Capítulo em exibição quando o vídeo desktop está na fração `fraction`. */
export function chapterIndexAt(config: ShowcaseConfig, fraction: number) {
const seconds = Math.min(1, Math.max(0, fraction)) * config.durations.desktop;
let index = 0;
config.chapters.forEach((chapter, i) => {
if (seconds >= chapter.desktop - 1e-6) index = i;
});
return index;
}
/** Fração (0 a 1) do vídeo `device` em que o capítulo `index` começa. */
export function chapterFraction(
config: ShowcaseConfig,
index: number,
device: Device,
) {
const chapter = config.chapters[index];
if (!chapter) return 0;
return device === "desktop"
? chapter.desktop / config.durations.desktop
: chapter.mobile / config.durations.mobile;
}
+10
View File
@@ -79,6 +79,16 @@ const nextConfig: NextConfig = {
source: "/(.*)",
headers: securityHeaders,
},
{
// vídeos da vitrine (46 MB): sem o hash no nome, um dia de cache e revalidação em segundo plano na semana seguinte
source: "/videos/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
],
},
];
},
+35
View File
@@ -94,6 +94,41 @@
"teamSize": "Individual"
}
},
{
"id": "lavacao-047",
"title": "Lavação 047",
"subtitle": "Site e painel para lavação automotiva",
"description": "Site da Lavação 047, em Garuva-SC, com tabela de preços por porte do veículo, pedido direto pelo WhatsApp, mapa de localização e painel do dono para editar preços, textos e fotos sem novo deploy.",
"longDescription": "Landing page em Next.js 16 com cena de rolagem do sujo ao brilho, galeria de trabalhos por serviço e FAQ montado a partir dos dados reais do negócio. O conteúdo salvo no painel alimenta a página, os dados estruturados, o sitemap e o cartão de compartilhamento. Respeita prefers-reduced-motion e guarda conteúdo e imagens em Redis e Blob privado, com arquivo local como alternativa.",
"technologies": [
"Next.js 16",
"React 19",
"TypeScript",
"Tailwind CSS 4",
"Motion",
"Upstash Redis",
"Vercel Blob"
],
"features": [
"Tabela de preços por porte do veículo",
"Pedido pelo WhatsApp com mensagem pronta",
"Painel do dono para preços, textos, fotos e mapa",
"Galeria de trabalhos por serviço",
"SEO com JSON-LD, sitemap e llms.txt",
"Mapa de localização com OpenStreetMap"
],
"category": "web",
"level": "senior",
"status": "Em Produção",
"demoUrl": "https://landing-page-lavacao047.vercel.app",
"githubUrl": null,
"badges": ["Site e painel", "Em Produção"],
"featured": true,
"metrics": {
"complexity": 4,
"teamSize": "Individual"
}
},
{
"id": "nutriscan",
"title": "NutriScan",
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.
Binary file not shown.
+101
View File
@@ -0,0 +1,101 @@
/**
* Recodifica um vídeo para a vitrine animada, em que o tempo do vídeo muda a cada quadro da rolagem.
* Para a busca ser instantânea o vídeo precisa de um quadro-chave a cada 6 quadros e nenhum B-frame; o tamanho
* de cada arquivo fica em até 3 MB (sobe o CRF e, se preciso, reduz a imagem até caber).
*
* ffmpeg-static não é dependência do site: instale-o numa pasta temporária e rode o script a partir dela
* (as dependências são resolvidas a partir da pasta onde o comando foi executado):
*
* mkdir rec && cd rec && npm init -y && npm i ffmpeg-static
* node <repo>/scripts/encode-scrub-video.mjs --in desktop-original.mp4 --out <repo>/public/videos/<id>/scrub-desktop.mp4 --size 1120x700
* node <repo>/scripts/encode-scrub-video.mjs --in mobile-original.mp4 --out <repo>/public/videos/<id>/scrub-mobile.mp4 --size 390x844
*
* Use como entrada o vídeo de melhor qualidade que existir (a entrada de record-project-videos.mjs antes do corte
* para MP4/WebM), não os MP4 já comprimidos de public/videos.
*
* A capa de compartilhamento (og.jpg, 1200x630) sai do poster desktop:
* ffmpeg -i public/videos/<id>/desktop.webp -vf "scale=1200:750:flags=lanczos,crop=1200:630:0:0" -q:v 4 public/videos/<id>/og.jpg
*/
import { spawnSync } from "node:child_process";
import { statSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
const require = createRequire(join(process.cwd(), "noop.js"));
const ffmpegPath = require("ffmpeg-static");
const args = Object.fromEntries(
process.argv.slice(2).reduce((acc, cur, i, all) => {
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
return acc;
}, []),
);
const { in: input, out: output } = args;
const [width, height] = (args.size ?? "1120x700").split("x").map(Number);
const MAX_BYTES = Number(args["max-mb"] ?? 3) * 1024 * 1024;
if (!input || !output || !width || !height) {
console.error(
"uso: --in <video> --out <video.mp4> --size 1120x700 [--max-mb 3]",
);
process.exit(1);
}
function encode(w, h, crf) {
const result = spawnSync(
ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
input,
"-vf",
`scale=${w}:${h}:flags=lanczos,fps=24`,
"-an",
"-c:v",
"libx264",
"-preset",
"slow",
"-profile:v",
"main",
"-crf",
String(crf),
"-g",
"6",
"-keyint_min",
"6",
"-sc_threshold",
"0",
"-bf",
"0",
"-refs",
"1",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
output,
],
{ encoding: "utf8" },
);
if (result.status !== 0) throw new Error(result.stderr);
return statSync(output).size;
}
for (const scale of [1, 0.9, 0.8]) {
const w = Math.round((width * scale) / 2) * 2;
const h = Math.round((height * scale) / 2) * 2;
for (const crf of [28, 31, 34, 37]) {
const bytes = encode(w, h, crf);
if (bytes <= MAX_BYTES) {
console.log(
`${output}: ${w}x${h}, crf ${crf}, ${Math.round(bytes / 1024)} KB`,
);
process.exit(0);
}
}
}
console.error("não coube no limite de tamanho, nem reduzindo a imagem");
process.exit(1);
+470
View File
@@ -0,0 +1,470 @@
/**
* Grava um vídeo de rolagem em velocidade constante de uma página (Playwright, headless com GPU) e codifica
* MP4 (H.264), WebM (VP9) e um poster WebP, cada vídeo com até 3 MB. É assim que nascem os arquivos de public/videos/<id>/.
*
* Playwright e ffmpeg não são dependências do site. Instale-os numa pasta temporária e rode o script a partir dela:
*
* mkdir rec && cd rec && npm init -y && npm i playwright ffmpeg-static && npx playwright install chromium
* node <repo>/scripts/record-project-videos.mjs --slug lavacao-047 --url https://exemplo.com --out ./out
*
* Opções: --viewports desktop,mobile (1440x900 e 390x844), --speed 300 (px/s), --max 30 (segundos), --settle 3000 (ms),
* --software 1 (rasterização por software; perde quadros em páginas pesadas).
*
* O relógio do vídeo do Playwright não acompanha o relógio real, então o corte entre o aquecimento da página e a
* rolagem usa quadros magenta (início) e ciano (fim) injetados na própria página e procurados no vídeo.
*/
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
// resolve playwright e ffmpeg-static a partir da pasta onde o comando foi executado, não da pasta do script
const require = createRequire(join(process.cwd(), "noop.js"));
const ffmpegPath = require("ffmpeg-static");
const { chromium } = require("playwright");
const args = Object.fromEntries(
process.argv.slice(2).reduce((acc, cur, i, all) => {
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
return acc;
}, []),
);
const slug = args.slug;
const url = args.url;
const outRoot = args.out;
const viewportNames = (args.viewports ?? "desktop,mobile").split(",");
const SPEED = Number(args.speed ?? 300); // px/s
const MAX_SECONDS = Number(args.max ?? 30);
const SETTLE_MS = Number(args.settle ?? 3000);
const MAX_BYTES = 3 * 1024 * 1024;
const FPS = 30;
const VIEWPORTS = {
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
mobile: { width: 390, height: 844, isMobile: true, hasTouch: true },
};
const MOBILE_UA =
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const outDir = join(outRoot, slug);
const rawRoot = join(outRoot, ".raw", slug);
mkdirSync(outDir, { recursive: true });
function ff(argv) {
const r = spawnSync(
ffmpegPath,
["-hide_banner", "-loglevel", "error", "-y", ...argv],
{ encoding: "utf8" },
);
if (r.status !== 0) throw new Error(`ffmpeg falhou: ${r.stderr}`);
}
async function dismissPopups(page) {
return page.evaluate(() => {
const done = [];
const roots = document.querySelectorAll(
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]',
);
for (const root of roots) {
const rect = root.getBoundingClientRect();
const style = getComputedStyle(root);
if (rect.width < 80 || rect.height < 24) continue;
if (
style.visibility === "hidden" ||
style.display === "none" ||
Number(style.opacity) === 0
)
continue;
if (!["fixed", "sticky"].includes(style.position)) continue;
const buttons = [
...root.querySelectorAll('button,a[role="button"],[role="button"]'),
];
const text = (b) =>
(b.innerText || b.getAttribute("aria-label") || "").trim();
const pick =
buttons.find((b) =>
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i.test(
text(b),
),
) ||
buttons.find((b) =>
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i.test(
text(b),
),
);
if (pick) {
done.push(text(pick));
pick.click();
}
}
return done;
});
}
async function flash(page, color) {
await page.evaluate(
(c) =>
new Promise((resolve) => {
const el = document.createElement("div");
el.style.cssText = `position:fixed;inset:0;z-index:2147483647;pointer-events:none;background:${c}`;
document.documentElement.appendChild(el);
setTimeout(() => {
el.remove();
requestAnimationFrame(() => requestAnimationFrame(resolve));
}, 320);
}),
color,
);
await sleep(150);
}
// decodifica o vídeo cru em miniaturas 2x2 e procura as sequências magenta (início) e ciano (fim)
function findMarkers(rawPath) {
const r = spawnSync(
ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-i",
rawPath,
"-vf",
`fps=${FPS},scale=2:2:flags=area,format=rgb24`,
"-f",
"rawvideo",
"-",
],
{ maxBuffer: 1024 * 1024 * 64 },
);
const buf = r.stdout;
const frames = Math.floor(buf.length / 12);
const kind = (i) => {
let R = 0,
G = 0,
B = 0;
for (let p = 0; p < 4; p++) {
R += buf[i * 12 + p * 3];
G += buf[i * 12 + p * 3 + 1];
B += buf[i * 12 + p * 3 + 2];
}
R /= 4;
G /= 4;
B /= 4;
if (R > 190 && G < 80 && B > 190) return "m";
if (R < 80 && G > 190 && B > 190) return "c";
return "-";
};
const seq = Array.from({ length: frames }, (_, i) => kind(i)).join("");
const mStart = seq.indexOf("mmm");
if (mStart < 0) throw new Error("marcador magenta não encontrado no vídeo");
let mEnd = mStart;
while (seq[mEnd + 1] === "m") mEnd += 1;
const cStart = seq.indexOf("ccc", mEnd);
if (cStart < 0) throw new Error("marcador ciano não encontrado no vídeo");
return {
scrollStart: (mEnd + 2) / FPS,
scrollEnd: (cStart - 1) / FPS,
frames,
mStart,
mEnd,
cStart,
};
}
async function waitImages(page, timeout = 15000) {
await page
.evaluate(
(ms) =>
Promise.race([
Promise.all(
[...document.images].map((img) =>
img.complete
? null
: new Promise((res) => {
img.onload = img.onerror = res;
}),
),
),
new Promise((res) => setTimeout(res, ms)),
]),
timeout,
)
.catch(() => {});
}
async function recordOne(browser, vpName) {
const vp = VIEWPORTS[vpName];
const rawDir = join(rawRoot, vpName);
rmSync(rawDir, { recursive: true, force: true });
mkdirSync(rawDir, { recursive: true });
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 1,
isMobile: vp.isMobile,
hasTouch: vp.hasTouch,
userAgent: vp.isMobile ? MOBILE_UA : undefined,
locale: "pt-BR",
timezoneId: "America/Sao_Paulo",
reducedMotion: "no-preference",
recordVideo: { dir: rawDir, size: { width: vp.width, height: vp.height } },
});
await context.addInitScript(() => {
// sem rolagem suave do CSS (o scroll é guiado por tempo) e sem o indicador do Next em dev
const style = document.createElement("style");
style.textContent =
"html,body{scroll-behavior:auto!important}nextjs-portal,#__next-build-watcher,[data-nextjs-toast]{display:none!important}";
document.addEventListener("DOMContentLoaded", () =>
document.head.appendChild(style),
);
});
const page = await context.newPage();
const logs = [];
page.on("pageerror", (e) => logs.push(`pageerror: ${e.message}`));
page.on(
"console",
(m) =>
m.type() === "error" &&
logs.push(`console.error: ${m.text().slice(0, 160)}`),
);
const tStart = Date.now();
const response = await page.goto(url, { waitUntil: "load", timeout: 90000 });
await page
.waitForLoadState("networkidle", { timeout: 20000 })
.catch(() => {});
await page.evaluate(() => document.fonts.ready).catch(() => {});
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
const popups1 = await dismissPopups(page);
// passe de aquecimento: força imagens preguiçosas e conteúdo sob demanda; depois volta ao topo (essa parte é cortada do vídeo)
const warm = await page.evaluate(async () => {
const step = Math.round(window.innerHeight * 0.8);
let guard = 0;
while (
window.scrollY + window.innerHeight <
document.documentElement.scrollHeight - 2 &&
guard++ < 400
) {
window.scrollBy(0, step);
await new Promise((r) => setTimeout(r, 140));
}
return document.documentElement.scrollHeight;
});
await sleep(1200);
await waitImages(page);
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(1800);
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(600);
const popups2 = await dismissPopups(page);
const metrics = await page.evaluate(() => ({
scrollY: window.scrollY,
height: document.documentElement.scrollHeight,
view: window.innerHeight,
title: document.title,
hasHorizontalOverflow:
document.documentElement.scrollWidth > window.innerWidth + 1,
}));
const maxScroll = Math.max(0, metrics.height - metrics.view);
const speed = Math.max(SPEED, maxScroll / (MAX_SECONDS - 2));
const expectedScrollSeconds = maxScroll / speed;
// marcadores visuais (magenta no início, ciano no fim): o relógio do vídeo do Playwright não é confiável, então o corte é feito pelos quadros
await flash(page, "rgb(255,0,255)");
const t0 = Date.now() - tStart;
await sleep(1000);
const result = await page.evaluate(
(speedPxS) =>
new Promise((resolve) => {
const start = performance.now();
const tick = (now) => {
const maxNow =
document.documentElement.scrollHeight - window.innerHeight;
const y = Math.min(((now - start) / 1000) * speedPxS, maxNow);
window.scrollTo(0, y);
if (y >= maxNow - 0.5)
resolve({ endY: window.scrollY, maxNow, ms: now - start });
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
speed,
);
await sleep(1000);
const t1 = Date.now() - tStart;
await flash(page, "rgb(0,255,255)");
await context.close();
const rawFile = readdirSync(rawDir).find((f) => f.endsWith(".webm"));
const rawPath = join(rawDir, rawFile);
const marks = findMarkers(rawPath);
const startAt = marks.scrollStart.toFixed(3);
const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3);
const wallSeconds = (t1 - t0) / 1000;
const trimmed = join(rawDir, "trimmed.mp4");
// intermediário de alta qualidade, só para os cortes
ff([
"-ss",
startAt,
"-i",
rawPath,
"-t",
dur,
"-an",
"-r",
String(FPS),
"-c:v",
"libx264",
"-crf",
"14",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
trimmed,
]);
const sizes = {};
const encode = (kind, scale) => {
const out = join(outDir, `${vpName}.${kind}`);
const vf = scale
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
: [];
const crfs = kind === "mp4" ? [26, 30, 34, 38] : [33, 37, 41, 46];
for (const crf of crfs) {
if (kind === "mp4") {
ff([
"-i",
trimmed,
...vf,
"-an",
"-c:v",
"libx264",
"-preset",
"slow",
"-profile:v",
"main",
"-crf",
String(crf),
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
out,
]);
} else {
ff([
"-i",
trimmed,
...vf,
"-an",
"-c:v",
"libvpx-vp9",
"-b:v",
"0",
"-crf",
String(crf),
"-row-mt",
"1",
"-deadline",
"good",
"-cpu-used",
"2",
"-pix_fmt",
"yuv420p",
out,
]);
}
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
if (sizes[kind].bytes <= MAX_BYTES) return true;
}
return false;
};
for (const kind of ["mp4", "webm"]) {
if (!encode(kind, null)) {
for (const scale of [0.85, 0.7]) if (encode(kind, scale)) break;
}
}
ff([
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
"1",
"-c:v",
"libwebp",
"-quality",
"80",
join(outDir, `${vpName}.webp`),
]);
// folha de contato para revisão visual (fora da pasta de saída)
ff([
"-i",
trimmed,
"-vf",
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${vpName === "mobile" ? 195 : 480}:-1,tile=${vpName === "mobile" ? 8 : 4}x2`,
"-frames:v",
"1",
join(rawRoot, `${vpName}-contact.png`),
]);
return {
vp: vpName,
status: response?.status(),
title: metrics.title,
pageHeight: metrics.height,
warmHeight: warm,
speedPxS: Math.round(speed),
scrollSeconds: +(result.ms / 1000).toFixed(1),
expectedScrollSeconds: +expectedScrollSeconds.toFixed(1),
endY: Math.round(result.endY),
maxNow: Math.round(result.maxNow),
durationS: +dur,
wallSeconds: +wallSeconds.toFixed(1),
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
popupsClosed: [...popups1, ...popups2],
horizontalOverflow: metrics.hasHorizontalOverflow,
sizesKB: Object.fromEntries(
Object.entries(sizes).map(([k, v]) => [
k,
{ kb: Math.round(v.bytes / 1024), crf: v.crf, scale: v.scale },
]),
),
errors: logs.slice(0, 5),
};
}
// "novo headless" do Chromium completo (headless=new) usa a GPU real; o headless-shell cai em rasterização por software e perde quadros
const software = args.software === "1";
const browser = await chromium.launch({
headless: true,
channel: software ? undefined : "chromium",
args: software
? [
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--ignore-gpu-blocklist",
"--autoplay-policy=no-user-gesture-required",
]
: [
"--use-angle=d3d11",
"--enable-gpu-rasterization",
"--ignore-gpu-blocklist",
"--autoplay-policy=no-user-gesture-required",
],
});
const report = [];
for (const vpName of viewportNames) {
try {
report.push(await recordOne(browser, vpName));
} catch (e) {
report.push({ vp: vpName, error: String(e).slice(0, 400) });
}
}
await browser.close();
console.log(JSON.stringify({ slug, url, report }, null, 2));
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.