feat: harden portfolio and add SEO foundations

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-04 19:25:30 -03:00
1 parent b079afdd73
commit 62bcfbd8fa
121 files changed
+7769 -10469

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
name: CI
"on":
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm build
- run: pnpm audit --prod --audit-level=high
- run: pnpm audit --dev --audit-level=high
+1
View File
@@ -46,6 +46,7 @@ next-env.d.ts
.claude/settings.local.json
.superpowers/
/.worktrees/
/.impeccable/
# generated analysis artifacts
/graphify-out/
+107 -63
View File
@@ -1,88 +1,132 @@
<div align="center">
# Evandro Dev — Portfolio
# Evandro.dev.br: portfólio
**A cinematic, interactive developer portfolio built with Next.js, React, TypeScript, and motion-first UI engineering.**
![Next.js](https://img.shields.io/badge/Next.js-15-000?logo=nextdotjs)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=111)
![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=fff)
![Biome](https://img.shields.io/badge/Code%20Style-Biome-60A5FA)
![Jest](https://img.shields.io/badge/Tests-Jest-C21325?logo=jest&logoColor=fff)
Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
</div>
## Overview
## Requisitos
This is the portfolio site for **Evandro Dev**. It presents professional work, case studies, technical positioning, and interactive visual storytelling through a high-polish Next.js interface.
- Node.js `22.x`
- pnpm `9.15.9`, fixado em `package.json#packageManager`
The project combines a modern App Router codebase with motion design, 3D/visual effects, and tested UI components.
O projeto usa Next.js `16.3.0`. Essa versão é intencional: `16.2.11` ainda mantinha três advisories transitivos de severidade alta; `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias atuais sem vulnerabilidades high.
## Highlights
| Area | Details |
| --- | --- |
| Framework | Next.js App Router with React and TypeScript. |
| Motion | GSAP, Framer Motion, Lenis, and custom animation components. |
| Visuals | Three.js / React Three Fiber / Drei for interactive and cinematic surfaces. |
| Styling | Tailwind CSS, utility composition, and reusable component primitives. |
| Quality | Jest, Testing Library, property-based tests, and Biome checks. |
| Content | Portfolio sections, case pages, company logos, hero composition, and supporting docs. |
## Project structure
```txt
app/
page.tsx Main portfolio route
layout.tsx Root layout and metadata
components/ Portfolio sections and cinematic UI modules
cases/ Case-study routes/content
api/ Server/API routes
components/ Shared UI primitives
lib/ Utilities and shared logic
docs/ Supporting documentation
graphify-out/ Project analysis output
public/ Static assets
```
## Getting started
## Início rápido
```bash
pnpm install
pnpm dev
corepack [email protected] install --frozen-lockfile
corepack [email protected] dev
```
The development server uses Turbopack through the project script.
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
```bash
corepack [email protected] build
corepack [email protected] start
```
## Scripts
| Command | Purpose |
| Comando | Comportamento |
| --- | --- |
| `pnpm dev` | Start the Next.js development server. |
| `pnpm build` | Build the production application. |
| `pnpm start` | Start the production server after building. |
| `pnpm lint` | Run Biome checks. |
| `pnpm format` | Format the codebase with Biome. |
| `pnpm test` | Run Jest tests. |
| `pnpm test:watch` | Run Jest in watch mode. |
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
| `pnpm build` | Gera o build de produção com Turbopack. |
| `pnpm start` | Serve um build já gerado. |
| `pnpm lint` | Executa `biome check`. |
| `pnpm typecheck` | Executa TypeScript sem emitir arquivos. |
| `pnpm format` | Formata o repositório com Biome. |
| `pnpm test` | Executa a suíte Jest. |
| `pnpm test:watch` | Executa Jest em modo watch. |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
## Engineering standards
## Stack
- Keep visual sections modular and testable.
- Respect reduced-motion and accessibility expectations for animated sections.
- Keep portfolio copy and case-study content easy to update without touching low-level animation code.
- Validate interactive components with tests when changing behavior, layout assumptions, or accessibility states.
| Área | Pacotes usados |
| --- | --- |
| Aplicação | Next.js 16.3.0, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
| Movimento | Motion 12 e GSAP 3. |
| 3D e gráficos | Three.js, React Three Fiber, Drei e Cobe. |
| Interface | Lucide React. |
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
## Validation
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
Before publishing changes:
## Rotas
```bash
pnpm lint
pnpm test
pnpm build
| Rota | Conteúdo |
| --- | --- |
| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato. |
| `/cases` | Índice dos seis cases. |
| `/cases/nutriscan` | Case NutriScan. |
| `/cases/twins-lab` | Case Twins Lab. |
| `/cases/reddit-explorer` | Case Reddit Posts Explorer. |
| `/cases/sc-plus` | Case SC Plus Aquicultura. |
| `/cases/gcc-installer` | Case MinGW GCC Installer. |
| `/cases/as-servicos` | Case AS Serviços. |
| `/servicos` | Índice dos seis serviços. |
| `/servicos/criacao-de-sites` | Criação de sites. |
| `/servicos/engenharia-de-software` | Engenharia de software. |
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
| `/servicos/tauri-rust` | Desenvolvimento Tauri e Rust. |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
## Comportamento da aplicação
### Conteúdo e project health
O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`. A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não remove cards. Ele apenas controla o estado do CTA de cada demo: disponível, indisponível, desconhecido ou sem demo.
Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md).
### 3D progressivo e movimento reduzido
O hero 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No notebook 3D, o loop de renderização só permanece ativo enquanto a seção cruza o viewport e a página está visível.
### Contato
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail.
### Consentimento de analytics
Google Analytics só é carregado depois de aceite explícito. A decisão fica em `evandro.analytics-consent.v1`; recusar não monta o loader. As preferências permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*` acessíveis.
## Estrutura
```text
app/ App Router, rotas e componentes da interface
lib/ Dados derivados, navegação, JSON-LD e project health
public/ Conteúdo JSON, PDF, fontes, imagens e modelo 3D
scripts/check-projects.mjs
Atualização manual do snapshot de demos
data/project-health.json
Última observação versionada das demos
docs/project-health.md Contrato operacional do snapshot
.github/workflows/ci.yml
Gate único de integração contínua
nixpacks.toml Build e start para Nixpacks
```
## License
## CI e deploy
Personal portfolio project unless a repository license says otherwise.
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high.
O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório.
## Validação local
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] lint
corepack [email protected] typecheck
corepack [email protected] test -- --runInBand
corepack [email protected] build
corepack [email protected] audit --prod --audit-level=high
corepack [email protected] audit --dev --audit-level=high
```
-8
View File
@@ -1,8 +0,0 @@
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
module.exports = ResizeObserver;
+52
View File
@@ -0,0 +1,52 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
function getCaseStudy(slug: string) {
return caseStudies.find(({ id }) => id === slug);
}
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
return {
id: caseStudy.id,
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
size: OG_SIZE,
contentType: OG_CONTENT_TYPE,
};
}
export function generateImageMetadata({
params,
}: {
params: { slug?: string };
}) {
if (!params.slug) {
return caseStudies.map(getImageMetadata);
}
const caseStudy = getCaseStudy(params.slug);
if (!caseStudy) notFound();
return [getImageMetadata(caseStudy)];
}
export default async function Image({
params,
id,
}: {
params: Promise<{ slug: string }>;
id: Promise<string | number>;
}) {
const [{ slug }, imageId] = await Promise.all([params, id]);
const caseStudy = getCaseStudy(slug);
if (!caseStudy || imageId !== caseStudy.id) notFound();
return new ImageResponse(
<OgCard
eyebrow="Case de engenharia de software"
title={caseStudy.title}
description={caseStudy.summary}
/>,
OG_SIZE,
);
}
+324
View File
@@ -0,0 +1,324 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
const publicCases = [
{ id: "nutriscan", title: "NutriScan" },
{ id: "twins-lab", title: "Twins Lab" },
{ id: "reddit-explorer", title: "Reddit Posts Explorer" },
{ id: "sc-plus", title: "SC Plus Aquicultura" },
{ id: "gcc-installer", title: "MinGW GCC Installer" },
{ id: "as-servicos", title: "AS Serviços" },
] as const;
const publicCaseIds = publicCases.map(({ id }) => id);
function getCase(id: (typeof publicCases)[number]["id"]) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
);
if (scripts.length === 0) throw new Error("Missing JSON-LD");
return Array.from(scripts).flatMap((script) => {
if (!script.textContent) throw new Error("Empty JSON-LD");
const value = JSON.parse(script.textContent) as Record<string, unknown>;
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
});
}
function expectCaseCtas() {
expect(
screen.getByRole("link", {
name: "Avaliar perfil para contratação",
}),
).toHaveAttribute("href", "/curriculo");
expect(
screen.getByRole("link", {
name: "Solicitar consultoria técnica",
}),
).toHaveAttribute("href", "/#contact");
}
it("keeps the six public params and factual case metadata", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
const caseStudy = getCase("as-servicos");
const url = `${siteUrl}/cases/${caseStudy.id}`;
const metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title: expect.stringContaining(caseStudy.title),
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
title: caseStudy.title,
description: caseStudy.summary,
url,
},
twitter: {
title: caseStudy.title,
description: caseStudy.summary,
},
});
expect(metadata).not.toHaveProperty("keywords");
});
it("renders the authorized detailed case in order with traceable sources", async () => {
const caseStudy = getCase("as-servicos");
if (!caseStudy.detail) throw new Error("Missing detailed case content");
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(caseStudy.detail.answer.text);
expect(heading.nextElementSibling).toBe(answer);
const sectionNames = [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
];
const sectionHeadings = sectionNames.map((name) =>
screen.getByRole("heading", { level: 2, name }),
);
for (let index = 1; index < sectionHeadings.length; index += 1) {
expect(
sectionHeadings[index - 1].compareDocumentPosition(
sectionHeadings[index],
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
}
const challengeSection = screen
.getByRole("heading", { level: 2, name: "Desafio verificável" })
.closest("section");
if (!challengeSection) throw new Error("Missing challenge section");
const challengeText = within(challengeSection).getByText(
caseStudy.detail.challenge.text,
);
expect(challengeText).toBeVisible();
expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]);
for (const statement of caseStudy.detail.implementation) {
expect(screen.getByText(statement.text)).toBeVisible();
}
for (const boundary of caseStudy.detail.boundaries) {
const boundaryText = screen.getByText(boundary.text);
expect(boundaryText).toBeVisible();
expect(boundaryText.closest("a")).toBeNull();
}
const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ });
expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length);
for (const source of caseStudy.detail.sources) {
const link = screen.getByRole("link", { name: `Fonte: ${source.label}` });
expect(link).toHaveAttribute("href", source.url);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/));
expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//);
const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1];
if (!sha) throw new Error(`Missing source SHA for ${source.id}`);
expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible();
}
expectCaseCtas();
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService|LocalBusiness/,
);
const breadcrumb = graph[0] as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
item: string;
}>;
};
const canonicalUrl = `${siteUrl}/cases/${caseStudy.id}`;
expect(breadcrumb.itemListElement).toEqual([
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
{
"@type": "ListItem",
position: 3,
name: caseStudy.title,
item: canonicalUrl,
},
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
current: link.getAttribute("aria-current"),
})),
).toEqual([
{ name: "Início", href: "/", current: null },
{ name: "Cases", href: "/cases", current: null },
{
name: caseStudy.title,
href: `/cases/${caseStudy.id}`,
current: "page",
},
]);
});
it("keeps the fallback concise and links its declared services", async () => {
const caseStudy = getCase("gcc-installer");
expect(caseStudy.detail).toBeUndefined();
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
expect(screen.getByText(caseStudy.impact)).toBeVisible();
for (const technology of caseStudy.stack) {
expect(screen.getByText(technology)).toBeVisible();
}
expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull();
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
"href",
caseStudy.githubUrl,
);
for (const name of [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
]) {
expect(screen.queryByRole("heading", { name })).toBeNull();
}
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
const expectedServices = servicePages
.filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id))
.map(({ slug, navLabel }) => ({
name: `Ver serviço relacionado: ${navLabel}`,
href: `/servicos/${slug}`,
}))
.sort((a, b) => a.href.localeCompare(b.href));
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver serviço relacionado:/,
});
expect(
relatedLinks
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
}))
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
).toEqual(expectedServices);
expectCaseCtas();
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
"BreadcrumbList",
]);
});
it("keeps the hub cards, schema and distinct intents aligned", () => {
const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i);
const caseLinks = screen.getAllByRole("link", { name: "Ler case" });
const caseHrefs = caseLinks.map((link) => link.getAttribute("href"));
expect(caseHrefs).toHaveLength(6);
expect(new Set(caseHrefs).size).toBe(6);
expect([...caseHrefs].sort()).toEqual(
publicCaseIds.map((id) => `/cases/${id}`).sort(),
);
expectCaseCtas();
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Cases", "/cases"],
]);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CollectionPage",
"ItemList",
"BreadcrumbList",
]);
const itemList = graph[1] as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
url: string;
}>;
};
const visibleItems = caseLinks.map((link, index) => {
const href = link.getAttribute("href");
const expected = publicCases.find(({ id }) => href === `/cases/${id}`);
if (!expected) throw new Error(`Unexpected visible case link ${href}`);
const card = link.closest("article");
if (!card) throw new Error(`Missing visible card for ${href}`);
expect(within(card).getByText(expected.title)).toBeVisible();
return {
"@type": "ListItem",
position: index + 1,
name: expected.title,
url: `${siteUrl}${href}`,
};
});
expect(itemList.itemListElement).toEqual(visibleItems);
});
+161 -57
View File
@@ -2,12 +2,25 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { safeJsonLd } from "@/lib/jsonld";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
import { buildBreadcrumbList } from "@/lib/seo";
import {
caseStudies,
getRelatedServices,
intentCtas,
siteName,
siteUrl,
} from "@/lib/site-content";
interface CasePageProps {
params: Promise<{ slug: string }>;
}
const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//;
function getSourceSha(url: string) {
return sourceShaPattern.exec(url)?.[1];
}
export function generateStaticParams() {
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
}
@@ -45,36 +58,16 @@ export default async function CasePage({ params }: CasePageProps) {
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) notFound();
const url = `${siteUrl}/cases/${caseStudy.id}`;
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
const relatedServices = getRelatedServices(caseStudy.id);
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
headline: caseStudy.title,
description: caseStudy.summary,
mainEntityOfPage: url,
author: { "@id": `${siteUrl}/#person` },
about: caseStudy.stack,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
{
"@type": "ListItem",
position: 3,
name: caseStudy.title,
item: url,
},
],
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", `${siteUrl}/cases`],
[caseStudy.title, pageUrl],
] as const),
],
};
@@ -84,18 +77,52 @@ export default async function CasePage({ params }: CasePageProps) {
<article>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/cases">
Cases
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/cases/${caseStudy.id}`}
>
{caseStudy.title}
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Case de engenharia de software</p>
<h1 className="ds-section-title text-white">
{caseStudy.title}: {caseStudy.subtitle}
</h1>
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.summary}
</p>
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
{caseStudy.detail?.answer.text ?? caseStudy.summary}
</p>
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
</p>
) : null}
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl && (
{caseStudy.demoUrl ? (
<a
className="ds-link ds-link-accent"
href={caseStudy.demoUrl}
@@ -104,8 +131,8 @@ export default async function CasePage({ params }: CasePageProps) {
>
Abrir demonstração
</a>
)}
{caseStudy.githubUrl && (
) : null}
{caseStudy.githubUrl ? (
<a
className="ds-link ds-link-muted"
href={caseStudy.githubUrl}
@@ -114,33 +141,110 @@ export default async function CasePage({ params }: CasePageProps) {
>
Ver código
</a>
)}
) : null}
</div>
</div>
</section>
<section className="ds-section">
{caseStudy.detail ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px] space-y-sp-10">
<section aria-labelledby="case-challenge">
<h2 id="case-challenge" className="ds-section-title">
Desafio verificável
</h2>
<p className="ds-section-description">
{caseStudy.detail.challenge.text}
</p>
</section>
<section aria-labelledby="case-implementation">
<h2 id="case-implementation" className="ds-section-title">
Decisões de implementação
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.implementation.map((statement) => (
<li key={statement.text}>{statement.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-boundaries">
<h2 id="case-boundaries" className="ds-section-title">
Limites desta evidência
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.boundaries.map((boundary) => (
<li key={boundary.text}>{boundary.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-sources">
<h2 id="case-sources" className="ds-section-title">
Fontes
</h2>
<ul className="mt-sp-5 space-y-sp-4">
{caseStudy.detail.sources.map((source) => {
const sha = getSourceSha(source.url);
return (
<li key={source.id}>
<a
className="ds-link ds-link-muted"
href={source.url}
target="_blank"
rel="noreferrer"
>
Fonte: {source.label}
</a>
{sha ? (
<span className="mt-sp-2 block text-[12px] text-[var(--meta)]">
Commit: <code className="break-all">{sha}</code>
</span>
) : null}
</li>
);
})}
</ul>
</section>
</div>
</section>
) : null}
{relatedServices.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Serviços relacionados</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{relatedServices.map((service) => (
<Link
key={service.slug}
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
</div>
</div>
</section>
) : null}
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title !leading-snug">
Tecnologias e contexto técnico
</h2>
<p className="ds-section-description">
Este case demonstra como aplico engenharia de software a produtos
com requisitos de performance, integração e operação, para
empresas do Norte de Santa Catarina, Curitiba e atuação remota.
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<Link className="ds-link ds-link-accent" href="/curriculo">
Ver currículo profissional
<h2 className="ds-card-title text-white">Próximos caminhos</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
className="ds-link ds-link-accent"
href={intentCtas.hiring.href}
>
{intentCtas.hiring.label}
</Link>
<Link className="ds-link ds-link-muted" href="/servicos">
Ver serviços de engenharia
<Link
className="ds-link ds-link-muted"
href={intentCtas.consulting.href}
>
{intentCtas.consulting.label}
</Link>
</div>
</div>
+46 -34
View File
@@ -3,20 +3,24 @@ import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
import { buildBreadcrumbList } from "@/lib/seo";
import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases de Engenharia de Software, Next.js e IoT";
const pageDescription =
"Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados.";
export const metadata: Metadata = {
title: "Cases de Engenharia de Software, Next.js e IoT",
description:
"Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos. Portfolio técnico com foco em clareza, performance e execução.",
title: pageTitle,
description: pageDescription,
alternates: {
canonical: `${siteUrl}/cases`,
canonical: pageUrl,
},
openGraph: {
title: "Cases de Engenharia de Software, Next.js e IoT",
description:
"Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos.",
url: `${siteUrl}/cases`,
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
@@ -28,11 +32,10 @@ export default function CasesPage() {
"@graph": [
{
"@type": "CollectionPage",
"@id": `${siteUrl}/cases#page`,
name: "Cases de engenharia de software",
url: `${siteUrl}/cases`,
description:
"Coleção de cases em Next.js, IoT, integrações e software para operações críticas.",
"@id": `${pageUrl}#page`,
name: pageTitle,
url: pageUrl,
description: pageDescription,
},
{
"@type": "ItemList",
@@ -40,21 +43,13 @@ export default function CasesPage() {
"@type": "ListItem",
position: index + 1,
name: caseStudy.title,
url: `${siteUrl}/cases/${caseStudy.id}`,
url: `${pageUrl}/${caseStudy.id}`,
})),
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
],
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", pageUrl],
] as const),
],
};
@@ -66,14 +61,31 @@ export default function CasesPage() {
<HeroBackground />
<div className="ds-section-shell relative z-10">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/cases"
>
Cases
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Selected work</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Cases em software, Next.js, integrações e produtos conectados.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Esta página organiza projetos com leitura mais indexável e
comercial, conectando stack, contexto técnico e tipo de problema
resolvido.
{pageDescription}
</p>
</div>
</div>
@@ -124,19 +136,19 @@ export default function CasesPage() {
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href="/servicos"
href={intentCtas.hiring.href}
className="ds-link ds-link-accent"
strength={0.25}
>
Ver serviços
{intentCtas.hiring.label}
</MagneticButton>
<MagneticButton
as="a"
href="/#contact"
href={intentCtas.consulting.href}
className="ds-link ds-link-muted"
strength={0.2}
>
Ir para contato
{intentCtas.consulting.label}
</MagneticButton>
</div>
</div>
@@ -1,320 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import CompanyLogos from "./CompanyLogos";
// Mock motion/react with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("motion/react", () => {
const actual = jest.requireActual("motion/react");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
return <div {...props}>{children}</div>;
},
},
};
});
describe("Property 10: Alt Text for Images", () => {
afterEach(() => {
mockReducedMotion = false;
});
it("should have non-empty alt attributes for all company logo images", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image in the CompanyLogos component,
* it should have a non-empty alt attribute describing the company.
* This ensures accessibility for screen readers and assistive technologies.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all images have non-empty alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
},
),
{ numRuns: 100 },
);
});
it("should have descriptive alt text that includes company name", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should be descriptive
* and include identifying information about the company. This ensures
* meaningful accessibility information is provided.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all alt text contains "logo" or is descriptive
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
if (!alt) return false;
const altLower = alt.toLowerCase();
// Alt text should contain "logo" or be at least 5 characters (descriptive)
return altLower.includes("logo") || alt.trim().length >= 5;
});
},
),
{ numRuns: 100 },
);
});
it("should maintain alt text consistency across multiple renders", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any number of component renders, the alt text for each
* company logo should remain consistent. This ensures stable accessibility
* information across re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 2, max: 5 }), // Test 2-5 renders
fc.boolean(), // Random reduced motion preference
(numRenders, shouldReduce) => {
mockReducedMotion = shouldReduce;
let firstRenderAltTexts: string[] = [];
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const currentAltTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
if (i === 0) {
firstRenderAltTexts = currentAltTexts;
} else {
// Compare with first render
if (currentAltTexts.length !== firstRenderAltTexts.length) {
unmount();
return false;
}
// Check that alt texts match (accounting for duplicates in animated mode)
const expectedCount = shouldReduce ? 5 : 10;
if (currentAltTexts.length !== expectedCount) {
unmount();
return false;
}
}
unmount();
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should have unique alt text for each distinct company", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any set of company logos, each distinct company should have
* its own unique alt text. This ensures each company is properly identified
* for accessibility purposes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true; // Use static mode to get unique logos
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const altTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
// In static mode, we should have 5 unique alt texts
const uniqueAltTexts = new Set(altTexts);
// Should have 5 unique alt texts (one per company)
return uniqueAltTexts.size === 5 && altTexts.length === 5;
}),
{ numRuns: 100 },
);
});
it("should preserve alt text in both animated and static modes", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any reduced motion preference, all company logo images
* should maintain their alt text attributes. The animation mode should
* not affect accessibility attributes.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Verify all images have alt attributes
const allHaveAlt = Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
// Verify correct number of images based on mode
const expectedCount = shouldReduce ? 5 : 10;
const hasCorrectCount = logoImages.length === expectedCount;
return allHaveAlt && hasCorrectCount;
},
),
{ numRuns: 100 },
);
});
it("should not have empty or whitespace-only alt text", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should not be empty
* or consist only of whitespace. This ensures meaningful accessibility
* information is always provided.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Check that no image has empty or whitespace-only alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
// Alt must exist and not be empty after trimming
if (!alt || alt.trim().length === 0) {
return false;
}
// Alt should not be just whitespace
return alt !== alt.trim() ? false : true;
});
},
),
{ numRuns: 100 },
);
});
it("should have alt text that matches the expected company names", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should correspond to
* one of the expected company names (Apolo Energia, Julia Beauty Lashes,
* Innovo, TeraByteShop, Pichau). This ensures correct identification.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true; // Use static mode for clarity
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const altTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
// Expected company names in alt text
const expectedCompanies = [
"Apolo Energia",
"Julia Beauty Lashes",
"Innovo",
"TeraByteShop",
"Pichau",
];
// Check that each alt text contains one of the expected company names
return altTexts.every((alt) =>
expectedCompanies.some((company) => alt.includes(company)),
);
}),
{ numRuns: 100 },
);
});
it("should maintain alt text when images fail to load", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image that fails to load, the alt text
* should still be present in the DOM. This ensures accessibility is
* maintained even when images are unavailable.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Verify all images have alt text before any error handling
const allHaveAltBeforeError = Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
// The alt attribute should be present regardless of load state
return allHaveAltBeforeError;
},
),
{ numRuns: 100 },
);
});
});
@@ -1,344 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import CompanyLogos from "./CompanyLogos";
// Mock motion/react with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("motion/react", () => {
const actual = jest.requireActual("motion/react");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
// Capture animation props for testing
const animationProps = {
"data-animate": JSON.stringify(animate),
"data-transition": JSON.stringify(transition),
};
return (
<div {...props} {...animationProps}>
{children}
</div>
);
},
},
};
});
describe("Property 4: Reduced Motion Respect", () => {
afterEach(() => {
mockReducedMotion = false;
});
it("should render static grid layout when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any reduced motion preference, when useReducedMotion() returns true,
* the CompanyLogos component should render a static grid layout without motion
* animations. This ensures accessibility compliance with prefers-reduced-motion.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Check for animated motion.div with animation props
const animatedElements = container.querySelectorAll("[data-animate]");
// Check for static grid layout
const staticGrid = container.querySelector(".grid");
if (shouldReduce) {
// When reduced motion is enabled:
// - Should NOT have animated elements
// - Should have static grid layout
return animatedElements.length === 0 && staticGrid !== null;
} else {
// When reduced motion is disabled:
// - Should have animated elements
// - Should NOT have static grid layout
return animatedElements.length > 0 && staticGrid === null;
}
},
),
{ numRuns: 100 },
);
});
it("should exclude horizontal scroll animation when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any component render with reduced motion enabled, the horizontal
* scroll animation (x: [0, '-50%']) should be completely absent. This verifies
* that motion animations are properly disabled.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed
() => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for animation props
const animatedElements = container.querySelectorAll("[data-animate]");
let hasScrollAnimation = false;
animatedElements.forEach((element) => {
const animateValue = element.getAttribute("data-animate");
if (animateValue && animateValue !== "undefined") {
try {
const props = JSON.parse(animateValue);
// Check for x animation (horizontal scroll)
if (props.x !== undefined) {
hasScrollAnimation = true;
}
} catch (e) {
// Skip invalid JSON
}
}
});
// Should not have scroll animation when reduced motion is enabled
return !hasScrollAnimation;
},
),
{ numRuns: 100 },
);
});
it("should display all company logos in both animated and static modes", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any reduced motion preference, all company logos should be
* displayed. The reduced motion preference should only affect the animation
* style, not the content visibility.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll('img[alt*="logo"]');
// Should have at least 5 logos (the original set)
// In animated mode, we have duplicates, so it will be 10
// In static mode, we have exactly 5
if (shouldReduce) {
return logoImages.length === 5;
} else {
return logoImages.length === 10; // Duplicated for infinite scroll
}
},
),
{ numRuns: 100 },
);
});
it("should maintain accessibility attributes regardless of reduced motion preference", () => {
/**
* **Validates: Requirements 3.6, 6.3, 6.2**
*
* Property: For any reduced motion preference, all company logo images should
* maintain proper alt text attributes. Accessibility should not be compromised
* by the animation mode.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all images have non-empty alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
},
),
{ numRuns: 100 },
);
});
it("should consistently apply reduced motion across multiple renders", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any number of component renders with reduced motion enabled,
* the static layout should be consistently rendered without animations.
* This verifies that the reduced motion behavior is stable across re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
mockReducedMotion = true;
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<CompanyLogos />);
// Check for animated elements
const animatedElements =
container.querySelectorAll("[data-animate]");
// Check for static grid
const staticGrid = container.querySelector(".grid");
// Should have no animations and should have static grid
if (animatedElements.length > 0 || staticGrid === null) {
allRendersValid = false;
}
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should use grid layout classes when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: When reduced motion is enabled, the component should use CSS grid
* layout classes (grid, grid-cols-*) to display logos in a static arrangement.
* This ensures proper visual presentation without animation.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for grid layout classes
const gridContainer = container.querySelector(".grid");
if (!gridContainer) {
return false;
}
// Verify grid has responsive column classes
const hasGridCols = gridContainer.className.includes("grid-cols-");
return hasGridCols;
}),
{ numRuns: 100 },
);
});
it("should not have infinite repeat transition when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: When reduced motion is enabled, the component should not have
* transition configurations with infinite repeat. This ensures no continuous
* motion occurs when users prefer reduced motion.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for transition props
const elementsWithTransition =
container.querySelectorAll("[data-transition]");
let hasInfiniteRepeat = false;
elementsWithTransition.forEach((element) => {
const transitionValue = element.getAttribute("data-transition");
if (transitionValue && transitionValue !== "undefined") {
try {
const props = JSON.parse(transitionValue);
// Check for infinite repeat
if (props.repeat === Infinity || props.repeat === "Infinity") {
hasInfiniteRepeat = true;
}
} catch (e) {
// Skip invalid JSON
}
}
});
// Should not have infinite repeat when reduced motion is enabled
return !hasInfiniteRepeat;
}),
{ numRuns: 100 },
);
});
it("should switch between animated and static modes based on reduced motion preference", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: The component should render different layouts based on the reduced
* motion preference. When the preference changes, the component should adapt
* accordingly. This verifies the conditional rendering logic.
*/
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), // Sequence of preference changes
(preferenceSequence) => {
let allTransitionsValid = true;
preferenceSequence.forEach((shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container, unmount } = render(<CompanyLogos />);
const hasAnimations =
container.querySelectorAll("[data-animate]").length > 0;
const hasStaticGrid = container.querySelector(".grid") !== null;
// Verify correct mode is rendered
if (shouldReduce) {
if (hasAnimations || !hasStaticGrid) {
allTransitionsValid = false;
}
} else {
if (!hasAnimations || hasStaticGrid) {
allTransitionsValid = false;
}
}
unmount();
});
return allTransitionsValid;
},
),
{ numRuns: 100 },
);
});
});
-503
View File
@@ -1,503 +0,0 @@
import { render, screen } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
// Mock motion/react with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("motion/react", () => {
const actual = jest.requireActual("motion/react");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
// Capture animation props for testing
const animationProps = {
"data-animate": JSON.stringify(animate),
"data-transition": JSON.stringify(transition),
};
return (
<div {...props} {...animationProps}>
{children}
</div>
);
},
},
};
});
describe("CompanyLogos Component - Logo Rendering", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should render all five company logos", () => {
const { container } = render(<CompanyLogos />);
// In animated mode, logos are duplicated (10 total)
const logoImages = container.querySelectorAll('img[alt*="logo"]');
expect(logoImages.length).toBe(10); // 5 logos x 2 (duplicated for infinite scroll)
});
it("should render Apolo Energia logo", () => {
render(<CompanyLogos />);
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
expect(apoloLogos.length).toBe(2); // Duplicated for infinite scroll
expect(apoloLogos[0]).toHaveAttribute("src", "/logos/apolo.svg");
});
it("should render Julia Beauty Lashes logo", () => {
render(<CompanyLogos />);
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
expect(juliaLogos.length).toBe(2);
expect(juliaLogos[0]).toHaveAttribute("src", "/logos/julia.svg");
});
it("should render Innovo logo", () => {
render(<CompanyLogos />);
const innovoLogos = screen.getAllByAltText("Innovo logo");
expect(innovoLogos.length).toBe(2);
expect(innovoLogos[0]).toHaveAttribute("src", "/logos/innovo.svg");
});
it("should render TeraByteShop logo", () => {
render(<CompanyLogos />);
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
expect(terabyteLogos.length).toBe(2);
expect(terabyteLogos[0]).toHaveAttribute("src", "/logos/terabyte.svg");
});
it("should render Pichau logo", () => {
render(<CompanyLogos />);
const pichauLogos = screen.getAllByAltText("Pichau logo");
expect(pichauLogos.length).toBe(2);
expect(pichauLogos[0]).toHaveAttribute("src", "/logos/pichau.svg");
});
it("should have alt text for all logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const alt = img.getAttribute("alt");
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
});
});
it("should duplicate logo array for seamless infinite scroll", () => {
const { container } = render(<CompanyLogos />);
// Each company should appear exactly twice
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
const innovoLogos = screen.getAllByAltText("Innovo logo");
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
const pichauLogos = screen.getAllByAltText("Pichau logo");
expect(apoloLogos.length).toBe(2);
expect(juliaLogos.length).toBe(2);
expect(innovoLogos.length).toBe(2);
expect(terabyteLogos.length).toBe(2);
expect(pichauLogos.length).toBe(2);
});
});
describe("CompanyLogos Component - Animation Configuration", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have horizontal scroll animation configuration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
expect(animatedElement).toBeInTheDocument();
const animateValue = animatedElement?.getAttribute("data-animate");
expect(animateValue).toBeTruthy();
const animateProps = JSON.parse(animateValue!);
expect(animateProps.x).toEqual([0, "-50%"]);
});
it("should have 30-second animation duration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
expect(animatedElement).toBeInTheDocument();
const transitionValue = animatedElement?.getAttribute("data-transition");
expect(transitionValue).toBeTruthy();
const transitionProps = JSON.parse(transitionValue!);
expect(transitionProps.duration).toBe(30);
});
it("should have infinite repeat configuration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
const transitionValue = animatedElement?.getAttribute("data-transition");
const transitionProps = JSON.parse(transitionValue!);
// JSON.stringify converts Infinity to null, so we check for null
expect(transitionProps.repeat).toBe(null);
});
it("should use linear easing for smooth animation", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
const transitionValue = animatedElement?.getAttribute("data-transition");
const transitionProps = JSON.parse(transitionValue!);
expect(transitionProps.ease).toBe("linear");
});
it("should have flex layout with gap-sp-12 for logo spacing", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
const classes = animatedElement?.className || "";
expect(classes).toContain("flex");
expect(classes).toContain("gap-sp-12");
});
it("should have items-center alignment", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
const classes = animatedElement?.className || "";
expect(classes).toContain("items-center");
});
});
describe("CompanyLogos Component - Reduced Motion Fallback", () => {
beforeEach(() => {
mockReducedMotion = true;
});
afterEach(() => {
mockReducedMotion = false;
});
it("should render static grid when reduced motion is enabled", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
expect(staticGrid).toBeInTheDocument();
});
it("should not have animated elements when reduced motion is enabled", () => {
const { container } = render(<CompanyLogos />);
const animatedElements = container.querySelectorAll("[data-animate]");
expect(animatedElements.length).toBe(0);
});
it("should render exactly 5 logos in static mode (no duplication)", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll('img[alt*="logo"]');
expect(logoImages.length).toBe(5);
});
it("should have responsive grid columns in static mode", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
const classes = staticGrid?.className || "";
expect(classes).toContain("grid-cols-2");
expect(classes).toContain("sm:grid-cols-3");
expect(classes).toContain("md:grid-cols-5");
});
it("should have gap-sp-6 spacing in static grid", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
const classes = staticGrid?.className || "";
expect(classes).toContain("gap-sp-6");
});
it("should maintain all logo alt text in static mode", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const alt = img.getAttribute("alt");
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
});
});
});
describe("CompanyLogos Component - Missing Logo Image Handling", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have onError handler for logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
// Check that the image element has an onError handler
// In React, this is attached as a prop, so we verify the element exists
expect(img).toBeInTheDocument();
});
});
it("should apply grayscale filter to logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("grayscale");
});
});
it("should have hover effect to remove grayscale", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("hover:grayscale-0");
});
});
it("should have opacity transition on hover", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("opacity-60");
expect(classes).toContain("hover:opacity-100");
});
});
it("should have transition-all for smooth hover effects", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("transition-all");
});
});
});
describe("CompanyLogos Component - Responsive Layout", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have section element as container", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
expect(section).toBeInTheDocument();
});
it("should have full width class", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("w-full");
});
it("should have overflow-hidden for animation containment", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("overflow-hidden");
});
it("should have border-b for visual separation", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("border-b");
});
it("should have background color class", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("bg-[var(--paper)]");
});
it("should have py-sp-8 vertical padding", () => {
const { container } = render(<CompanyLogos />);
// In animated mode, padding is on inner div
const innerDiv = container.querySelector(".py-sp-8");
expect(innerDiv).toBeInTheDocument();
});
it("should have responsive padding in static mode", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
const innerContainer = container.querySelector(".container");
const classes = innerContainer?.className || "";
expect(classes).toContain("px-sp-6");
expect(classes).toContain("py-sp-8");
expect(classes).toContain("md:px-sp-10");
expect(classes).toContain("lg:px-sp-16");
});
it("should have mx-auto for centering in static mode", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
const innerContainer = container.querySelector(".container");
const classes = innerContainer?.className || "";
expect(classes).toContain("mx-auto");
});
});
describe("CompanyLogos Component - Logo Sizing and Styling", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have h-12 height for all logos", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("h-12");
});
});
it("should have w-auto width for aspect ratio preservation", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("w-auto");
});
});
it("should have object-contain for proper image scaling", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("object-contain");
});
});
it("should have flex-shrink-0 on logo containers in animated mode", () => {
const { container } = render(<CompanyLogos />);
const logoContainers = container.querySelectorAll("[data-animate] > div");
logoContainers.forEach((div) => {
const classes = div.className;
expect(classes).toContain("flex-shrink-0");
});
});
it("should center logos within their containers", () => {
const { container } = render(<CompanyLogos />);
const logoContainers = container.querySelectorAll("[data-animate] > div");
logoContainers.forEach((div) => {
const classes = div.className;
expect(classes).toContain("flex");
expect(classes).toContain("items-center");
expect(classes).toContain("justify-center");
});
});
});
describe("CompanyLogos Component - Accessibility", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should use semantic section element", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
expect(section).toBeInTheDocument();
});
it("should have descriptive alt text for each logo", () => {
render(<CompanyLogos />);
expect(screen.getAllByAltText("Apolo Energia logo")).toHaveLength(2);
expect(screen.getAllByAltText("Julia Beauty Lashes logo")).toHaveLength(2);
expect(screen.getAllByAltText("Innovo logo")).toHaveLength(2);
expect(screen.getAllByAltText("TeraByteShop logo")).toHaveLength(2);
expect(screen.getAllByAltText("Pichau logo")).toHaveLength(2);
});
it("should respect reduced motion preference", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Should render static grid instead of animation
const staticGrid = container.querySelector(".grid");
const animatedElements = container.querySelectorAll("[data-animate]");
expect(staticGrid).toBeInTheDocument();
expect(animatedElements.length).toBe(0);
});
it("should maintain content visibility in both modes", () => {
// Test animated mode
mockReducedMotion = false;
const { container: animatedContainer } = render(<CompanyLogos />);
const animatedLogos =
animatedContainer.querySelectorAll('img[alt*="logo"]');
// Test static mode
mockReducedMotion = true;
const { container: staticContainer } = render(<CompanyLogos />);
const staticLogos = staticContainer.querySelectorAll('img[alt*="logo"]');
// Both modes should display logos (different counts due to duplication)
expect(animatedLogos.length).toBeGreaterThan(0);
expect(staticLogos.length).toBeGreaterThan(0);
});
});
-116
View File
@@ -1,116 +0,0 @@
"use client";
/**
* CompanyLogos Component
*
* Displays an infinite-scrolling banner of company logos after the header.
* Features smooth horizontal animation with reduced motion support.
*
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
*/
import { motion } from "motion/react";
import Image from "next/image";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
// TypeScript interface for company logos
export interface CompanyLogo {
name: string; // Company name
imagePath: string; // Path to logo image (SVG preferred)
alt: string; // Alt text for accessibility
}
// Company logos data
const companies: CompanyLogo[] = [
{
name: "Apolo Energia",
imagePath: "/logos/apolo.svg",
alt: "Apolo Energia logo",
},
{
name: "Julia Beauty Lashes",
imagePath: "/logos/julia.svg",
alt: "Julia Beauty Lashes logo",
},
{
name: "Innovo",
imagePath: "/logos/innovo.svg",
alt: "Innovo logo",
},
{
name: "TeraByteShop",
imagePath: "/logos/terabyte.svg",
alt: "TeraByteShop logo",
},
{
name: "Pichau",
imagePath: "/logos/pichau.svg",
alt: "Pichau logo",
},
];
export default function CompanyLogos() {
const shouldReduceMotion = useReducedMotion();
// Duplicate logo array for seamless infinite scroll
const duplicatedLogos = [...companies, ...companies];
// Static grid for reduced motion preference
if (shouldReduceMotion) {
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="container mx-auto px-sp-6 py-sp-8 md:px-sp-10 lg:px-sp-16">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-sp-6 items-center justify-items-center">
{companies.map((logo) => (
<div key={logo.name} className="flex items-center justify-center">
<Image
src={logo.imagePath}
alt={logo.alt}
width={150}
height={48}
className="h-12 w-auto object-contain grayscale opacity-60"
/>
</div>
))}
</div>
</div>
</section>
);
}
// Animated infinite scroll
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="py-sp-8" data-reel="zoom" data-reel-distance="40">
<motion.div
className="flex gap-sp-12 items-center"
animate={{
x: [0, "-50%"],
}}
transition={{
duration: 30,
repeat: Infinity,
ease: "linear",
}}
>
{duplicatedLogos.map((logo, index) => (
<div
key={`${logo.name}-${index}`}
className="flex-shrink-0 flex items-center justify-center"
// Second half is a visual duplicate for seamless looping; hide from AT
aria-hidden={index >= companies.length ? true : undefined}
>
<Image
src={logo.imagePath}
alt={logo.alt}
width={150}
height={48}
className="h-12 w-auto object-contain grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
/>
</div>
))}
</motion.div>
</div>
</section>
);
}
-911
View File
@@ -1,911 +0,0 @@
"use client";
import { motion } from "motion/react";
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
interface ContactFormProps {
formType: "simple" | "advanced";
setFormType: (type: "simple" | "advanced") => void;
}
export function ContactForm({ formType, setFormType }: ContactFormProps) {
const { data } = usePortfolioData();
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
"whatsapp",
);
// MENTORIA: Agrupamento de estado para reduzir complexidade e re-renders
const [formData, setFormData] = useState({
userName: "",
userEmail: "",
userPhone: "",
userCompany: "",
howFound: "",
projectType: "",
otherProjectType: "",
desiredDeadline: "",
budgetValue: "",
technicalComplexity: "",
hasInternalTeam: "",
customMessage: "",
});
const [isDecisor, setIsDecisor] = useState<string>("");
const [projectApproved, setProjectApproved] = useState<string>("");
const [urgency, setUrgency] = useState<string>("");
const [preferredModality, setPreferredModality] = useState<string>("");
const [isExpanded, setIsExpanded] = useState<boolean>(false); // Novo estado para controlar a expansão do formulário avançado
const [nameError, setNameError] = useState<string | null>(null);
const [emailError, setEmailError] = useState<string | null>(null);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [otherProjectTypeError, setOtherProjectTypeError] = useState<
string | null
>(null); // Novo estado de erro
const [honeypot, setHoneypot] = useState<string>(""); // Estado para o campo honeypot
const [startTime, setStartTime] = useState<number>(0); // Estado para o tempo de início do formulário
const [nameFocused, setNameFocused] = useState<boolean>(false);
const [emailFocused, setEmailFocused] = useState<boolean>(false);
const [phoneFocused, setPhoneFocused] = useState<boolean>(false);
useEffect(() => {
setStartTime(Date.now()); // Registra o tempo de carregamento do componente
}, []);
if (!data) {
return null; // Ou um componente de carregamento/erro
}
const getGreetingMessageWithUserName = () => {
const hour = new Date().getHours();
const dayPart =
hour >= 8 && hour < 12
? "manhã"
: hour >= 12 && hour < 18
? "tarde"
: "noite";
return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva.
Meu nome é ${formData.userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva.
Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos.
Atenciosamente,
${formData.userName}`;
};
const generateAdvancedMessage = () => {
if (formData.customMessage) return formData.customMessage;
// Saudação profissional
let message = `Prezado Evandro,\n\n`;
// Informações do contato
message += `Meu nome é ${formData.userName}`;
if (formData.userCompany) {
message += `, represento a empresa ${formData.userCompany}`;
}
message += `.\n`;
if (formData.howFound) {
message += `Conheci seu trabalho através de ${formData.howFound}.\n`;
}
message += `\n`;
// Detalhes do projeto
message += `=== DETALHES DO PROJETO ===\n`;
message += `Tipo de projeto: `;
if (formData.projectType === "outro" && formData.otherProjectType) {
message += `${formData.otherProjectType}\n`;
} else if (formData.projectType) {
message += `${formData.projectType}\n`;
} else {
message += `Não especificado\n`;
}
if (formData.desiredDeadline) {
message += `Prazo desejado: ${formData.desiredDeadline}\n`;
}
if (formData.budgetValue) {
message += `Orçamento disponível: ${formData.budgetValue}\n`;
}
if (formData.technicalComplexity) {
message += `Complexidade técnica estimada: ${formData.technicalComplexity}\n`;
}
if (formData.hasInternalTeam) {
message += `Equipe técnica interna: ${
formData.hasInternalTeam === "sim"
? "Sim"
: formData.hasInternalTeam === "nao"
? "Não"
: "Parcialmente"
}\n`;
}
message += `\n`;
// Contatos
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
if (formData.userEmail) {
message += `📧 Email: ${formData.userEmail}\n`;
}
if (formData.userPhone) {
message += `📱 Telefone/WhatsApp: ${formData.userPhone}\n`;
}
message += `\n`;
// Mensagem final
message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`;
message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`;
message += `Atenciosamente,\n`;
message += `${formData.userName}`;
return message;
};
const handleInputChange =
(field: keyof typeof formData) =>
(
e: ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>,
) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
// Clear specific errors if the user starts typing
if (e.target.value.trim() !== "") {
if (field === "userName") setNameError(null);
if (field === "userEmail") setEmailError(null);
if (field === "userPhone") setPhoneError(null);
if (field === "otherProjectType") setOtherProjectTypeError(null);
}
};
const handleNameFocus = () => setNameFocused(true);
const handleEmailFocus = () => setEmailFocused(true);
const handlePhoneFocus = () => setPhoneFocused(true);
const generateWhatsAppUrl = () =>
buildWhatsAppUrl(
formType === "advanced"
? generateAdvancedMessage()
: formData.customMessage || getGreetingMessageWithUserName(),
);
const generateEmailUrl = () => {
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
const body = encodeURIComponent(
formType === "advanced"
? generateAdvancedMessage()
: `Nome: ${formData.userName}\nEmail: ${formData.userEmail}\nTelefone: ${formData.userPhone}\nEmpresa/Posição: ${formData.userCompany}\nComo conheceu: ${formData.howFound}\n\nTipo de Projeto: ${formData.projectType}\nPrazo Desejado: ${formData.desiredDeadline}\nOrçamento Disponível: ${formData.budgetValue}\nComplexidade Técnica: ${formData.technicalComplexity}\nTem equipe interna: ${formData.hasInternalTeam}\n\nMensagem: ${
formData.customMessage || getGreetingMessageWithUserName()
}`,
);
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
let hasError = false;
if (formData.userName.trim() === "") {
setNameError("Por favor, insira seu nome.");
hasError = true;
} else {
setNameError(null);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.userEmail)) {
setEmailError("Por favor, insira um email válido.");
hasError = true;
} else {
setEmailError(null);
}
if (formData.userPhone.trim() === "") {
setPhoneError("Por favor, insira seu telefone.");
hasError = true;
} else {
setPhoneError(null);
}
if (
formData.projectType === "outro" &&
formData.otherProjectType.trim() === ""
) {
setOtherProjectTypeError("Por favor, especifique o tipo de projeto.");
hasError = true;
} else {
setOtherProjectTypeError(null);
}
// Validação Honeypot
if (honeypot) {
console.log("Honeypot preenchido, provável bot.");
return; // Ignora a submissão
}
// UX FIX: Validação de tempo com feedback ou limite menor
const timeElapsed = Date.now() - startTime;
// Reduzido para 1000ms para evitar falsos positivos com autofill
if (timeElapsed < 1000) {
console.log("Formulário preenchido muito rápido, provável bot.");
// Opcional: Mostrar erro para humano real que digita muito rápido
// alert("Por favor, verifique os dados antes de enviar.");
return; // Ignora a submissão
}
if (hasError) return;
// Verificação de interação mínima
if (!nameFocused || !emailFocused || !phoneFocused) {
// MENTORIA: Isso pode bloquear usuários de Screen Readers ou Autofill agressivo.
// Considere remover ou tornar apenas um aviso no console.
console.warn(
"Campos principais não foram focados, verificando integridade.",
);
}
if (contactMethod === "whatsapp") {
window.open(generateWhatsAppUrl(), "_blank");
} else {
window.open(generateEmailUrl(), "_blank");
}
};
const getCtaMessage = () => {
if (formData.projectType === "consultoria") {
return "Agendar Consultoria Gratuita";
}
if (
formData.budgetValue &&
parseFloat(formData.budgetValue.replace(/\D/g, "")) < 2000
) {
return "Discutir Soluções de Baixo Custo";
}
if (
formData.projectType &&
formData.desiredDeadline &&
formData.budgetValue
) {
return "Receber Proposta Personalizada";
}
return "Enviar Formulário";
};
const getEstimatedValue = () => {
let hours = 0;
let hourlyRate = 55; // Taxa base R$ 55/h
// Estimativa de horas baseada no tipo de projeto
switch (formData.projectType) {
case "web-app":
hours += 80; // Aplicação web completa
break;
case "landing-page":
hours += 16; // Landing page simples
break;
case "mvp":
hours += 40; // MVP básico
break;
case "iot-app":
hours += 120; // Aplicação IoT complexa
break;
case "integracao":
hours += 32; // Integração de sistemas
break;
case "manutencao":
hours += 8; // Manutenção pontual
break;
case "consultoria":
hours += 4; // Consultoria inicial
break;
default:
break;
}
// Ajuste de horas baseado no prazo
switch (formData.desiredDeadline) {
case "1-3-weeks":
hours *= 1.3; // Urgência aumenta horas
break;
case "1-2-months":
hours *= 1.1;
break;
case "6-plus-months":
hours *= 0.9; // Prazo longo pode reduzir horas
break;
default:
break;
}
// Ajuste de horas baseado na complexidade
switch (formData.technicalComplexity) {
case "alta":
hours *= 1.4;
hourlyRate = 65; // Taxa maior para alta complexidade
break;
case "muito-alta":
hours *= 1.7;
hourlyRate = 70; // Taxa máxima para muito alta complexidade
break;
case "media":
hourlyRate = 50; // Taxa média
break;
default:
break;
}
if (formData.hasInternalTeam === "nao") {
hours *= 1.2; // Sem equipe interna = mais horas necessárias
}
const totalValue = hours * hourlyRate;
if (totalValue > 0) {
return `Estimativa: ${Math.round(
hours,
)}h × R$ ${hourlyRate}/h = R$ ${totalValue.toFixed(0)}`;
}
return "Estimativa: Solicite orçamento personalizado";
};
return (
<motion.div
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
viewport={{ once: true }}
className="space-y-4"
>
<h3 className="text-2xl font-semibold mb-6 bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
Preencha o Formulário
</h3>
<div className="flex justify-center gap-4 mb-8">
<button
type="button"
onClick={() => setFormType("simple")}
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
formType === "simple"
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
}`}
>
Formulário Simples
</button>
<button
type="button"
onClick={() => setFormType("advanced")}
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
formType === "advanced"
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
}`}
>
Formulário Avançado
</button>
</div>
{formType === "simple" ? (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Campo Honeypot */}
<div style={{ display: "none" }}>
<label htmlFor="honeypot">Não preencha este campo</label>
<input
type="text"
id="honeypot"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
/>
</div>
{/* Formulário Simples: Apenas Nome */}
<div>
<label
htmlFor="simple-user-name"
className="block text-sm font-medium text-gray-300 mb-2"
>
Seu Nome:
</label>
<input
type="text"
id="simple-user-name"
className={`w-full p-3 rounded-lg bg-white/5 border ${
nameError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="Seu nome"
value={formData.userName}
onChange={handleInputChange("userName")}
onFocus={handleNameFocus}
required
/>
{nameError && (
<p className="text-red-400 text-xs mt-1">{nameError}</p>
)}
</div>
{/* Botão de Contato para o formulário simples */}
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<button
type="submit"
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-green-600 hover:bg-green-700 text-white shadow-lg shadow-green-600/25"
onClick={() => window.open(generateWhatsAppUrl(), "_blank")}
>
Enviar Mensagem via WhatsApp
</button>
</motion.div>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Campo Honeypot */}
<div style={{ display: "none" }}>
<label htmlFor="honeypot">Não preencha este campo</label>
<input
type="text"
id="honeypot"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
/>
</div>
{/* Dados Básicos */}
<div>
<h4 className="text-xl font-semibold mb-4 text-gray-200">
Dados Básicos
</h4>
<div className="space-y-4">
<div>
<label
htmlFor="user-name"
className="block text-sm font-medium text-gray-300 mb-2"
>
Nome Completo:
</label>
<input
type="text"
id="user-name"
className={`w-full p-3 rounded-lg bg-white/5 border ${
nameError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="Seu nome completo"
value={formData.userName}
onChange={handleInputChange("userName")}
onFocus={handleNameFocus}
required
/>
{nameError && (
<p className="text-red-400 text-xs mt-1">{nameError}</p>
)}
</div>
<div>
<label
htmlFor="user-email"
className="block text-sm font-medium text-gray-300 mb-2"
>
Email Corporativo:
</label>
<input
type="email"
id="user-email"
className={`w-full p-3 rounded-lg bg-white/5 border ${
emailError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="[email protected]"
value={formData.userEmail}
onChange={handleInputChange("userEmail")}
onFocus={handleEmailFocus}
required
/>
{emailError && (
<p className="text-red-400 text-xs mt-1">{emailError}</p>
)}
</div>
<div>
<label
htmlFor="user-phone"
className="block text-sm font-medium text-gray-300 mb-2"
>
WhatsApp/Telefone:
</label>
<input
type="tel"
id="user-phone"
className={`w-full p-3 rounded-lg bg-white/5 border ${
phoneError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="(XX) XXXXX-XXXX"
value={formData.userPhone}
onChange={handleInputChange("userPhone")}
onFocus={handlePhoneFocus}
required
/>
{phoneError && (
<p className="text-red-400 text-xs mt-1">{phoneError}</p>
)}
</div>
<div>
<label
htmlFor="user-company"
className="block text-sm font-medium text-gray-300 mb-2"
>
Empresa/Posição:
</label>
<input
type="text"
id="user-company"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="Nome da empresa, sua posição"
value={formData.userCompany}
onChange={handleInputChange("userCompany")}
/>
</div>
<div>
<label
htmlFor="how-found"
className="block text-sm font-medium text-gray-300 mb-2"
>
Como conheceu meu trabalho?
</label>
<select
id="how-found"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.howFound}
onChange={handleInputChange("howFound")}
>
{" "}
<option value="" className="bg-gray-800 text-white">
Selecione uma opção
</option>
<option value="linkedin" className="bg-gray-800 text-white">
LinkedIn
</option>
<option value="github" className="bg-gray-800 text-white">
GitHub
</option>
<option value="indicacao" className="bg-gray-800 text-white">
Indicação
</option>
<option value="pesquisa" className="bg-gray-800 text-white">
Pesquisa Online
</option>
<option value="outro" className="bg-gray-800 text-white">
Outro
</option>
</select>
</div>
</div>
</div>
{/* Tile de Expandir/Recolher */}
<div
onClick={() => setIsExpanded(!isExpanded)}
className="p-4 rounded-lg bg-white/5 border border-white/10 text-white cursor-pointer hover:bg-white/10 transition-colors duration-300 mb-6 flex justify-between items-center"
>
<h4 className="text-xl font-semibold text-gray-200">
{isExpanded
? "Detalhes do Projeto (Recolher)"
: "Detalhes do Projeto (Expandir)"}
</h4>
<svg
className={`w-6 h-6 transition-transform duration-300 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</div>
<div
className={`overflow-hidden transition-all duration-500 ease-in-out ${
isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
} space-y-6`}
>
{/* Conteúdo do Formulário Avançado */}
{/* Sobre o Projeto */}
<div>
<h4 className="text-xl font-semibold mb-4 text-gray-200">
Sobre o Projeto
</h4>
<div className="space-y-4">
<div>
<label
htmlFor="project-type"
className="block text-sm font-medium text-gray-300 mb-2"
>
Tipo de Projeto:
</label>
<select
id="project-type"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.projectType}
onChange={handleInputChange("projectType")}
>
<option value="" className="bg-gray-800 text-white">
Selecione o tipo
</option>
<option value="web-app" className="bg-gray-800 text-white">
Aplicação Web Completa
</option>
<option
value="landing-page"
className="bg-gray-800 text-white"
>
Landing Page
</option>
<option value="mvp" className="bg-gray-800 text-white">
MVP (Produto Mínimo Viável)
</option>
<option value="iot-app" className="bg-gray-800 text-white">
Aplicação IoT
</option>
<option
value="integracao"
className="bg-gray-800 text-white"
>
Integração de Sistemas
</option>
<option
value="manutencao"
className="bg-gray-800 text-white"
>
Manutenção/Ajustes
</option>
<option
value="consultoria"
className="bg-gray-800 text-white"
>
Consultoria/Mentoria
</option>
<option value="outro" className="bg-gray-800 text-white">
Outro
</option>
</select>
</div>
{formData.projectType === "outro" && (
<div>
<label
htmlFor="other-project-type"
className="block text-sm font-medium text-gray-300 mb-2"
>
Qual outro tipo de projeto?
</label>
<input
type="text"
id="other-project-type"
className={`w-full p-3 rounded-lg bg-white/5 border ${
otherProjectTypeError
? "border-red-500"
: "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="Ex: Desenvolvimento de Plugin, Automação de Processos"
value={formData.otherProjectType}
onChange={handleInputChange("otherProjectType")}
required
/>
{otherProjectTypeError && (
<p className="text-red-400 text-xs mt-1">
{otherProjectTypeError}
</p>
)}
</div>
)}
<div>
<label
htmlFor="desired-deadline"
className="block text-sm font-medium text-gray-300 mb-2"
>
Prazo Desejado:
</label>
<select
id="desired-deadline"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.desiredDeadline}
onChange={handleInputChange("desiredDeadline")}
>
<option value="" className="bg-gray-800 text-white">
Selecione o prazo
</option>
<option
value="1-3-weeks"
className="bg-gray-800 text-white"
>
1-3 Semanas
</option>
<option
value="1-2-months"
className="bg-gray-800 text-white"
>
1-2 Meses
</option>
<option
value="2-4-months"
className="bg-gray-800 text-white"
>
2-4 Meses
</option>
<option
value="4-6-months"
className="bg-gray-800 text-white"
>
4-6 Meses
</option>
<option
value="6-plus-months"
className="bg-gray-800 text-white"
>
Mais de 6 Meses
</option>
</select>
</div>
<div>
<label
htmlFor="budget-value"
className="block text-sm font-medium text-gray-300 mb-2"
>
Orçamento Disponível (R$):
</label>
<input
type="text"
id="budget-value"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="Ex: 5000, 10000, A Combinar"
value={formData.budgetValue}
onChange={handleInputChange("budgetValue")}
/>
</div>
<div>
<label
htmlFor="technical-complexity"
className="block text-sm font-medium text-gray-300 mb-2"
>
Complexidade Técnica Estimada:
</label>
<select
id="technical-complexity"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.technicalComplexity}
onChange={handleInputChange("technicalComplexity")}
>
<option value="" className="bg-gray-800 text-white">
Selecione a complexidade
</option>
<option value="baixa" className="bg-gray-800 text-white">
Baixa
</option>
<option value="media" className="bg-gray-800 text-white">
Média
</option>
<option value="alta" className="bg-gray-800 text-white">
Alta
</option>
<option
value="muito-alta"
className="bg-gray-800 text-white"
>
Muito Alta
</option>
</select>
</div>
<div>
<label
htmlFor="has-internal-team"
className="block text-sm font-medium text-gray-300 mb-2"
>
Já tem equipe técnica interna?
</label>
<select
id="has-internal-team"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.hasInternalTeam}
onChange={handleInputChange("hasInternalTeam")}
>
<option value="" className="bg-gray-800 text-white">
Selecione
</option>
<option value="sim" className="bg-gray-800 text-white">
Sim
</option>
<option value="nao" className="bg-gray-800 text-white">
Não
</option>
<option value="parcial" className="bg-gray-800 text-white">
Parcialmente
</option>
</select>
</div>
</div>
</div>
{/* Custom Message Input */}
<div className="mb-6">
<label
htmlFor="custom-message"
className="block text-sm font-medium text-gray-300 mb-2"
>
Mensagem Adicional (Opcional):
</label>
<textarea
id="custom-message"
rows={4}
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="Descreva seu projeto ou suas necessidades aqui..."
value={formData.customMessage}
onChange={handleInputChange("customMessage")}
></textarea>
</div>
</div>
{/* Contact Button */}
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<button
type="submit"
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
>
{getCtaMessage()}
</button>
</motion.div>
{formData.projectType &&
formData.desiredDeadline &&
formData.budgetValue && (
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
{getEstimatedValue()}
</p>
)}
</form>
)}
</motion.div>
);
}
-147
View File
@@ -1,147 +0,0 @@
"use client";
import { Mail, MessageCircle } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import MagneticButton from "./cinematic/MagneticButton";
interface ContactInfoProps {
onWhatsAppClick: () => void;
}
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
const { data } = usePortfolioData();
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
setWhatsAppDisplay(getWhatsAppDisplayNumber());
}, []);
if (!data) {
return null; // Ou um componente de carregamento/erro
}
return (
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
viewport={{ once: true }}
className="space-y-4"
>
<div>
<h3 className="text-2xl font-semibold mb-6 text-blue-400">
Entre em Contato
</h3>
<div className="space-y-4">
<MagneticButton
as="a"
href={`mailto:${data.contact.email}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-blue-500/20">
<Mail className="w-6 h-6 text-blue-400" />
</div>
<div>
<p className="text-white font-medium">Email</p>
<p className="text-gray-400">{data.contact.email}</p>
</div>
</MagneticButton>
<MagneticButton
onClick={onWhatsAppClick}
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-green-500/20">
<MessageCircle className="w-6 h-6 text-green-400" />
</div>
<div>
<p className="text-white font-medium">WhatsApp</p>
<p className="text-gray-400">{whatsAppDisplay}</p>
</div>
</MagneticButton>
<MagneticButton
as="a"
href={data.contact.linkedin}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-blue-500/20">
<svg
className="w-6 h-6 text-blue-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</div>
<div>
<p className="text-white font-medium">LinkedIn</p>
<p className="text-gray-400">
{data.contact.linkedin.replace("https://www.", "")}
</p>
</div>
</MagneticButton>
<MagneticButton
as="a"
href={data.contact.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-gray-500/20">
<svg
className="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</div>
<div>
<p className="text-white font-medium">GitHub</p>
<p className="text-gray-400">
{data.contact.github.replace("https://github.com/", "")}
</p>
</div>
</MagneticButton>
</div>
</div>
<div>
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
Localização
</h3>
<p className="text-gray-300">📍 {data.contact.location}</p>
<p className="text-gray-400 mt-2">
Disponível para projetos remotos e trabalho presencial na região.
</p>
</div>
{/* Timeline de Resposta (Placeholder) */}
<div>
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
Timeline de Resposta
</h3>
<p className="text-gray-300">
Em breve, uma timeline detalhada do processo de contato e resposta.
</p>
</div>
</motion.div>
);
}
@@ -1,127 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import type { HTMLAttributes, ReactNode } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import Experience from "./Experience";
type MotionProps = HTMLAttributes<HTMLElement> & {
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
whileHover?: unknown;
whileTap?: unknown;
transition?: unknown;
};
type SectionMockProps = {
children?: ReactNode;
title?: string;
eyebrow?: string;
description?: string;
disableChildrenAnimation?: boolean;
id?: string;
className?: string;
};
jest.mock("motion/react", () => {
const createMotionElement =
(Tag: "div" | "article") =>
({
children,
initial: _initial,
whileInView: _whileInView,
viewport: _viewport,
variants: _variants,
whileHover: _whileHover,
whileTap: _whileTap,
transition: _transition,
...props
}: MotionProps & { children?: ReactNode }) => (
<Tag {...props}>{children}</Tag>
);
return {
motion: {
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
jest.mock("./Section", () => ({
Section: ({
children,
title,
eyebrow: _eyebrow,
description: _description,
disableChildrenAnimation: _disableChildrenAnimation,
...props
}: SectionMockProps) => (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <span data-testid="draw-line" />,
}));
function makeExperience(index: number, featured: boolean): PortfolioExperience {
return {
id: `experience-${index}`,
title: `Experience ${index}`,
company: "Company",
period: `${2020 + index} - Present`,
location: "Remote",
type: index % 2 === 0 ? "Software" : "Hardware",
description: "Description",
achievements: [],
technologies: [],
featured,
};
}
describe("Experience featured filtering", () => {
it("renders exactly the featured experiences", () => {
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 1, maxLength: 8 }),
(featuredFlags) => {
const experiences = featuredFlags.map((featured, index) =>
makeExperience(index, featured),
);
const { container, unmount } = render(
<Experience experiences={experiences} />,
);
const articles = Array.from(container.querySelectorAll("article"));
const expectedCount = featuredFlags.filter(Boolean).length;
expect(articles).toHaveLength(expectedCount);
const articleText = articles.map(
(article) => article.textContent ?? "",
);
featuredFlags.forEach((featured, index) => {
const title = `Experience ${index}`;
expect(articleText.some((text) => text.includes(title))).toBe(
featured,
);
});
unmount();
},
),
{ numRuns: 25 },
);
});
it("renders an empty experience list safely", () => {
const { container } = render(<Experience experiences={[]} />);
expect(container.querySelector("section")).toBeInTheDocument();
expect(container.querySelectorAll("article")).toHaveLength(0);
});
});
@@ -1,146 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import type { HTMLAttributes, ReactNode } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import Experience from "./Experience";
type MotionProps = HTMLAttributes<HTMLElement> & {
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
whileHover?: unknown;
whileTap?: unknown;
transition?: unknown;
};
type SectionMockProps = {
children?: ReactNode;
title?: string;
eyebrow?: string;
description?: string;
disableChildrenAnimation?: boolean;
id?: string;
className?: string;
};
jest.mock("motion/react", () => {
const createMotionElement =
(Tag: "div" | "article") =>
({
children,
initial: _initial,
whileInView: _whileInView,
viewport: _viewport,
variants: _variants,
whileHover: _whileHover,
whileTap: _whileTap,
transition: _transition,
...props
}: MotionProps & { children?: ReactNode }) => (
<Tag {...props}>{children}</Tag>
);
return {
motion: {
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
jest.mock("./Section", () => ({
Section: ({
children,
title,
eyebrow: _eyebrow,
description: _description,
disableChildrenAnimation: _disableChildrenAnimation,
...props
}: SectionMockProps) => (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <span data-testid="draw-line" />,
}));
function makeExperience(
index: number,
year: number,
period = `${year} - Present`,
): PortfolioExperience {
return {
id: `experience-${index}`,
title: `Experience ${index}`,
company: "Company",
period,
location: "Remote",
type: "Software",
description: "Description",
achievements: [],
technologies: [],
featured: true,
};
}
describe("Experience chronological ordering", () => {
it("sorts featured experiences by the first year in descending order", () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1990, max: 2035 }), {
minLength: 1,
maxLength: 8,
}),
(years) => {
const experiences = years.map((year, index) =>
makeExperience(index, year),
);
const { container, unmount } = render(
<Experience experiences={experiences} />,
);
const renderedYears = Array.from(
container.querySelectorAll("article .ds-kicker"),
).map((element) => Number.parseInt(element.textContent ?? "0", 10));
const expectedExperiences = [...experiences].sort(
(a, b) =>
Number.parseInt(b.period, 10) - Number.parseInt(a.period, 10),
);
const expectedYears = expectedExperiences.map((experience) =>
Number.parseInt(experience.period, 10),
);
const renderedTitles = Array.from(
container.querySelectorAll("article h3"),
).map((element) => element.textContent);
expect(renderedYears).toEqual(expectedYears);
expect(renderedTitles).toEqual(
expectedExperiences.map((experience) => experience.title),
);
unmount();
},
),
{ numRuns: 25 },
);
});
it("sorts a period range by its first year", () => {
const experiences = [
makeExperience(1, 2022, "2022 - 2024"),
makeExperience(2, 2024, "2024 - Present"),
makeExperience(3, 2021, "2021 - 2023"),
];
const { container } = render(<Experience experiences={experiences} />);
expect(
Array.from(container.querySelectorAll("article .ds-kicker")).map(
(element) => element.textContent,
),
).toEqual(["2024 - Present", "2022 - 2024", "2021 - 2023"]);
});
});
@@ -1,132 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import type { HTMLAttributes, ReactNode } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import Experience from "./Experience";
type MotionProps = HTMLAttributes<HTMLElement> & {
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
whileHover?: unknown;
whileTap?: unknown;
transition?: unknown;
};
type SectionMockProps = {
children?: ReactNode;
title?: string;
eyebrow?: string;
description?: string;
disableChildrenAnimation?: boolean;
id?: string;
className?: string;
};
jest.mock("motion/react", () => {
const createMotionElement =
(Tag: "div" | "article") =>
({
children,
initial: _initial,
whileInView: _whileInView,
viewport: _viewport,
variants: _variants,
whileHover: _whileHover,
whileTap: _whileTap,
transition: _transition,
...props
}: MotionProps & { children?: ReactNode }) => (
<Tag {...props}>{children}</Tag>
);
return {
motion: {
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
jest.mock("./Section", () => ({
Section: ({
children,
title,
eyebrow: _eyebrow,
description: _description,
disableChildrenAnimation: _disableChildrenAnimation,
...props
}: SectionMockProps) => (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <span data-testid="draw-line" />,
}));
function makeExperience(index: number): PortfolioExperience {
return {
id: `experience-${index}`,
title: `Experience ${index}`,
company: "Company",
period: `${2020 + index} - Present`,
location: "Remote",
type: index % 2 === 0 ? "Software" : "Hardware",
description: "Description",
achievements: [],
technologies: [],
featured: true,
};
}
describe("Experience heading hierarchy", () => {
it("renders one h2 section heading and h3 headings for featured cards", () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 8 }), (count) => {
const experiences = Array.from({ length: count }, (_, index) =>
makeExperience(index),
);
const { container, unmount } = render(
<Experience experiences={experiences} />,
);
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
expect(headings[0]?.tagName).toBe("H2");
expect(container.querySelector("section > h2")).toHaveTextContent(
"Experiência em engenharia de software e sistemas críticos",
);
expect(container.querySelectorAll("section > h2")).toHaveLength(1);
expect(container.querySelectorAll("article h3")).toHaveLength(count);
expect(
new Set(
Array.from(container.querySelectorAll("article h3")).map(
(heading) => heading.textContent,
),
),
).toEqual(new Set(experiences.map((experience) => experience.title)));
expect(headings.some((heading) => heading.tagName === "H4")).toBe(
false,
);
unmount();
}),
{ numRuns: 25 },
);
});
it("renders no card headings when no experiences are supplied", () => {
const { container } = render(<Experience />);
expect(container.querySelector("section > h2")).toBeInTheDocument();
expect(container.querySelectorAll("article h3")).toHaveLength(0);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { render, screen, within } from "@testing-library/react";
import type { HTMLAttributes, ReactNode } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import Experience from "./Experience";
type MotionProps = HTMLAttributes<HTMLElement> & {
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
whileHover?: unknown;
whileTap?: unknown;
transition?: unknown;
};
jest.mock("motion/react", () => {
const createMotionElement =
(Tag: "div" | "article") =>
({
children,
initial: _initial,
whileInView: _whileInView,
viewport: _viewport,
variants: _variants,
whileHover: _whileHover,
whileTap: _whileTap,
transition: _transition,
...props
}: MotionProps & { children?: ReactNode }) => (
<Tag {...props}>{children}</Tag>
);
return {
motion: {
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
jest.mock("./Section", () => ({
Section: ({
children,
id,
title,
}: {
children?: ReactNode;
id?: string;
title?: string;
}) => (
<section id={id}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => null,
}));
function experience({
id,
title,
period,
featured,
}: {
id: string;
title: string;
period: string;
featured: boolean;
}): PortfolioExperience {
return {
id,
title,
company: "Empresa",
period,
location: "Remoto",
type: "Software",
description: "Descrição",
achievements: [],
technologies: [],
featured,
};
}
describe("Experience", () => {
it("renders only featured experiences from newest to oldest", () => {
render(
<Experience
experiences={[
experience({
id: "hidden",
title: "Oculta",
period: "2026 - Atual",
featured: false,
}),
experience({
id: "older",
title: "Mais antiga",
period: "2019 - 2021",
featured: true,
}),
experience({
id: "recent",
title: "Mais recente",
period: "2024 - Atual",
featured: true,
}),
]}
/>,
);
expect(
screen.queryByRole("heading", { name: "Oculta" }),
).not.toBeInTheDocument();
expect(
screen
.getAllByRole("article")
.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
),
).toEqual(["Mais recente", "Mais antiga"]);
});
});
+6 -2
View File
@@ -79,7 +79,9 @@ export default function Experience({
<div className="max-w-[720px]">
<div className="ds-kicker">{exp.period}</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title" data-reel="left">{exp.title}</h3>
<h3 className="ds-card-title" data-reel="left">
{exp.title}
</h3>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
{exp.company} • {exp.type}
</p>
@@ -87,7 +89,9 @@ export default function Experience({
<div className="ds-metric">{exp.location}</div>
</div>
<p className="ds-copy" data-reel="right">{exp.description}</p>
<p className="ds-copy" data-reel="right">
{exp.description}
</p>
<ul className="ds-list">
{exp.achievements?.map((achievement) => (
@@ -1,300 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import Footer from "./Footer";
describe("Property 9: ARIA Labels for Links", () => {
it("should have descriptive aria-label attributes for all social media links", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link in the Footer component, it should have
* a descriptive aria-label attribute that identifies the platform and action.
* This ensures accessibility for users with assistive technologies.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find all social media links (they have SVG icons and are in the Connect section)
const socialLinks = container.querySelectorAll(
"footer a[aria-label]",
);
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Check each social link has a non-empty, descriptive aria-label
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
// aria-label must exist and be non-empty
if (!ariaLabel || ariaLabel.trim().length === 0) {
return false;
}
// aria-label should be descriptive (at least 5 characters)
// and should describe an action (contain words like "Visit", "View", "Go to", etc.)
return ariaLabel.length >= 5;
});
},
),
{ numRuns: 100 },
);
});
it("should have unique aria-label for each social platform", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any set of social media links, each should have a unique
* aria-label that identifies the specific platform. This ensures users can
* distinguish between different social links when using screen readers.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links with aria-labels
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Collect all aria-labels
const ariaLabels = socialLinksWithSvg.map((link) =>
link.getAttribute("aria-label"),
);
// Verify we have aria-labels
if (ariaLabels.length === 0) {
return false;
}
// Check that all aria-labels are unique
const uniqueLabels = new Set(ariaLabels);
return uniqueLabels.size === ariaLabels.length;
}),
{ numRuns: 100 },
);
});
it("should have aria-label that identifies both platform and action", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link, the aria-label should contain both
* the platform name (GitHub, LinkedIn, etc.) and an action verb (Visit, View, etc.).
* This provides complete context for assistive technology users.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Common action verbs for social links
const actionVerbs = ["visit", "view", "go to", "open", "see", "check"];
// Check each link has an action verb in its aria-label
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
if (!ariaLabel) {
return false;
}
const lowerLabel = ariaLabel.toLowerCase();
// Check if aria-label contains at least one action verb
return actionVerbs.some((verb) => lowerLabel.includes(verb));
});
}),
{ numRuns: 100 },
);
});
it("should maintain aria-labels across multiple renders", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any number of component renders, social links should consistently
* maintain their aria-label attributes. This verifies that accessibility attributes
* are not accidentally removed during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Find social links
const socialLinks = container.querySelectorAll(
"footer a[aria-label]",
);
const socialLinksWithSvg = Array.from(socialLinks).filter(
(link) => {
return link.querySelector("svg") !== null;
},
);
// Check each link has a valid aria-label
socialLinksWithSvg.forEach((link) => {
const ariaLabel = link.getAttribute("aria-label");
if (!ariaLabel || ariaLabel.trim().length < 5) {
allRendersValid = false;
}
});
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it('should have aria-label for every social link with target="_blank"', () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social link that opens in a new tab (target="_blank"),
* it must have an aria-label. This ensures users with assistive technologies
* are informed about where the link leads before activating it.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all links that open in new tab
const newTabLinks = container.querySelectorAll(
'footer a[target="_blank"]',
);
// Verify we found links
if (newTabLinks.length === 0) {
return false;
}
// Each new tab link should have an aria-label
return Array.from(newTabLinks).every((link) => {
const ariaLabel = link.getAttribute("aria-label");
return ariaLabel !== null && ariaLabel.trim().length > 0;
});
}),
{ numRuns: 100 },
);
});
it("should not have empty or whitespace-only aria-labels", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link with an aria-label attribute,
* the value should not be empty or contain only whitespace. Empty
* aria-labels provide no accessibility benefit and can confuse screen readers.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Check that no aria-label is empty or whitespace-only
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
return ariaLabel !== null && ariaLabel.trim().length > 0;
});
}),
{ numRuns: 100 },
);
});
it("should have aria-label that matches or relates to the link title", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link with both aria-label and title attributes,
* the aria-label should provide context that relates to the title (platform name).
* This ensures consistency between visual tooltips and screen reader announcements.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links with both aria-label and title
const socialLinks = container.querySelectorAll(
"footer a[aria-label][title]",
);
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// If no links have both attributes, that's okay (not all implementations require title)
if (socialLinksWithSvg.length === 0) {
return true;
}
// Check that aria-label relates to title
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
const title = link.getAttribute("title");
if (!ariaLabel || !title) {
return false;
}
// aria-label should contain the platform name from title
// or title should be contained in aria-label
const lowerLabel = ariaLabel.toLowerCase();
const lowerTitle = title.toLowerCase();
return (
lowerLabel.includes(lowerTitle) || lowerTitle.includes(lowerLabel)
);
});
}),
{ numRuns: 100 },
);
});
});
@@ -1,36 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import Footer from "./Footer";
describe("Footer responsive structure", () => {
it("keeps the responsive design-system structure across renders", () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 5 }), (renderCount) => {
for (let index = 0; index < renderCount; index += 1) {
const { container, unmount } = render(<Footer />);
const footer = container.querySelector("footer");
const grid = footer?.querySelector(".ds-grid-2");
expect(footer).toHaveClass("ds-section");
expect(
footer?.querySelector(".ds-section-shell"),
).toBeInTheDocument();
expect(grid).toBeInTheDocument();
expect(grid?.querySelectorAll(":scope > .ds-card")).toHaveLength(2);
unmount();
}
}),
{ numRuns: 25 },
);
});
it("uses the mobile-first grid primitive instead of the legacy grid contract", () => {
const { container } = render(<Footer />);
const grid = container.querySelector(".ds-grid-2");
expect(grid).toBeInTheDocument();
expect(grid).not.toHaveClass("grid-cols-1");
expect(grid).not.toHaveClass("lg:grid-cols-3");
});
});
@@ -1,246 +0,0 @@
import { render } from "@testing-library/react";
import fc from "fast-check";
import Footer from "./Footer";
describe("Property 2: Social Links Open in New Tab", () => {
it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any social media link in the Footer component, it should have
* target="_blank" and rel="noopener noreferrer" attributes to open in a new tab
* securely. This ensures consistent behavior across all social links and prevents
* security vulnerabilities (tabnabbing).
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find all social media links (they are within the "Connect" section)
const socialLinks = container.querySelectorAll(
'a[aria-label*="Visit"]',
);
// Verify we found social links
if (socialLinks.length === 0) {
return false;
}
// Check each social link
let allLinksValid = true;
socialLinks.forEach((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
// Both attributes must be present and correct
if (target !== "_blank" || rel !== "noopener noreferrer") {
allLinksValid = false;
}
});
return allLinksValid;
},
),
{ numRuns: 100 },
);
});
it("should consistently apply security attributes across all social link variations", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any subset of social links rendered, each link should maintain
* the security attributes (target="_blank" and rel="noopener noreferrer").
* This property verifies that the security pattern is consistently applied
* regardless of which social platforms are included.
*/
fc.assert(
fc.property(
fc.integer({ min: 0, max: 10 }), // Random seed for variation
() => {
const { container } = render(<Footer />);
// Get all anchor tags in the footer
const allLinks = container.querySelectorAll("footer a");
// Filter to only social links (those with SVG icons and aria-labels about visiting profiles)
const socialLinks = Array.from(allLinks).filter((link) => {
const ariaLabel = link.getAttribute("aria-label");
const hasSvg = link.querySelector("svg") !== null;
return ariaLabel && ariaLabel.includes("Visit") && hasSvg;
});
// Verify we found social links
if (socialLinks.length === 0) {
return false;
}
// Check that ALL social links have the required attributes
return socialLinks.every((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
return target === "_blank" && rel === "noopener noreferrer";
});
},
),
{ numRuns: 100 },
);
});
it("should not apply new tab attributes to non-social links", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: Only social media links should have target="_blank". Other links
* in the footer (like email) should not force new tab behavior. This ensures
* the new tab behavior is intentionally applied only where appropriate.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Get all anchor tags in the footer
const allLinks = container.querySelectorAll("footer a");
// Separate social links from other links
const socialLinks: Element[] = [];
const otherLinks: Element[] = [];
allLinks.forEach((link) => {
const ariaLabel = link.getAttribute("aria-label");
const hasSvg = link.querySelector("svg") !== null;
if (ariaLabel && ariaLabel.includes("Visit") && hasSvg) {
socialLinks.push(link);
} else {
otherLinks.push(link);
}
});
// All social links should have target="_blank"
const allSocialLinksHaveTarget = socialLinks.every((link) => {
return link.getAttribute("target") === "_blank";
});
// Non-social links (like email) should NOT have target="_blank"
const noOtherLinksHaveTarget = otherLinks.every((link) => {
return link.getAttribute("target") !== "_blank";
});
return allSocialLinksHaveTarget && noOtherLinksHaveTarget;
}),
{ numRuns: 100 },
);
});
it("should maintain security attributes after multiple renders", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any number of component renders, social links should consistently
* maintain their security attributes. This verifies that the attributes are not
* accidentally removed or modified during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Find social links
const socialLinks = container.querySelectorAll(
'a[aria-label*="Visit"]',
);
// Check each link
socialLinks.forEach((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
if (target !== "_blank" || rel !== "noopener noreferrer") {
allRendersValid = false;
}
});
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should have both target and rel attributes present together", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any social link, if target="_blank" is present, then
* rel="noopener noreferrer" must also be present. These attributes work
* together to provide secure new tab behavior, and one without the other
* creates a security vulnerability.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all links with target="_blank"
const linksWithTarget = container.querySelectorAll(
'footer a[target="_blank"]',
);
// Verify we found links
if (linksWithTarget.length === 0) {
return false;
}
// Each link with target="_blank" must have rel="noopener noreferrer"
return Array.from(linksWithTarget).every((link) => {
const rel = link.getAttribute("rel");
return rel === "noopener noreferrer";
});
}),
{ numRuns: 100 },
);
});
it("should have exactly the expected number of social links with security attributes", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: The Footer should render a specific number of social links (based on
* the socialLinks array), and all of them should have the security attributes.
* This ensures no links are accidentally missing the attributes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social links with security attributes
const secureLinks = container.querySelectorAll(
'footer a[target="_blank"][rel="noopener noreferrer"]',
);
// Find all social links (with aria-label containing "Visit")
const allSocialLinks = container.querySelectorAll(
'footer a[aria-label*="Visit"]',
);
// The number of secure links should equal the number of social links
// This ensures ALL social links have the security attributes
return (
secureLinks.length === allSocialLinks.length &&
allSocialLinks.length > 0
);
}),
{ numRuns: 100 },
);
});
});
+1 -12
View File
@@ -72,18 +72,7 @@ describe("Footer social links", () => {
});
});
describe("Footer structure", () => {
it("uses the current design-system shell and two-card grid", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer");
expect(footer).toHaveClass("ds-section");
expect(footer?.querySelector(".ds-section-shell")).toBeInTheDocument();
expect(footer?.querySelector(".ds-grid-2")).toBeInTheDocument();
expect(footer?.querySelectorAll(":scope .ds-card")).toHaveLength(2);
expect(footer?.querySelectorAll("h3")).toHaveLength(2);
});
describe("Footer copyright", () => {
it("renders the current year without hardcoded copyright text", () => {
render(<Footer />);
+220
View File
@@ -0,0 +1,220 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import GoogleAnalytics from "./GoogleAnalytics";
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S";
type AnalyticsWindow = Window & {
dataLayer?: IArguments[];
gtag?: (...args: unknown[]) => void;
[GA_DISABLE_KEY]?: boolean;
};
const analyticsWindow = window as AnalyticsWindow;
function consentUpdates(gtag: jest.Mock) {
return gtag.mock.calls.filter(
([command, action]) => command === "consent" && action === "update",
);
}
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
describe("GoogleAnalytics consent", () => {
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
document.getElementById("google-analytics-script")?.remove();
localStorage.clear();
setCookie("_ga=; Max-Age=0; Path=/");
setCookie("_ga_PORTFOLIO=; Max-Age=0; Path=/");
setCookie("portfolio_session=; Max-Age=0; Path=/");
analyticsWindow.dataLayer = undefined;
analyticsWindow.gtag = undefined;
analyticsWindow[GA_DISABLE_KEY] = undefined;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("waits for a decision before loading analytics", async () => {
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Aceitar analytics" }),
).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
it("persists acceptance and enables analytics without advertising consent", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
"src",
GA_SRC,
);
const dataLayer = analyticsWindow.dataLayer;
expect(dataLayer).toHaveLength(4);
expect(dataLayer?.every((entry) => !Array.isArray(entry))).toBe(true);
expect(Array.from(dataLayer?.[0] ?? [])).toEqual([
"consent",
"default",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(Array.from(dataLayer?.[1] ?? [])).toEqual(["js", expect.any(Date)]);
expect(Array.from(dataLayer?.[2] ?? [])).toEqual([
"consent",
"update",
{
analytics_storage: "granted",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(Array.from(dataLayer?.[3] ?? [])).toEqual([
"config",
"G-QTT9QT0T3S",
{
allow_ad_personalization_signals: false,
anonymize_ip: true,
},
]);
expect(
screen.getByRole("button", { name: "Preferências de analytics" }),
).not.toHaveFocus();
});
it("persists refusal and keeps analytics absent after reload", async () => {
const user = userEvent.setup();
const firstRender = render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
firstRender.unmount();
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Preferências de analytics" }),
).toBeVisible();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
it("keeps preferences reachable and fully revokes analytics only", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=main; Path=/");
setCookie("_ga_PORTFOLIO=property; Path=/");
setCookie("portfolio_session=keep; Path=/");
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(consentUpdates(gtag)).toContainEqual([
"consent",
"update",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(document.cookie).not.toContain("_ga=");
expect(document.cookie).not.toContain("_ga_PORTFOLIO=");
expect(document.cookie).toContain("portfolio_session=keep");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
const preferencesButton = screen.getByRole("button", {
name: "Preferências de analytics",
});
expect(preferencesButton).toBeVisible();
expect(preferencesButton).toHaveFocus();
});
it("restores focus after closing or applying opened preferences", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(<GoogleAnalytics />);
const preferencesButton = await screen.findByRole("button", {
name: "Preferências de analytics",
});
await user.click(preferencesButton);
await user.click(
screen.getByRole("button", { name: "Fechar preferências" }),
);
expect(preferencesButton).toHaveFocus();
await user.click(preferencesButton);
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
expect(preferencesButton).toHaveFocus();
});
it("fails closed when local storage is unavailable", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage blocked");
});
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
await waitFor(() => {
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
});
});
+268 -19
View File
@@ -1,33 +1,282 @@
"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") {
const deniedConsent = {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
} as const;
type AnalyticsConsent = "granted" | "denied";
type Gtag = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer?: IArguments[];
"ga-disable-G-QTT9QT0T3S"?: boolean;
gtag?: Gtag;
}
}
function readConsent(): AnalyticsConsent | null {
try {
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
return value === "granted" || value === "denied" ? value : null;
} catch {
return null;
}
}
function persistConsent(value: AnalyticsConsent): boolean {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, value);
return true;
} catch {
return false;
}
}
function getGtag(): Gtag {
window.dataLayer ??= [];
const dataLayer = window.dataLayer;
if (!window.gtag) {
window.gtag = function gtag() {
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
dataLayer.push(arguments);
};
}
return window.gtag;
}
function removeAnalyticsLoader() {
document.getElementById("google-analytics-script")?.remove();
}
function writeCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
document.cookie = value;
}
function expireAnalyticsCookies() {
const cookieNames = new Set(
document.cookie
.split(";")
.map((cookie) => cookie.split("=")[0]?.trim())
.filter(
(name): name is string =>
name === "_ga" || Boolean(name?.startsWith("_ga_")),
),
);
const { hostname } = window.location;
const domains =
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
? new Set([hostname, SITE_DOMAIN])
: new Set<string>();
for (const name of cookieNames) {
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
writeCookie(expiration);
for (const domain of domains) {
writeCookie(`${expiration}; Domain=${domain}`);
}
}
}
const panelClassName =
"fixed bottom-sp-4 left-sp-4 right-sp-4 z-[9980] ml-auto max-w-[560px] border border-[var(--slash)] bg-[var(--ink)] p-sp-5 text-white shadow-[8px_8px_0_var(--slash)] sm:left-auto";
const buttonClassName =
"inline-flex min-h-11 min-w-11 items-center justify-center border px-sp-4 py-sp-2 font-mono text-[11px] font-bold uppercase tracking-[0.08em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false);
const configuredRef = useRef(false);
const preferencesButtonRef = useRef<HTMLButtonElement>(null);
const enableAnalytics = useCallback(() => {
const gtag = getGtag();
window[GA_DISABLE_KEY] = false;
if (!configuredRef.current) {
gtag("consent", "default", deniedConsent);
gtag("js", new Date());
configuredRef.current = true;
}
gtag("consent", "update", {
...deniedConsent,
analytics_storage: "granted",
});
gtag("config", GA_MEASUREMENT_ID, {
allow_ad_personalization_signals: false,
anonymize_ip: true,
});
}, []);
useEffect(() => {
const storedConsent = readConsent();
if (storedConsent === "granted") enableAnalytics();
setConsent(storedConsent);
}, [enableAnalytics]);
useEffect(() => {
if (consent !== "granted" && consent !== undefined) {
removeAnalyticsLoader();
}
}, [consent]);
function closePreferences() {
setPreferencesOpen(false);
preferencesButtonRef.current?.focus();
}
function acceptAnalytics() {
if (!persistConsent("granted")) return;
enableAnalytics();
setConsent("granted");
closePreferences();
}
function denyAnalytics() {
persistConsent("denied");
setConsent("denied");
closePreferences();
}
function revokeAnalytics() {
persistConsent("denied");
window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies();
setConsent("denied");
closePreferences();
}
if (consent === undefined) return null;
if (consent === null) {
return (
<section
role="dialog"
aria-modal="false"
aria-labelledby="analytics-consent-title"
aria-describedby="analytics-consent-description"
className={panelClassName}
>
<p
id="analytics-consent-title"
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
>
{"Privacidade // Analytics"}
</p>
<p
id="analytics-consent-description"
className="mt-sp-2 max-w-[48ch] text-[13px] leading-6 text-white/85"
>
Você decide se permite métricas anônimas para melhorar este site.
Publicidade e personalização continuam desativadas.
</p>
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={acceptAnalytics}
>
Aceitar analytics
</button>
<button
type="button"
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar analytics
</button>
</div>
</section>
);
}
return (
<>
<Script src="/sys/libs/vendor-utils.js" strategy="lazyOnload" />
<Script id="google-analytics" strategy="lazyOnload">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
transport_url: window.location.origin + '/sys/api/vitals',
cookie_flags: 'SameSite=None;Secure',
allow_ad_personalization_signals: false,
anonymize_ip: true,
page_location: window.location.href,
page_title: document.title
});
`}
</Script>
{consent === "granted" && (
<Script
id="google-analytics-script"
data-testid="google-analytics-script"
src={GA_SCRIPT_SRC}
strategy="afterInteractive"
/>
)}
<button
type="button"
ref={preferencesButtonRef}
aria-expanded={preferencesOpen}
aria-controls="analytics-preferences"
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
onClick={() => setPreferencesOpen((open) => !open)}
>
Preferências de analytics
</button>
{preferencesOpen && (
<section
id="analytics-preferences"
role="dialog"
aria-modal="false"
aria-labelledby="analytics-preferences-title"
aria-describedby="analytics-preferences-description"
className={panelClassName}
>
<p
id="analytics-preferences-title"
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
>
{"Preferências // Analytics"}
</p>
<p
id="analytics-preferences-description"
className="mt-sp-2 text-[13px] leading-6 text-white/85"
>
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
Dados de publicidade permanecem negados.
</p>
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
{consent === "granted" ? (
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={revokeAnalytics}
>
Revogar analytics
</button>
) : (
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={acceptAnalytics}
>
Aceitar analytics
</button>
)}
<button
type="button"
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
onClick={closePreferences}
>
Fechar preferências
</button>
</div>
</section>
)}
</>
);
}
export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") return null;
return <GoogleAnalyticsConsent />;
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function Header() {
<button
ref={menuButtonRef}
type="button"
className="site-header-menu-toggle flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
className="site-header-menu-toggle flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
+67 -42
View File
@@ -1,18 +1,37 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero from "./Hero";
jest.mock("next/dynamic", () => () => {
const DynamicComponent = ({ variant }: { variant?: string }) => (
<div data-testid="hero-scene" data-variant={variant} />
);
const DynamicComponent = () => <div data-testid="hero-scene" />;
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
});
jest.mock("motion/react", () => {
const React = require("react");
const MotionStateContext = React.createContext({
initial: undefined,
animate: undefined,
});
const resolveOpacity = (
value: unknown,
variants: Record<string, { opacity?: number }> | undefined,
) => {
const state: unknown =
typeof value === "string" ? variants?.[value] : value;
if (
state &&
typeof state === "object" &&
"opacity" in state &&
typeof state.opacity === "number"
) {
return state.opacity;
}
return undefined;
};
const motion = new Proxy(
{},
@@ -22,26 +41,47 @@ jest.mock("motion/react", () => {
children,
initial,
animate,
variants,
transition: _transition,
whileHover: _whileHover,
whileTap: _whileTap,
variants: _variants,
transition: _transition,
style,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: unknown;
variants?: Record<string, { opacity?: number }>;
style?: Record<string, unknown>;
} & Record<string, unknown>) => {
const dataProps = {
...(initial !== undefined
? { "data-initial": JSON.stringify(initial) }
: {}),
...(animate !== undefined
? { "data-animate": JSON.stringify(animate) }
: {}),
};
const inherited = React.useContext(MotionStateContext);
const effectiveInitial =
initial === undefined ? inherited.initial : initial;
const effectiveAnimate =
animate === undefined ? inherited.animate : animate;
const opacity = resolveOpacity(
effectiveInitial === false ? effectiveAnimate : effectiveInitial,
variants,
);
const element = React.createElement(
tag,
{
...props,
style: opacity === undefined ? style : { ...style, opacity },
},
children,
);
return React.createElement(tag, { ...props, ...dataProps }, children);
return React.createElement(
MotionStateContext.Provider,
{
value: {
initial: effectiveInitial,
animate: effectiveAnimate,
},
},
element,
);
};
Component.displayName = `motion.${tag}`;
return Component;
@@ -96,46 +136,31 @@ function mockMatchMedia(matches: boolean) {
describe("Hero reduced motion", () => {
beforeEach(() => {
mockMatchMedia(true);
jest.spyOn(Math, "random").mockReturnValue(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("keeps essential hero content visible with reduced motion enabled", async () => {
it("shows active copy and actions immediately without animation timers", () => {
render(<Hero />);
await waitFor(() =>
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
);
expect(
const visibleContent = [
screen.getByText(/Disponível para novas oportunidades/i),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
level: 1,
name: /engenharia de software para sistemas/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
).toBeInTheDocument();
});
screen.getByRole("button", {
name: /conversar sobre uma oportunidade/i,
}),
screen.getByRole("link", { name: /ver casos selecionados/i }),
screen.getByRole("link", { name: /download cv/i }),
];
it("keeps motion blocks declarative instead of requiring 3D", async () => {
const { container } = render(<Hero />);
await waitFor(() =>
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
);
const rootMotionBlock = container.querySelector(
"div[data-initial][data-animate]",
);
expect(rootMotionBlock).toBeInTheDocument();
expect(rootMotionBlock?.getAttribute("data-initial")).toContain("hidden");
expect(rootMotionBlock?.getAttribute("data-animate")).toContain("visible");
for (const element of visibleContent) {
expect(element).toBeVisible();
}
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
});
+43 -32
View File
@@ -1,12 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero from "./Hero";
jest.mock("next/dynamic", () => () => {
const DynamicComponent = ({ variant }: { variant?: string }) => (
<div data-testid="hero-scene" data-variant={variant} />
);
const DynamicComponent = () => <div data-testid="hero-scene" />;
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
});
@@ -82,7 +80,6 @@ function mockMatchMedia(matches = false) {
describe("Hero", () => {
beforeEach(() => {
mockMatchMedia(false);
jest.spyOn(Math, "random").mockReturnValue(0);
jest.spyOn(window, "open").mockImplementation(() => null);
});
@@ -90,54 +87,68 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("renders the command center hero and primary actions", async () => {
const { container } = render(<Hero />);
await waitFor(() =>
expect(container.querySelector("[data-hero-variant]")).toHaveAttribute(
"data-hero-variant",
"command-center",
),
);
it("renders the published command-center content and primary actions", () => {
render(<Hero />);
expect(
screen.getByText("Disponível para novas oportunidades"),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
level: 1,
name: /engenharia de software para sistemas/i,
name: "Engenharia de software para sistemas que precisam operar de verdade.",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
screen.getByRole("button", {
name: "Conversar sobre uma oportunidade",
}),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /ver casos selecionados/i }),
screen.getByRole("button", { name: "Agendar no WhatsApp" }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver casos selecionados" }),
).toHaveAttribute("href", "/cases");
expect(screen.getByRole("link", { name: /download cv/i })).toHaveAttribute(
expect(screen.getByRole("link", { name: "Download CV" })).toHaveAttribute(
"href",
"/Profile.pdf",
);
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
it("keeps social links, proof content and WhatsApp CTA", async () => {
it("keeps social destinations, operational proof and WhatsApp action", () => {
render(<Hero />);
await waitFor(() =>
expect(
screen.getByText(/Disponível para novas oportunidades/i),
).toBeInTheDocument(),
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
"href",
"https://github.com/evandrodevbr",
);
expect(screen.getByLabelText("GitHub")).toBeInTheDocument();
expect(screen.getByLabelText("LinkedIn")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
expect(screen.getByText(/Operação/i)).toBeInTheDocument();
expect(screen.getByText(/Clareza/i)).toBeInTheDocument();
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
"href",
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
);
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
"href",
"mailto:[email protected]",
);
expect(screen.getByText("Garuva, SC / Brasil")).toBeInTheDocument();
expect(screen.getByText("Operação")).toBeInTheDocument();
expect(screen.getByText("Clareza")).toBeInTheDocument();
expect(screen.getByText("Resultado")).toBeInTheDocument();
expect(
screen.getByRole("region", {
name: "Telemetria operacional em tempo real",
}),
).toBeInTheDocument();
expect(
screen.getByText(/Globo interativo centrado em Joinville/i),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
screen.getByRole("button", {
name: "Conversar sobre uma oportunidade",
}),
);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining("https://wa.me/"),
+119 -472
View File
@@ -1,6 +1,6 @@
"use client";
"use client";
import { motion, type Variants } from "motion/react";
import { motion, useReducedMotion, type Variants } from "motion/react";
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
@@ -15,33 +15,6 @@ const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
ssr: false,
});
type HeroSceneVariant =
| "command"
| "launch"
| "rescue"
| "signal"
| "diagnostic"
| "portrait";
type HeroVariant = {
id: string;
weight: number;
scene: HeroSceneVariant;
layout: "default" | "visual-first" | "split" | "report" | "portrait";
status: string;
chip: string;
eyebrow: string;
headline: string;
subtitle: string;
cta: string;
whatsappMessage: string;
sideLabel: string;
sideTitle: string;
sideText: string;
points: string[];
proof: Array<{ label: string; value: string }>;
};
const SOCIAL_LINKS = [
{
href: "https://github.com/evandrodevbr",
@@ -70,236 +43,42 @@ const SOCIAL_LINKS = [
},
];
const HERO_VARIANTS: HeroVariant[] = [
{
id: "command-center",
weight: 22,
scene: "command",
layout: "default",
status: "Disponível para novas oportunidades",
chip: "Garuva · Joinville · Curitiba · Remoto",
eyebrow:
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
headline:
"Engenharia de software para sistemas que precisam operar de verdade.",
subtitle:
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
cta: "Conversar sobre uma oportunidade",
whatsappMessage:
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
sideLabel: "Contato profissional direto",
sideTitle: "Procurando um engenheiro da computação para o seu time?",
sideText:
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
points: [
"Engenharia full-stack com base em sistemas e integrações.",
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
],
proof: [
{
label: "Operação",
value: "Fluxo pensado para discovery, build, deploy e sustentação.",
},
{
label: "Clareza",
value: "Menos ruído entre produto, engenharia e decisão técnica.",
},
{
label: "Resultado",
value: "Base pronta para operar, evoluir e receber próximo ciclo.",
},
],
},
{
id: "launch-sequence",
weight: 18,
scene: "launch",
layout: "visual-first",
status: "Launch sequence ready",
chip: "Discovery → build → deploy",
eyebrow: "MVP, produto e entrega em produção",
headline:
"Tire seu MVP do plano e coloque em produção com base técnica real.",
subtitle:
"Transformo escopo solto em sequência de lançamento: o que validar, o que construir primeiro e como publicar sem criar uma dívida técnica logo no início.",
cta: "Lançar meu MVP",
whatsappMessage:
"Olá! Quero lançar um MVP com uma base técnica segura. Podemos conversar?",
sideLabel: "Sequência de lançamento",
sideTitle: "Do primeiro corte ao deploy.",
sideText:
"A conversa define prioridade, risco e o menor caminho confiável para colocar a primeira versão no ar.",
points: [
"Recorte de MVP sem inflar escopo.",
"Entrega incremental com critério de produção.",
"Deploy e continuidade pensados desde o início.",
],
proof: [
{ label: "01", value: "Diagnóstico do objetivo e gargalo principal." },
{ label: "02", value: "Plano de construção com risco visível." },
{ label: "03", value: "Publicação e próximo ciclo sem perder contexto." },
],
},
{
id: "rescue-split",
weight: 16,
scene: "rescue",
layout: "split",
status: "Rescue mode available",
chip: "Legado, instabilidade e projeto travado",
eyebrow: "Rescue técnico para sistemas que precisam voltar a andar",
headline:
"Código confuso, entrega travada ou sistema instável? Vamos organizar.",
subtitle:
"Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos técnico em plano executável.",
cta: "Destravar meu projeto",
whatsappMessage:
"Olá! Tenho um projeto travado ou instável e quero destravar. Podemos conversar?",
sideLabel: "Antes / depois técnico",
sideTitle: "Do caos operacional para uma rota clara.",
sideText:
"O primeiro objetivo não é reescrever tudo. É encontrar o menor corte seguro que devolve controle e previsibilidade.",
points: [
"Leitura rápida de fluxo, risco e acoplamento.",
"Priorização do que destrava sem reescrever tudo.",
"Correções com critério para não trocar um bug por outro.",
],
proof: [
{
label: "Antes",
value: "Backlog confuso, bugs recorrentes e pouca previsibilidade.",
},
{
label: "Intervenção",
value: "Mapa de risco, cortes pequenos e decisão técnica clara.",
},
{ label: "Depois", value: "Entrega volta a andar com menos improviso." },
],
},
{
id: "iot-signal-map",
weight: 15,
scene: "signal",
layout: "default",
status: "Signal map active",
chip: "Hardware + software + operação",
eyebrow: "Produtos conectados, telemetria e integrações críticas",
headline:
"Conecte dispositivo, backend e interface em uma operação confiável.",
subtitle:
"Atuo na fronteira que costuma quebrar primeiro: sinal físico, API, painel, dados e rotina operacional funcionando como um único produto.",
cta: "Conectar meu produto",
whatsappMessage:
"Olá! Quero conectar hardware, software e operação em um produto confiável. Podemos conversar?",
sideLabel: "Mapa de sinais",
sideTitle: "Dados, comandos e painel no mesmo fluxo.",
sideText:
"A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integração em produto.",
points: [
"Contratos claros entre dispositivo, API e interface.",
"Telemetria e dashboards com contexto operacional.",
"Base pronta para automação, IA ou analytics.",
],
proof: [
{
label: "Entrada",
value: "Sensores, dispositivos, eventos ou sistemas externos.",
},
{
label: "Meio",
value: "APIs, WebSockets, filas, banco e observabilidade.",
},
{
label: "Saída",
value: "Painel, operação, alerta e decisão de produto.",
},
],
},
{
id: "diagnostic-report",
weight: 15,
scene: "diagnostic",
layout: "report",
status: "Diagnostic report open",
chip: "Arquitetura antes de mais código",
eyebrow: "Diagnóstico técnico para decidir melhor",
headline: "Antes de escrever mais código, descubra o próximo passo certo.",
subtitle:
"Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da próxima decisão cara.",
cta: "Agendar diagnóstico",
whatsappMessage:
"Olá! Quero um diagnóstico técnico para decidir o próximo passo do meu sistema. Podemos conversar?",
sideLabel: "Relatório técnico",
sideTitle: "Risco, arquitetura e rota de ação.",
sideText:
"Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho técnico.",
points: [
"Mapa claro dos gargalos e dependências.",
"Trade-offs traduzidos para decisão de negócio.",
"Plano de transição sem promessa mágica.",
],
proof: [
{ label: "Risco", value: "O que pode quebrar, atrasar ou custar caro." },
{
label: "Arquitetura",
value: "Onde simplificar, separar ou estabilizar primeiro.",
},
{ label: "Ação", value: "Próximo corte de implementação com critério." },
],
},
{
id: "cinematic-portrait",
weight: 14,
scene: "portrait",
layout: "portrait",
status: "Direct technical partner",
chip: "Conversa direta com quem implementa",
eyebrow: "Engenharia sênior, ownership e execução",
headline: "Um parceiro técnico para transformar complexidade em entrega.",
subtitle:
"Você não precisa apenas de tela bonita ou código isolado. Precisa de alguém que entenda contexto, escolha o corte certo e acompanhe até o sistema operar.",
cta: "Falar com Evandro",
whatsappMessage:
"Olá Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.",
sideLabel: "Founder-led engineering",
sideTitle: "Menos repasse, mais ownership.",
sideText:
"A conversa é comigo: contexto técnico, prioridade, risco e caminho de entrega sem perder informação entre camadas.",
points: [
"Atendimento consultivo com leitura técnica real.",
"Do produto ao backend sem perder contexto.",
"Execução com critério, não só código por demanda.",
],
proof: [
{
label: "Perfil",
value: "Engenharia full-stack com base em sistemas e integrações.",
},
{
label: "Postura",
value: "Clareza, autonomia e decisão técnica pragmática.",
},
{
label: "Acesso",
value: "WhatsApp direto para conversa técnica inicial.",
},
],
},
];
const LAYOUT_CLASSES: Record<HeroVariant["layout"], string> = {
default:
"grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10",
"visual-first":
"grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10",
split:
"grid grid-cols-[minmax(0,1fr)] items-stretch gap-sp-8 lg:grid-cols-2 lg:gap-sp-10",
report:
"grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10",
portrait:
"grid grid-cols-[minmax(0,1fr)] items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
};
const HERO_CONTENT = {
status: "Disponível para novas oportunidades",
chip: "Garuva · Joinville · Curitiba · Remoto",
eyebrow:
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
headline:
"Engenharia de software para sistemas que precisam operar de verdade.",
subtitle:
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
cta: "Conversar sobre uma oportunidade",
whatsappMessage:
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
sideLabel: "Contato profissional direto",
sideTitle: "Procurando um engenheiro da computação para o seu time?",
sideText:
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
points: [
"Engenharia full-stack com base em sistemas e integrações.",
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
],
proof: [
{
label: "Operação",
value: "Fluxo pensado para discovery, build, deploy e sustentação.",
},
{
label: "Clareza",
value: "Menos ruído entre produto, engenharia e decisão técnica.",
},
{
label: "Resultado",
value: "Base pronta para operar, evoluir e receber próximo ciclo.",
},
],
} as const;
function getGreeting(message: string) {
const hour = new Date().getHours();
@@ -308,155 +87,9 @@ function getGreeting(message: string) {
return message;
}
function SideVisual({
variant,
shouldReduceMotion,
}: {
variant: HeroVariant;
shouldReduceMotion: boolean;
}) {
if (variant.scene === "command") {
return (
<div className="mb-sp-6 space-y-sp-4">
{/* Starfield decorativo: grade uniforme com jitter cobrindo o card inteiro */}
<HeroStarfield />
{/* autoRotate off em reduced-motion: globo vira âncora estática */}
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
<TelemetryStrip reducedMotion={shouldReduceMotion} />
</div>
);
}
if (variant.scene === "launch") {
return (
<div className="mb-sp-6 grid gap-sp-3">
{variant.proof.map((item, index) => (
<div
key={item.label}
className="cinematic-glass flex gap-sp-4 p-sp-4"
>
<span className="font-mono text-[11px] text-[var(--slash)]">
0{index + 1}
</span>
<div>
<div className="text-[12px] font-bold uppercase tracking-[0.08em]">
{item.label}
</div>
<p className="mt-sp-1 text-[12px] leading-6 text-[rgba(244,242,237,.7)]">
{item.value}
</p>
</div>
</div>
))}
</div>
);
}
if (variant.scene === "rescue") {
return (
<div className="mb-sp-6 grid gap-sp-4 sm:grid-cols-2">
<div className="border border-[rgba(255,80,80,.28)] bg-[rgba(255,80,80,.05)] p-sp-4">
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(255,160,160,.9)]">
Antes
</div>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.72)]">
Bugs recorrentes, escopo nebuloso e medo de mexer.
</p>
</div>
<div className="border border-[rgba(204,255,0,.35)] bg-[rgba(204,255,0,.06)] p-sp-4">
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]">
Depois
</div>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.86)]">
Rota clara, cortes menores e entrega voltando a andar.
</p>
</div>
</div>
);
}
if (variant.scene === "signal") {
return (
<div className="relative mb-sp-6 h-40 overflow-hidden border border-[rgba(255,255,255,.08)] bg-[rgba(255,255,255,.03)]">
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full"
viewBox="0 0 420 160"
>
<path
d="M28 116 C108 18 190 142 284 56 S380 36 400 28"
fill="none"
stroke="rgba(204,255,0,.75)"
strokeWidth="2"
/>
{[28, 150, 284, 400].map((x, index) => (
<circle
key={x}
cx={x}
cy={[116, 78, 56, 28][index]}
r="6"
fill="#ccff00"
/>
))}
</svg>
</div>
);
}
if (variant.scene === "diagnostic") {
return (
<div className="mb-sp-6 border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] p-sp-5">
<div className="mb-sp-4 flex items-center justify-between border-b border-[rgba(255,255,255,.08)] pb-sp-3 font-mono text-[10px] uppercase tracking-[0.12em]">
<span>Risk report</span>
<span className="text-[var(--slash)]">Actionable</span>
</div>
<div className="space-y-sp-3">
{variant.proof.map((item) => (
<div key={item.label}>
<div className="flex justify-between text-[12px] text-[rgba(244,242,237,.78)]">
<span>{item.label}</span>
<span className="text-[var(--slash)]">mapped</span>
</div>
<div className="mt-sp-2 h-px bg-[linear-gradient(90deg,var(--slash),rgba(255,255,255,.08))]" />
</div>
))}
</div>
</div>
);
}
if (variant.scene === "portrait") {
return (
<div className="mb-sp-6 border border-[rgba(204,255,0,.22)] bg-[radial-gradient(circle_at_70%_20%,rgba(204,255,0,.14),transparent_35%),rgba(255,255,255,.035)] p-sp-5">
<div className="font-display text-[clamp(2.5rem,8vw,5rem)] font-extrabold leading-none tracking-[-0.08em] text-[rgba(244,242,237,.95)]">
EFJ
</div>
<div className="mt-sp-4 font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
Engenharia com contato direto
</div>
</div>
);
}
return (
<div className="mb-sp-6 grid gap-sp-3 font-mono text-[11px] uppercase tracking-[0.08em]">
{["deploy pipeline", "api health", "production ready"].map((item) => (
<div
key={item}
className="flex items-center justify-between border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] px-sp-4 py-sp-3"
>
<span>{item}</span>
<span className="text-[var(--slash)]">ok</span>
</div>
))}
</div>
);
}
export default function Hero() {
const variant = HERO_VARIANTS[0];
const heroRef = useRef<HTMLElement>(null);
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
const shouldReduceMotion = useReducedMotion() ?? true;
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [isHeroVisible, setIsHeroVisible] = useState(true);
@@ -465,7 +98,6 @@ export default function Hero() {
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
setShouldReduceMotion(reducedMotion);
if (
!shouldLoadHeroScene({
@@ -494,17 +126,19 @@ export default function Hero() {
const handleWhatsAppClick = () => {
window.open(
buildWhatsAppUrl(getGreeting(variant.whatsappMessage)),
buildWhatsAppUrl(getGreeting(HERO_CONTENT.whatsappMessage)),
"_blank",
"noopener,noreferrer",
);
};
const heroContainer: Variants = {
hidden: { opacity: 0 },
hidden: shouldReduceMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1, delayChildren: 0.3 },
transition: shouldReduceMotion
? { duration: 0 }
: { staggerChildren: 0.1, delayChildren: 0.3 },
},
};
@@ -512,47 +146,39 @@ export default function Hero() {
const heroItem: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 40, filter: "blur(10px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 1, ease: easeOut },
transition: shouldReduceMotion
? { duration: 0 }
: { duration: 1, ease: easeOut },
},
};
const heroItemSlow: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 60, filter: "blur(12px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 1.2, ease: easeOut },
transition: shouldReduceMotion
? { duration: 0 }
: { duration: 1.2, ease: easeOut },
},
};
const contentOrder =
variant.layout === "visual-first" || variant.layout === "report"
? "lg:order-2"
: "";
const panelOrder =
variant.layout === "visual-first" || variant.layout === "report"
? "lg:order-1"
: "";
return (
<section
ref={heroRef}
id="home"
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
data-hero-variant={variant.id}
>
{shouldRenderHeroScene && (
<HeroScene isVisible={isHeroVisible} variant={variant.scene} />
)}
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
@@ -568,12 +194,12 @@ export default function Hero() {
{/* pt >= altura do header fixo (52px): conteúdo do hero nunca nasce sob a barra */}
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 pt-sp-16 pb-sp-16 lg:max-h-none lg:px-sp-16 lg:pt-sp-14 lg:pb-sp-12">
<motion.div
initial="hidden"
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={heroContainer}
className={LAYOUT_CLASSES[variant.layout]}
className="grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10"
>
<div className={`max-w-[800px] ${contentOrder}`.trim()}>
<div className="max-w-[800px]">
<motion.div
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
variants={heroItem}
@@ -604,11 +230,11 @@ export default function Hero() {
}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
{variant.status}
{HERO_CONTENT.status}
</span>
</div>
<div className="cinematic-glass inline-flex items-center gap-sp-2 px-4 py-2 text-[11px] text-[rgba(244,242,237,.72)]">
{variant.chip}
{HERO_CONTENT.chip}
</div>
</motion.div>
@@ -616,42 +242,46 @@ export default function Hero() {
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
variants={heroItem}
>
{variant.eyebrow}
{HERO_CONTENT.eyebrow}
</motion.p>
<motion.h1
className="max-w-[940px] font-display text-[clamp(1.9rem,5.2vw,4.75rem)] font-extrabold leading-[0.95] tracking-[-0.055em] lg:leading-[0.9] lg:text-balance"
variants={heroItemSlow}
>
{variant.headline}
{HERO_CONTENT.headline}
</motion.h1>
<motion.p
className="mt-sp-6 max-w-[700px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
variants={heroItem}
>
{variant.subtitle}
{HERO_CONTENT.subtitle}
</motion.p>
<motion.ul
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
variants={heroItem}
>
{variant.points.map((point, i) => (
{HERO_CONTENT.points.map((point, i) => (
<motion.li
key={point}
className="cinematic-glass flex min-w-0 gap-sp-3 px-sp-4 py-sp-4 text-[13px] leading-6 text-[rgba(244,242,237,.82)]"
initial={
shouldReduceMotion
? { opacity: 0 }
? false
: { opacity: 0, y: 30, scale: 0.95 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.8,
delay: 0.8 + i * 0.1,
ease: [0.16, 1, 0.3, 1],
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.8,
delay: 0.8 + i * 0.1,
ease: [0.16, 1, 0.3, 1],
}
}
>
<span className="mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--slash)] shadow-[0_0_8px_rgba(204,255,0,0.5)]" />
<span>{point}</span>
@@ -668,17 +298,15 @@ export default function Hero() {
type="button"
onClick={handleWhatsAppClick}
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
data-cursor="View"
strength={0.25}
>
<span className="relative z-10">{variant.cta}</span>
<span className="relative z-10">{HERO_CONTENT.cta}</span>
</MagneticButton>
<MagneticButton
as="a"
href="/cases"
className="cinematic-cta-secondary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#f4f2ed] no-underline"
data-cursor="View"
strength={0.25}
>
Ver casos selecionados
@@ -741,40 +369,53 @@ export default function Hero() {
</div>
<motion.aside
className={`cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8 ${panelOrder}`.trim()}
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
initial={
shouldReduceMotion
? { opacity: 0 }
? false
: { opacity: 0, x: 60, rotateY: -8, transformPerspective: 1000 }
}
animate={
animate={{ opacity: 1, x: 0, rotateY: 0 }}
transition={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, x: 0, rotateY: 0 }
? { duration: 0 }
: {
duration: 1.2,
delay: 0.5,
ease: [0.16, 1, 0.3, 1],
}
}
transition={{ duration: 1.2, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
>
<motion.div
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
initial={{ scaleX: 0 }}
initial={shouldReduceMotion ? false : { scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 1.5,
delay: 1,
ease: [0.16, 1, 0.3, 1],
}
}
/>
<SideVisual
variant={variant}
shouldReduceMotion={shouldReduceMotion}
/>
<div className="mb-sp-6 space-y-sp-4">
<HeroStarfield />
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
<TelemetryStrip reducedMotion={shouldReduceMotion} />
</div>
<div className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
{variant.sideLabel}
{HERO_CONTENT.sideLabel}
</div>
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
{variant.sideTitle}
{HERO_CONTENT.sideTitle}
</h2>
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
{variant.sideText}
{HERO_CONTENT.sideText}
</p>
<button
type="button"
@@ -795,21 +436,25 @@ export default function Hero() {
</div>
<div className="mt-sp-6 space-y-sp-5">
{variant.proof.map((item, index) => (
{HERO_CONTENT.proof.map((item, index) => (
<motion.article
key={`${variant.id}-${item.label}`}
key={item.label}
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
initial={
shouldReduceMotion
? { opacity: 0 }
? false
: { opacity: 0, y: 20, filter: "blur(6px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
duration: 0.8,
delay: 0.8 + index * 0.12,
ease: [0.16, 1, 0.3, 1],
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.8,
delay: 0.8 + index * 0.12,
ease: [0.16, 1, 0.3, 1],
}
}
>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.56)]">
{item.label}
@@ -826,13 +471,15 @@ export default function Hero() {
<motion.div
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
initial={{ opacity: 0, y: -10 }}
initial={shouldReduceMotion ? false : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2, duration: 1 }}
transition={
shouldReduceMotion ? { duration: 0 } : { delay: 2, duration: 1 }
}
>
{shouldReduceMotion ? (
<div className="flex flex-col items-center gap-2" aria-hidden="true">
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
@@ -847,7 +494,7 @@ export default function Hero() {
ease: "easeInOut",
}}
>
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
+161
View File
@@ -0,0 +1,161 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import Projects from "./Projects";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
initial: _initial,
transition: _transition,
variants: _variants,
viewport: _viewport,
whileHover: _whileHover,
whileInView: _whileInView,
whileTap: _whileTap,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => true,
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => null,
}));
jest.mock("./Section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
}));
const [baseProject] = getFeaturedProjects(null);
const projects: PortfolioProjectView[] = [
{
...baseProject,
id: "available-demo",
title: "Demo disponível",
description: "Descrição disponível",
technologies: ["Stack disponível"],
status: "Disponível",
demoUrl: "https://available.example",
githubUrl: "https://github.com/example/available",
demoAvailability: "available",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
},
{
...baseProject,
id: "unavailable-demo",
title: "Demo indisponível",
description: "Descrição indisponível",
technologies: ["Stack indisponível"],
status: "Indisponível",
demoUrl: "https://unavailable.example",
githubUrl: "https://github.com/example/unavailable",
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
},
{
...baseProject,
id: "unknown-demo",
title: "Demo desconhecida",
description: "Descrição desconhecida",
technologies: ["Stack desconhecida"],
status: "Não verificada",
demoUrl: "https://unknown.example",
githubUrl: "https://github.com/example/unknown",
demoAvailability: "unknown",
demoCheckedAt: null,
},
{
...baseProject,
id: "no-demo",
title: "Projeto sem demo",
description: "Descrição sem demo",
technologies: ["Stack sem demo"],
status: "Sem demo",
demoUrl: null,
githubUrl: "https://github.com/example/no-demo",
demoAvailability: "none",
demoCheckedAt: null,
},
];
describe("Projects", () => {
it("renders demo actions according to availability", () => {
render(<Projects projects={projects} />);
expect(
screen.getByRole("heading", { name: "Demo disponível" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
"href",
"https://available.example",
);
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable).not.toHaveAttribute("href");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.getByRole("link", {
name: /Abrir projeto.*Disponibilidade não verificada/i,
}),
).toHaveAttribute("href", "https://unknown.example");
const noDemoCard = screen
.getByRole("heading", { name: "Projeto sem demo" })
.closest("article");
expect(noDemoCard).not.toBeNull();
expect(
within(noDemoCard as HTMLElement).queryByRole("link", {
name: /Abrir projeto/i,
}),
).not.toBeInTheDocument();
});
it("keeps project evidence visible when the demo is unavailable", () => {
render(<Projects projects={projects} />);
const card = screen
.getByRole("heading", { name: "Demo indisponível" })
.closest("article");
expect(card).not.toBeNull();
const unavailableCard = within(card as HTMLElement);
expect(unavailableCard.getByText("Descrição indisponível")).toBeVisible();
expect(unavailableCard.getByText("Stack indisponível")).toBeVisible();
expect(unavailableCard.getByText("Indisponível")).toBeVisible();
expect(
unavailableCard.getByRole("link", { name: "Ver codigo" }),
).toHaveAttribute("href", "https://github.com/example/unavailable");
expect(
unavailableCard.queryByRole("link", { name: /Abrir projeto/i }),
).not.toBeInTheDocument();
});
});
+26 -13
View File
@@ -2,7 +2,7 @@
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioProject } from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { Section } from "./Section";
@@ -10,7 +10,7 @@ import { Section } from "./Section";
export default function Projects({
projects,
}: {
projects: PortfolioProject[];
projects: PortfolioProjectView[];
}) {
const shouldReduceMotion = useReducedMotion();
@@ -114,17 +114,30 @@ export default function Projects({
Ver codigo
</MagneticButton>
)}
{project.demoUrl && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
</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>
-39
View File
@@ -1,39 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface RouteTransitionProps {
children: ReactNode;
}
export function RouteTransition({ children }: Readonly<RouteTransitionProps>) {
const pathname = usePathname();
const shouldReduceMotion = useReducedMotion();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -20 }}
transition={
shouldReduceMotion
? { duration: 0.2 }
: {
opacity: { duration: 0.3, ease: [0.16, 1, 0.3, 1] },
y: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
},
}
}
>
{children}
</motion.div>
</AnimatePresence>
);
}
+8 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useId } from "react";
import { TopProgressBar } from "./animations/TopProgressBar";
interface SectionLoadingProps {
@@ -14,6 +15,11 @@ export function SectionLoading({
skeletons = 2,
}: Readonly<SectionLoadingProps>) {
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
const skeletonId = useId();
const skeletonKeys = Array.from(
{ length: skeletons },
(_, index) => `${skeletonId}-${index}`,
);
return (
<section className={sectionClass}>
@@ -21,8 +27,8 @@ export function SectionLoading({
<div className="ds-section-shell">
<div className="ds-loading">{message}</div>
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
{Array.from({ length: skeletons }).map((_, index) => (
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
{skeletonKeys.map((skeletonKey) => (
<div key={skeletonKey} className="ds-card ds-skeleton-card">
<div className="ds-skeleton ds-skeleton-kicker" />
<div className="ds-skeleton ds-skeleton-title" />
<div className="ds-skeleton ds-skeleton-line" />
+12 -3
View File
@@ -128,11 +128,17 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
</p>
</div>
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel="right">
<p
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel="right"
>
{tier.description}
</p>
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel-stagger="fade">
<ul
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel-stagger="fade"
>
{tier.deliverables.map((deliverable) => (
<li key={deliverable} className="pl-sp-1">
{deliverable}
@@ -221,7 +227,10 @@ export function WorkModalities() {
</MagneticButton>
</div>
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3" data-reel-stagger="left">
<div
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"
data-reel-stagger="left"
>
{serviceHubLinks.map((service) => (
<Link
key={service.href}
+60 -59
View File
@@ -36,7 +36,17 @@ export function TextDiffuseReveal({
return () => media.removeEventListener("change", updateStagger);
}, []);
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
const tokens = useMemo(() => {
let offset = 0;
return text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((value) => {
const key = `${offset}:${value}`;
offset += value.length;
return { key, value };
});
}, [text]);
const animatedChars = useMemo(
() =>
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
@@ -58,75 +68,66 @@ export function TextDiffuseReveal({
<span
ref={ref}
className={className}
aria-label={text}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
>
{tokens.map((token, tokenIndex) => {
if (!token) {
return null;
}
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{tokens.map(({ key, value: token }) => {
if (token === "\n") {
return <br key={key} />;
}
if (token === "\n") {
return <br key={`line-${tokenIndex}`} />;
}
if (/^\s+$/.test(token)) {
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
}
if (/^\s+$/.test(token)) {
return (
<span key={`space-${tokenIndex}`} aria-hidden="true">
{token.replace(/ /g, "\u00A0")}
<span key={key} className="inline-block whitespace-nowrap">
{Array.from(token).map((char) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`${key}-${currentIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
}
return (
<span
key={`word-${tokenIndex}`}
className="inline-block whitespace-nowrap"
>
{Array.from(token).map((char, charIndex) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`char-${tokenIndex}-${charIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
})}
</span>
</span>
);
}
@@ -1,4 +1,3 @@
import React from "react";
export default function PointCloudScene() {
return <div data-testid="point-cloud-mock"></div>;
}
@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from "@testing-library/react";
import FilmGrain from "./FilmGrain";
import ScrollVelocityText from "./ScrollVelocityText";
import SectionDivider from "./SectionDivider";
function mockReducedMotion(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion") ? matches : false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
describe("static cinematic effects", () => {
beforeEach(() => mockReducedMotion(false));
it("renders grain and every divider without a canvas", () => {
const grain = render(<FilmGrain />);
expect(grain.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
grain.unmount();
for (const variant of ["gradient", "pulse", "waveform"] as const) {
const divider = render(<SectionDivider variant={variant} />);
expect(divider.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(divider.container.querySelector("canvas")).not.toBeInTheDocument();
expect(divider.container.firstElementChild).not.toHaveAttribute(
"tabindex",
);
divider.unmount();
}
});
it("exposes a persistent pause control for an automatic marquee", () => {
render(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
const pause = screen.getByRole("button", { name: "Pausar animação" });
const track = document.getElementById(
pause.getAttribute("aria-controls") ?? "",
);
expect(pause).toHaveAttribute("aria-pressed", "false");
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
expect(
screen.getByText("SOFTWARE ENGINEERING", {
selector: '[aria-hidden="false"]',
}),
).toBeVisible();
expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2);
expect(
screen
.getAllByText("SOFTWARE ENGINEERING")
.slice(1)
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
).toBe(true);
fireEvent.click(pause);
const resume = screen.getByRole("button", { name: "Retomar animação" });
expect(resume).toHaveAttribute("aria-pressed", "true");
expect(track).toHaveStyle({ animationPlayState: "paused" });
fireEvent.click(resume);
expect(
screen.getByRole("button", { name: "Pausar animação" }),
).toHaveAttribute("aria-pressed", "false");
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
});
it("starts static under reduced motion without a redundant control", () => {
mockReducedMotion(true);
render(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
expect(
screen.queryByRole("button", { name: /animação/i }),
).not.toBeInTheDocument();
const copies = screen.getAllByText("VAMOS CONVERSAR");
expect(copies).toHaveLength(2);
expect(copies[0]).toBeVisible();
expect(copies[0]).toHaveAttribute("aria-hidden", "false");
expect(copies[0].closest("[style]")).toHaveStyle({
animationPlayState: "paused",
});
expect(
copies
.slice(1)
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
).toBe(true);
});
});
+26 -24
View File
@@ -1,5 +1,4 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import CinematicHome from "./CinematicHome";
jest.mock("next/dynamic", () => {
@@ -19,41 +18,44 @@ jest.mock("next/dynamic", () => {
return () => {
const label = labels[index++] ?? "DynamicSection";
return function DynamicMock() {
return <div data-testid={`dynamic-${label}`}>{label}</div>;
return <section data-testid={`home-section-${label}`}>{label}</section>;
};
};
});
jest.mock("../Hero", () => () => <div data-testid="hero" />);
jest.mock("../Projects", () => () => <div data-testid="projects" />);
jest.mock("../story/ReelDirector", () => () => (
<div data-testid="reel-director" />
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />);
jest.mock("../Projects", () => () => (
<section data-testid="home-section-Projects" />
));
jest.mock("../story/NotebookStory", () => () => (
<section data-testid="notebook-story" />
<section data-testid="home-section-NotebookStory" />
));
jest.mock(
"./CinematicSection",
() =>
({ children }: { children: ReactNode }) => <>{children}</>,
);
describe("CinematicHome", () => {
it("uses one notebook story and keeps About after it", () => {
render(
it("renders each primary section directly in the published order", () => {
const { container } = render(
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />,
);
const main = container.querySelector("main");
const sectionNames = [
"Hero",
"NotebookStory",
"About",
"Skills",
"Experience",
"Projects",
"LighthouseMetrics",
"WorkModalities",
"Contact",
];
const sectionIds = sectionNames.map((name) => `home-section-${name}`);
const directSections = Array.from(main?.children ?? []).filter((element) =>
sectionIds.includes(element.getAttribute("data-testid") ?? ""),
);
const story = screen.getByTestId("notebook-story");
const about = screen.getByTestId("dynamic-About");
expect(story).toBeInTheDocument();
expect(screen.queryByTestId("print-reel")).not.toBeInTheDocument();
expect(screen.queryByTestId("scroll-story")).not.toBeInTheDocument();
expect(screen.getAllByTestId("home-section-NotebookStory")).toHaveLength(1);
expect(
Boolean(
story.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
directSections.map((section) => section.getAttribute("data-testid")),
).toEqual(sectionIds);
});
});
+12 -29
View File
@@ -3,15 +3,13 @@
import dynamic from "next/dynamic";
import type {
PortfolioExperience,
PortfolioProject,
PortfolioProjectView,
PortfolioSkillCategory,
} from "@/lib/portfolio-data";
import Hero from "../Hero";
import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading";
import NotebookStory from "../story/NotebookStory";
import ReelDirector from "../story/ReelDirector";
import CinematicSection from "./CinematicSection";
const About = dynamic(() => import("../About"), {
loading: () => <SectionLoading message="Carregando..." />,
@@ -46,67 +44,52 @@ export default function CinematicHome({
experiences,
skillCategories,
}: {
projects: PortfolioProject[];
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
return (
<div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main">
<ReelDirector />
<Hero />
{/* Marquee divider after hero */}
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
</div>
<NotebookStory projects={projects} />
<CinematicSection effect="parallax-depth">
<About />
</CinematicSection>
<About />
<SectionDivider variant="waveform" />
<CinematicSection effect="clip-reveal">
<Skills categories={skillCategories} />
</CinematicSection>
<Skills categories={skillCategories} />
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
<ScrollVelocityText
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
baseVelocity={1}
direction="right"
/>
</div>
<CinematicSection effect="fade-scale">
<Experience experiences={experiences} />
</CinematicSection>
<Experience experiences={experiences} />
<SectionDivider variant="gradient" />
<CinematicSection effect="rotate-in">
<Projects projects={projects} />
</CinematicSection>
<Projects projects={projects} />
<CinematicSection effect="clip-reveal">
<LighthouseMetrics />
</CinematicSection>
<LighthouseMetrics />
<SectionDivider variant="pulse" />
<CinematicSection effect="parallax-depth">
<WorkModalities />
</CinematicSection>
<WorkModalities />
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
</div>
<CinematicSection effect="fade-scale">
<Contact />
</CinematicSection>
<Contact />
</main>
<Footer />
</div>
@@ -2,9 +2,7 @@
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import PageIntro from "./PageIntro";
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
ssr: false,
@@ -16,10 +14,8 @@ export default function CinematicOverlays() {
return (
<>
<PageIntro />
{isHome ? (
<>
<CustomCursor />
<FilmGrain />
<ScrollProgress />
</>
@@ -1,173 +0,0 @@
"use client";
import { type ReactNode, useEffect, useRef } from "react";
interface CinematicSectionProps {
children: ReactNode;
className?: string;
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
id?: string;
}
export default function CinematicSection({
children,
className = "",
effect = "fade-scale",
id,
}: CinematicSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
// Check reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
switch (effect) {
case "fade-scale": {
gsap.set(el, {
opacity: 0,
scale: 0.92,
transformOrigin: "center center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 85%",
end: "top 20%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
scale: 1,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "clip-reveal": {
gsap.set(el, {
clipPath: "inset(15% 5% 15% 5%)",
opacity: 0.3,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 15%",
scrub: 1,
},
});
tl.to(el, {
clipPath: "inset(0% 0% 0% 0%)",
opacity: 1,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "parallax-depth": {
gsap.set(el, {
y: 80,
opacity: 0,
rotateX: 4,
transformPerspective: 1200,
transformOrigin: "center bottom",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 90%",
end: "top 30%",
scrub: 0.6,
},
});
tl.to(el, {
y: 0,
opacity: 1,
rotateX: 0,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "rotate-in": {
gsap.set(el, {
opacity: 0,
rotateY: -5,
x: -60,
transformPerspective: 1000,
transformOrigin: "left center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 25%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
rotateY: 0,
x: 0,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
}
};
void setup();
return () => {
cleanup?.();
};
}, [effect]);
return (
<div ref={sectionRef} className={className} id={id}>
{children}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
interface CinematicTextProps {
text: string;
className?: string;
tag?: "h1" | "h2" | "h3" | "p" | "span";
delay?: number;
splitBy?: "chars" | "words" | "lines";
}
export default function CinematicText({
text,
className = "",
tag: Tag = "h2",
delay = 0,
splitBy = "chars",
}: CinematicTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const spans = el.querySelectorAll("[data-char]");
if (spans.length === 0) return;
gsap.set(spans, {
opacity: 0,
y: 60,
rotateX: -90,
transformOrigin: "center bottom",
transformPerspective: 600,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none none",
},
});
tl.to(spans, {
opacity: 1,
y: 0,
rotateX: 0,
duration: 0.8,
ease: "back.out(1.2)",
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
delay,
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, [text, delay, splitBy]);
const renderContent = () => {
if (splitBy === "chars") {
return text.split("").map((char, i) => (
<span
key={`${char}-${i}`}
data-char
className="inline-block will-change-transform"
style={{ whiteSpace: char === " " ? "pre" : undefined }}
>
{char === " " ? "\u00A0" : char}
</span>
));
}
if (splitBy === "words") {
return text.split(" ").map((word, i) => (
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
<span data-char className="inline-block will-change-transform">
{word}
</span>
{i < text.split(" ").length - 1 && "\u00A0"}
</span>
));
}
// lines
return text.split("\n").map((line, i) => (
<span key={`${line}-${i}`} className="block overflow-hidden">
<span data-char className="block will-change-transform">
{line}
</span>
</span>
));
};
return (
<div ref={containerRef} className="overflow-hidden">
<Tag className={className}>{renderContent()}</Tag>
</div>
);
}
@@ -0,0 +1,146 @@
import { act, render, waitFor } from "@testing-library/react";
import createGlobe from "cobe";
import { ControlRoomGlobe } from "./ControlRoomGlobe";
const mockDestroy = jest.fn();
jest.mock("cobe", () => ({
__esModule: true,
default: jest.fn(() => ({ destroy: mockDestroy })),
}));
jest.mock("motion/react", () => ({
useMotionValue: (initial: number) => ({
get: () => initial,
set: jest.fn(),
}),
useSpring: (value: unknown) => value,
}));
let intersectionCallback: IntersectionObserverCallback | undefined;
let resizeCallback: ResizeObserverCallback | undefined;
let measuredWidth = 400;
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setGlobeIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function renderCallback() {
expect(createGlobe).toHaveBeenCalledTimes(1);
const call = jest.mocked(createGlobe).mock.calls[0];
if (!call) throw new Error("Globe was not initialized");
return call[1].onRender;
}
describe("ControlRoomGlobe lifecycle", () => {
beforeEach(() => {
jest.clearAllMocks();
setDocumentHidden(false);
intersectionCallback = undefined;
resizeCallback = undefined;
measuredWidth = 400;
jest
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
.mockImplementation(() => measuredWidth);
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({} as unknown as GPUCanvasContext);
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
unobserve = jest.fn();
}
global.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("pauses for a hidden tab without losing the intersection flag", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
const renderFrame = renderCallback();
const state = { phi: 0, theta: 0, width: 0, height: 0 };
setGlobeIntersects(true);
renderFrame(state);
expect(state.phi).not.toBe(0);
setDocumentHidden(true);
const pausedPhi = state.phi;
renderFrame(state);
expect(state.phi).toBe(pausedPhi);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).not.toBe(pausedPhi);
setGlobeIntersects(false);
const offscreenPhi = state.phi;
setDocumentHidden(true);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).toBe(offscreenPhi);
});
it("keeps one Cobe instance while ResizeObserver updates render dimensions", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
measuredWidth = 520;
const entry = {
contentRect: { width: measuredWidth },
} as ResizeObserverEntry;
act(() => resizeCallback?.([entry], {} as ResizeObserver));
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(createGlobe).toHaveBeenCalledTimes(1);
expect(state.width).toBe(1040);
expect(state.height).toBe(1040);
});
it("keeps the reduced-motion globe static", async () => {
render(<ControlRoomGlobe autoRotate={false} />);
await waitFor(() => expect(createGlobe).toHaveBeenCalled());
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(state).toEqual({ phi: 0, theta: 0, width: 0, height: 0 });
});
});
+34 -50
View File
@@ -2,7 +2,7 @@
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
@@ -47,7 +47,7 @@ export function ControlRoomGlobe({
autoRotate?: boolean;
rotationSpeed?: number;
}) {
const [width, setWidth] = useState(0);
const widthRef = useRef(0);
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
const thetaPhaseRef = useRef(0);
@@ -55,7 +55,7 @@ export function ControlRoomGlobe({
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerYOrigin = useRef<number | null>(null);
const isVisibleRef = useRef(true);
const intersectsRef = useRef(false);
const r = useMotionValue(0);
const rs = useSpring(r, {
@@ -88,57 +88,44 @@ export function ControlRoomGlobe({
};
useEffect(() => {
const onResize = () => {
if (canvasRef.current) {
setWidth(canvasRef.current.offsetWidth);
}
const canvas = canvasRef.current;
if (!canvas) return undefined;
const dpr = config.devicePixelRatio;
const updateWidth = (entry?: ResizeObserverEntry) => {
const nextWidth =
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
if (nextWidth > 0) widthRef.current = nextWidth;
};
updateWidth();
window.addEventListener("resize", onResize);
onResize();
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
resizeObserver.observe(canvas);
const observer = new IntersectionObserver(
const intersectionObserver = new IntersectionObserver(
([entry]) => {
isVisibleRef.current = entry.isIntersecting;
intersectsRef.current = entry.isIntersecting;
},
{ rootMargin: "100px" },
);
intersectionObserver.observe(canvas);
if (canvasRef.current) {
observer.observe(canvasRef.current);
}
const handleVisibilityChange = () => {
isVisibleRef.current = !document.hidden;
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const canvas = canvasRef.current;
if (!canvas) {
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
observer.disconnect();
};
}
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
let globe: ReturnType<typeof createGlobe> | null = null;
let globe: { destroy(): void } | null = null;
try {
const supportsWebGL =
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (supportsWebGL) {
const initialRenderWidth = widthRef.current * dpr;
globe = createGlobe(canvas, {
...config,
width: width * 2,
height: width * 2,
width: initialRenderWidth,
height: initialRenderWidth,
onRender: (state) => {
if (!isVisibleRef.current || document.hidden) return;
if (autoRotate && !pointerInteracting.current) {
if (!autoRotate || !intersectsRef.current || document.hidden)
return;
if (!pointerInteracting.current) {
phiRef.current += rotationSpeed;
thetaPhaseRef.current += rotationSpeed * 0.9;
}
@@ -148,8 +135,9 @@ export function ControlRoomGlobe({
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
Math.sin(thetaPhaseRef.current) * 0.35 +
thetaDragRef.current;
state.width = width * 2;
state.height = width * 2;
const renderWidth = widthRef.current * dpr;
state.width = renderWidth;
state.height = renderWidth;
},
});
}
@@ -161,28 +149,24 @@ export function ControlRoomGlobe({
// Sem globo: fallback visível (gradiente do card permanece), página viva.
canvas.style.opacity = "1";
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
observer.disconnect();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}
setTimeout(() => {
const revealTimer = window.setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
}, 0);
return () => {
window.clearTimeout(revealTimer);
globe.destroy();
window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
observer.disconnect();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [rs, config, autoRotate, rotationSpeed, width]);
}, [rs, config, autoRotate, rotationSpeed]);
return (
<div
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { motion, useMotionValue } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
export default function CustomCursor() {
const [isHovering, setIsHovering] = useState(false);
const [isClicking, setIsClicking] = useState(false);
const [hoverText, setHoverText] = useState("");
const [isVisible, setIsVisible] = useState(false);
const cursorX = useMotionValue(0);
const cursorY = useMotionValue(0);
const rafRef = useRef<number>(0);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
cursorX.set(e.clientX);
cursorY.set(e.clientY);
if (!isVisible) setIsVisible(true);
});
},
[cursorX, cursorY, isVisible],
);
useEffect(() => {
// Only show custom cursor on desktop with fine pointer
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
if (!hasFinePointer) return;
const handleDown = () => setIsClicking(true);
const handleUp = () => setIsClicking(false);
const handleLeave = () => setIsVisible(false);
const handleEnter = () => setIsVisible(true);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousedown", handleDown);
window.addEventListener("mouseup", handleUp);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
// Detect hoverable elements
const observer = new MutationObserver(() => {
attachHoverListeners();
});
observer.observe(document.body, { childList: true, subtree: true });
attachHoverListeners();
function attachHoverListeners() {
const hoverables = document.querySelectorAll(
'a, button, [role="button"], input, textarea, select, [data-cursor]',
);
for (const el of hoverables) {
el.addEventListener("mouseenter", handleElementEnter as EventListener);
el.addEventListener("mouseleave", handleElementLeave as EventListener);
}
}
function handleElementEnter(e: Event) {
const el = e.currentTarget as HTMLElement;
setIsHovering(true);
const cursorData = el.getAttribute("data-cursor");
if (cursorData) setHoverText(cursorData);
}
function handleElementLeave() {
setIsHovering(false);
setHoverText("");
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousedown", handleDown);
window.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
observer.disconnect();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [handleMouseMove]);
// Don't render on touch devices
if (
typeof window !== "undefined" &&
!window.matchMedia("(pointer: fine)").matches
) {
return null;
}
return (
<>
<style jsx global>{`
@media (pointer: fine) {
* { cursor: none !important; }
}
`}</style>
{/* Dot cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
style={{
x: cursorX,
y: cursorY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
opacity: isVisible ? 1 : 0,
}}
transition={{ duration: 0.15 }}
>
<div
className="rounded-full bg-[#ccff00]"
style={{ width: 8, height: 8 }}
/>
</motion.div>
{/* Ring cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
style={{
x: cursorX,
y: cursorY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
width: isHovering ? 80 : 40,
height: isHovering ? 80 : 40,
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
borderWidth: isHovering ? 2 : 1,
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
<div
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
style={{
borderStyle: "solid",
borderColor: "#ccff00",
borderWidth: "inherit",
}}
>
{hoverText && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
>
{hoverText}
</motion.span>
)}
</div>
</motion.div>
</>
);
}
+1 -68
View File
@@ -1,70 +1,3 @@
"use client";
import { useEffect, useRef } from "react";
export default function FilmGrain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
if (reducedMotion || coarsePointer) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let lastFrame = 0;
const fps = 24; // Cinematic film fps
const frameInterval = 1000 / fps;
const resize = () => {
canvas.width = window.innerWidth / 2;
canvas.height = window.innerHeight / 2;
};
resize();
window.addEventListener("resize", resize);
const render = (timestamp: number) => {
animId = requestAnimationFrame(render);
const delta = timestamp - lastFrame;
if (delta < frameInterval) return;
lastFrame = timestamp - (delta % frameInterval);
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const noise = Math.random() * 20;
data[i] = noise;
data[i + 1] = noise;
data[i + 2] = noise;
data[i + 3] = 12; // Very subtle
}
ctx.putImageData(imageData, 0, 0);
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
style={{ imageRendering: "pixelated" }}
/>
);
return <div aria-hidden="true" className="film-grain" />;
}
@@ -1,178 +0,0 @@
"use client";
import {
Float,
MeshDistortMaterial,
MeshTransmissionMaterial,
} from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
function GlassSphere({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
});
return (
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={meshRef} position={position} scale={scale}>
<sphereGeometry args={[1, 32, 32]} />
<MeshDistortMaterial
color="#ccff00"
transparent
opacity={0.08}
distort={0.4}
speed={2}
wireframe
/>
</mesh>
</Float>
);
}
function WireframeRing({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = t * 0.1;
meshRef.current.rotation.y = t * 0.15;
});
return (
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
<mesh ref={meshRef} position={position} scale={scale}>
<torusGeometry args={[1, 0.02, 16, 100]} />
<meshStandardMaterial
color="#ccff00"
transparent
opacity={0.15}
emissive="#ccff00"
emissiveIntensity={0.1}
/>
</mesh>
</Float>
);
}
function DataStream({ count = 50 }: { count?: number }) {
const pointsRef = useRef<THREE.Points>(null);
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
// Create a spiral/helix pattern
const angle = (i / count) * Math.PI * 8;
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
pos[i3] = Math.cos(angle) * radius;
pos[i3 + 1] = (i / count - 0.5) * 12;
pos[i3 + 2] = Math.sin(angle) * radius;
}
return pos;
}, [count]);
useFrame((state) => {
if (!pointsRef.current) return;
const t = state.clock.elapsedTime;
pointsRef.current.rotation.y = t * 0.05;
const posAttr = pointsRef.current.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3 + 1] += 0.02;
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
}
posAttr.needsUpdate = true;
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.04}
transparent
opacity={0.4}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
interface FloatingElementsProps {
variant?: "sphere" | "ring" | "data" | "combined";
className?: string;
}
export default function FloatingElements({
variant = "combined",
className = "",
}: FloatingElementsProps) {
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
<Canvas
camera={{ position: [0, 0, 6], fov: 50 }}
dpr={[1, 1.5]}
gl={{
antialias: false,
alpha: true,
powerPreference: "high-performance",
}}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.2} />
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
{(variant === "sphere" || variant === "combined") && (
<>
<GlassSphere position={[3, 1, -2]} scale={0.8} />
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
</>
)}
{(variant === "ring" || variant === "combined") && (
<>
<WireframeRing position={[0, 0, -2]} scale={1.5} />
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
</>
)}
{(variant === "data" || variant === "combined") && (
<DataStream count={60} />
)}
</Canvas>
</div>
);
}
-11
View File
@@ -4,14 +4,6 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
type HeroSceneVariant =
| "command"
| "launch"
| "rescue"
| "signal"
| "diagnostic"
| "portrait";
function GridFloor() {
const meshRef = useRef<THREE.Mesh>(null);
@@ -104,13 +96,10 @@ function CameraRig() {
}
export default function HeroScene({
variant,
isVisible = true,
}: {
variant?: HeroSceneVariant;
isVisible?: boolean;
}) {
void variant;
return (
<div
className="absolute inset-0 z-0"
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";
import MagneticButton from "./MagneticButton";
export default function HomeButton() {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<AnimatePresence>
{!isHome && (
<motion.div
className="fixed left-6 top-6 z-[9000]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
>
<MagneticButton
as="a"
href="/"
strength={0.3}
aria-label="Voltar para o início"
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.72)] px-4 py-2.5 text-[11px] font-mono uppercase tracking-[0.1em] text-[rgba(244,242,237,0.76)] backdrop-blur-[16px] transition-all duration-300 hover:border-[rgba(204,255,0,0.5)] hover:text-[var(--slash)]"
>
{/* Glow effect */}
<span
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
boxShadow:
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
}}
/>
{/* Arrow left icon */}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="relative z-10 -translate-x-0 transition-transform duration-300 group-hover:-translate-x-0.5"
>
<path d="M7.5 1.5 L2.5 6 L7.5 10.5" />
</svg>
<span className="relative z-10">Home</span>
{/* Animated neon dot */}
<motion.span
className="relative z-10 ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--slash)]"
animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
</MagneticButton>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,108 +0,0 @@
"use client";
import { type ReactNode, useEffect, useRef } from "react";
interface HorizontalScrollProps {
children: ReactNode;
className?: string;
}
export default function HorizontalScroll({
children,
className = "",
}: HorizontalScrollProps) {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
const track = trackRef.current;
if (!container || !track) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Disable on mobile for better UX
if (window.innerWidth < 768) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const totalWidth = track.scrollWidth;
const viewportWidth = window.innerWidth;
const tween = gsap.to(track, {
x: -(totalWidth - viewportWidth),
ease: "none",
scrollTrigger: {
trigger: container,
pin: true,
scrub: 1,
end: () => `+=${totalWidth - viewportWidth}`,
invalidateOnRefresh: true,
},
});
// Animate individual items as they come into view
const items = track.querySelectorAll("[data-scroll-item]");
for (const item of items) {
gsap.fromTo(
item,
{
opacity: 0.3,
scale: 0.9,
rotateY: -8,
transformPerspective: 1000,
},
{
opacity: 1,
scale: 1,
rotateY: 0,
scrollTrigger: {
trigger: item,
containerAnimation: tween,
start: "left 80%",
end: "left 30%",
scrub: true,
},
},
);
}
cleanup = () => {
ScrollTrigger.getAll().forEach((st) => {
if (
st.trigger === container ||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
tween
) {
st.kill();
}
});
tween.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, []);
return (
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
<div
ref={trackRef}
className="flex items-stretch gap-sp-6 will-change-transform"
>
{children}
</div>
</div>
);
}
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
target?: string;
rel?: string;
download?: boolean | string;
"data-cursor"?: string;
type?: "button" | "submit" | "reset";
wrapperClassName?: string;
"aria-label"?: string;
@@ -29,7 +28,6 @@ export default function MagneticButton({
target,
rel,
download,
"data-cursor": dataCursor,
type,
wrapperClassName = "",
"aria-label": ariaLabel,
@@ -84,7 +82,6 @@ export default function MagneticButton({
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
{...motionProps}
{...linkProps}
{...buttonProps}
@@ -102,7 +99,6 @@ export default function MagneticButton({
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
{...motionProps}
{...linkProps}
{...buttonProps}
@@ -1,53 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import PageIntro from "./PageIntro";
jest.mock("react-dom", () => ({
...jest.requireActual("react-dom"),
createPortal: (children: ReactNode) => children,
}));
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get:
(_target, tag: string) =>
({ children, ...props }: { children?: ReactNode }) =>
React.createElement(tag, props, children),
},
);
return {
AnimatePresence: ({ children }: { children: ReactNode }) => children,
motion,
useReducedMotion: () => false,
};
});
describe("PageIntro", () => {
it("renders the compact signal loading state without a fake percentage", () => {
render(<PageIntro />);
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
expect(screen.queryByText("000")).not.toBeInTheDocument();
});
it("stays visible long enough to be perceived on a fast page load", () => {
jest.useFakeTimers();
render(<PageIntro />);
act(() => jest.advanceTimersByTime(900));
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
act(() => jest.runAllTimers());
expect(
screen.queryByText("Preparando o portfólio"),
).not.toBeInTheDocument();
jest.useRealTimers();
});
});
-107
View File
@@ -1,107 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
export default function PageIntro() {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(true);
const reduceMotion = useReducedMotion();
useEffect(() => {
setMounted(true);
const timeout = window.setTimeout(
() => setVisible(false),
reduceMotion ? 150 : 1350,
);
return () => window.clearTimeout(timeout);
}, [reduceMotion]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{visible && (
<motion.div
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
initial={{ opacity: 1 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
clipPath: "inset(0 0 100% 0)",
transition: { duration: 0.65, ease: EXIT_EASE },
}
}
aria-live="polite"
aria-label="Preparando o portfólio"
>
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
<div className="relative m-auto w-[min(88vw,680px)]">
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
>
SINAL DE ENTRADA
</motion.p>
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
<motion.p
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.48,
delay: 0.06,
ease: [0.16, 1, 0.3, 1],
}}
>
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
</motion.p>
<motion.div
className="mt-sp-8 flex items-center gap-sp-4"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, delay: 0.18 }}
>
<span className="relative block h-2 w-2" aria-hidden="true">
<span className="absolute inset-0 bg-[var(--slash)]" />
{!reduceMotion && (
<motion.span
className="absolute inset-0 bg-[var(--slash)]"
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
}}
/>
)}
</span>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
Preparando o portfólio
</p>
<span
className="h-px flex-1 bg-[rgba(244,242,237,.22)]"
aria-hidden="true"
/>
</motion.div>
</div>
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
<span>Engenharia de software</span>
<span>Garuva · Joinville · Curitiba</span>
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
+55 -76
View File
@@ -1,100 +1,79 @@
"use client";
import {
motion,
useAnimationFrame,
useMotionValue,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useState } from "react";
interface ScrollVelocityTextProps {
text: string;
baseVelocity?: number;
direction: "left" | "right";
className?: string;
}
function wrap(min: number, max: number, v: number) {
const rangeSize = max - min;
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
}
const GROUPS = [0, 1] as const;
export default function ScrollVelocityText({
text,
baseVelocity = 2,
direction,
className = "",
}: ScrollVelocityTextProps) {
const baseX = useMotionValue(0);
const { scrollY } = useScroll();
const scrollVelocity = useMotionValue(0);
const smoothVelocity = useSpring(scrollVelocity, {
damping: 50,
stiffness: 300,
});
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
clamp: false,
});
const [repetitions, setRepetitions] = useState(4);
const containerRef = useRef<HTMLDivElement>(null);
const trackId = useId();
const [isPaused, setIsPaused] = useState(false);
const [shouldReduceMotion, setShouldReduceMotion] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const handleResize = () => {
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const updatePreference = (event: MediaQueryListEvent) => {
setShouldReduceMotion(event.matches);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
media.addEventListener("change", updatePreference);
return () => media.removeEventListener("change", updatePreference);
}, []);
// Track scroll velocity
const prevScrollY = useRef(0);
useEffect(() => {
return scrollY.on("change", (latest) => {
const delta = latest - prevScrollY.current;
prevScrollY.current = latest;
scrollVelocity.set(Math.abs(delta));
});
}, [scrollY, scrollVelocity]);
const directionFactor = useRef(1);
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
useAnimationFrame((_, delta) => {
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
const vf = velocityFactor.get();
if (vf < 0) {
directionFactor.current = -1;
} else if (vf > 0) {
directionFactor.current = 1;
}
moveBy += directionFactor.current * moveBy * vf;
baseX.set(baseX.get() + moveBy);
});
const animationPaused = shouldReduceMotion || isPaused;
return (
<div
ref={containerRef}
className={`overflow-hidden whitespace-nowrap ${className}`}
>
<motion.div className="inline-flex" style={{ x }}>
{[...Array(repetitions)].map((_, i) => (
<span
key={i}
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
>
{text}
<span className="mx-8 inline-block text-[var(--slash)]">
&#x2022;
<div className={`scroll-velocity-text ${className}`.trim()}>
<div className="scroll-velocity-viewport">
<div
id={trackId}
className={`scroll-velocity-track ${
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
}`}
style={{
animationPlayState: animationPaused ? "paused" : undefined,
}}
>
{GROUPS.map((group) => (
<span
key={group}
className="scroll-velocity-group"
aria-hidden={group === 0 ? undefined : "true"}
>
<span className="scroll-velocity-item">
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
<span aria-hidden="true" className="scroll-velocity-dot">
&#x2022;
</span>
</span>
</span>
</span>
))}
</motion.div>
))}
</div>
</div>
{!shouldReduceMotion ? (
<button
type="button"
className="scroll-velocity-control"
aria-controls={trackId}
aria-pressed={isPaused}
onClick={() => setIsPaused((paused) => !paused)}
>
{isPaused ? "Retomar animação" : "Pausar animação"}
</button>
) : null}
</div>
);
}
+17 -87
View File
@@ -1,114 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
className?: string;
}
const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const;
export default function SectionDivider({
variant = "gradient",
className = "",
}: SectionDividerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (variant !== "waveform") return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = 60;
};
resize();
window.addEventListener("resize", resize);
const render = () => {
animId = requestAnimationFrame(render);
const { width, height } = canvas;
const t = performance.now() * 0.001;
ctx.clearRect(0, 0, width, height);
// Draw multiple waveforms
for (let wave = 0; wave < 3; wave++) {
ctx.beginPath();
ctx.strokeStyle = `rgba(204, 255, 0, ${0.1 - wave * 0.03})`;
ctx.lineWidth = 1;
for (let x = 0; x < width; x++) {
const frequency = 0.005 + wave * 0.002;
const amplitude = 8 + wave * 4;
const speed = t * (0.5 + wave * 0.2);
const y =
height / 2 +
Math.sin(x * frequency + speed) * amplitude +
Math.sin(x * frequency * 2.3 + speed * 1.5) * (amplitude * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, [variant]);
const rootClassName =
`section-divider section-divider-${variant} ${className}`.trim();
if (variant === "gradient") {
return (
<div className={`relative h-px w-full overflow-hidden ${className}`}>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-30" />
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-60"
style={{
animation: "shimmerDivider 3s ease-in-out infinite",
}}
/>
<div aria-hidden="true" className={rootClassName}>
<span />
<span />
</div>
);
}
if (variant === "pulse") {
return (
<div
className={`flex items-center justify-center gap-2 py-8 ${className}`}
>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-1 w-1 rounded-full bg-[var(--slash)]"
style={{
animation: `pulseDot 2s ease-in-out ${i * 0.15}s infinite`,
}}
/>
<div aria-hidden="true" className={rootClassName}>
{PULSE_DOTS.map((dot) => (
<span key={dot} />
))}
</div>
);
}
// waveform
return (
<div className={`relative ${className}`}>
<canvas
ref={canvasRef}
className="w-full opacity-60"
style={{ height: 60 }}
aria-hidden="true"
/>
<div aria-hidden="true" className={rootClassName}>
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
<title>Divisor decorativo</title>
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
</svg>
</div>
);
}
+55 -5
View File
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { portfolio } from "@/lib/portfolio-data";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(portfolio.projects);
const projects = selectNotebookProjects(getFeaturedProjects(null));
const frame: NotebookBrowserProps["frame"] = {
cursorVisible: true,
cursorX: 0.4,
@@ -113,15 +113,65 @@ describe("NotebookBrowser", () => {
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("exposes a real deployment link with safe new-tab attributes", () => {
renderBrowser();
it("keeps an unknown demo link and announces its state", () => {
const unknownProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unknown" as const,
demoCheckedAt: null,
}
: project,
);
renderBrowser({ projects: unknownProjects });
const link = screen.getByRole("link", { name: /abrir projeto/i });
const link = screen.getByRole("link", {
name: /abrir projeto.*disponibilidade não verificada/i,
});
expect(link).toHaveAttribute("href", projects[0].demoUrl);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("renders an unavailable demo as non-navigable text", () => {
const unavailableProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
renderBrowser({ projects: unavailableProjects });
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.queryByRole("link", { name: /abrir projeto/i }),
).not.toBeInTheDocument();
});
it("renders no demo action for a project without a demo", () => {
const noDemoProjects = [
{
...projects[0],
demoUrl: null,
demoAvailability: "none",
demoCheckedAt: null,
},
] as unknown as NotebookBrowserProps["projects"];
renderBrowser({ projects: noDemoProjects });
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
expect(
screen.queryByText("Demo indisponível na última verificação"),
).not.toBeInTheDocument();
});
it("keeps the animated cursor decorative and never embeds an iframe", () => {
const { container } = renderBrowser({
frame: { ...frame, cursorClicking: true },
+20 -7
View File
@@ -176,13 +176,26 @@ export default function NotebookBrowser({
<li key={technology}>{technology}</li>
))}
</ul>
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
</a>
{selectedProject.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : selectedProject.demoAvailability === "none" ||
!selectedProject.demoUrl ? null : (
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
{selectedProject.demoAvailability === "unknown" ? (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
) : null}
</a>
)}
</div>
<div className="notebook-screenshot-wrap">
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { portfolio } from "@/lib/portfolio-data";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(portfolio.projects);
const projects = selectNotebookProjects(getFeaturedProjects(null));
describe("NotebookFallback", () => {
it("keeps project content and controls in normal document flow", () => {
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import * as THREE from "three";
import { portfolio } from "@/lib/portfolio-data";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, {
getNotebookLidRotation,
type NotebookStage3DProps,
@@ -30,7 +30,7 @@ jest.mock("@react-three/drei", () => ({
RoundedBox: () => <div data-testid="mock-roundedbox" />,
}));
const projects = selectNotebookProjects(portfolio.projects);
const projects = selectNotebookProjects(getFeaturedProjects(null));
const props: NotebookStage3DProps = {
activeIndex: 0,
isVisible: true,
+82 -5
View File
@@ -1,14 +1,18 @@
import { render, screen, waitFor } from "@testing-library/react";
import { portfolio } from "@/lib/portfolio-data";
import { act, render, screen, waitFor } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, {
canUseNotebook3D,
getNotebookRunwayVh,
getNotebookSeekProgress,
} from "./NotebookStory";
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
));
jest.mock("next/dynamic", () => () => {
return function DynamicNotebookStage() {
return <div data-testid="notebook-stage" />;
return function DynamicNotebookStage(props: { isVisible: boolean }) {
return mockNotebookStage(props);
};
});
@@ -26,7 +30,23 @@ jest.mock("gsap", () => ({
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { getAll: jest.fn(() => []) },
}));
const projects = portfolio.projects;
const projects = getFeaturedProjects(null);
let intersectionCallback: IntersectionObserverCallback | undefined;
const mockObserve = jest.fn();
const mockDisconnect = jest.fn();
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setSectionIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function mockMatchMedia({
reduced = false,
@@ -62,6 +82,26 @@ describe("NotebookStory", () => {
configurable: true,
value: 1280,
});
setDocumentHidden(false);
intersectionCallback = undefined;
mockObserve.mockReset();
mockDisconnect.mockReset();
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = mockDisconnect;
observe = mockObserve;
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
@@ -99,6 +139,43 @@ describe("NotebookStory", () => {
screen.getByLabelText("Projetos em produção no notebook"),
).toHaveAttribute("data-notebook-runway-vh", "520");
});
it("stays paused when the tab returns while the section is offscreen", async () => {
const addEventListener = jest.spyOn(document, "addEventListener");
const removeEventListener = jest.spyOn(document, "removeEventListener");
const { unmount } = render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
expect(stage).toHaveAttribute("data-is-visible", "false");
setSectionIntersects(true);
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "true"),
);
setDocumentHidden(true);
act(() => document.dispatchEvent(new Event("visibilitychange")));
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "false"),
);
setSectionIntersects(false);
setDocumentHidden(false);
act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(stage).toHaveAttribute("data-is-visible", "false");
expect(
addEventListener.mock.calls.filter(
([type]) => type === "visibilitychange",
),
).toHaveLength(1);
unmount();
expect(
removeEventListener.mock.calls.filter(
([type]) => type === "visibilitychange",
),
).toHaveLength(1);
});
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
+21 -6
View File
@@ -3,7 +3,7 @@
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProject } from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame } from "./notebookTimeline";
@@ -18,7 +18,7 @@ const BROWSE_END = 0.78;
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProject[];
projects: PortfolioProjectView[];
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
@@ -64,11 +64,17 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
const progressRef = useRef(0);
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
const [engine, setEngine] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [sectionIntersects, setSectionIntersects] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() => typeof document === "undefined" || !document.hidden,
);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
const updateEngine = () => {
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
const nextEngine =
notebookProjects.length > 0 && canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
@@ -91,6 +97,15 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
};
}, [notebookProjects.length]);
useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
useEffect(() => {
const section = sectionRef.current;
if (!engine || !section || !("IntersectionObserver" in window)) {
@@ -98,7 +113,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
}
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry?.isIntersecting ?? false),
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(section);
@@ -246,7 +261,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
</div>
<NotebookStage3D
activeIndex={activeIndex}
isVisible={isVisible}
isVisible={sectionIntersects && isPageVisible}
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
-149
View File
@@ -1,149 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
*
* Contrato (declarativo, via atributos data-*):
* [data-reel="left|right|top|bottom|zoom|fade"]
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
* [data-reel-stagger="left|right|top|zoom|fade|bottom"]
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
* data-reel-distance="64" (opcional: px de deslocamento)
*
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
*
* Anti-hidratação: elementos inicializados vivem num WeakSet (nunca um
* atributo data-reel-init no DOM) e o primeiro scan roda depois que a
* hidratação assentou (double-rAF + debounce) — MutationObserver dispara em
* microtask e pegaria nós ainda em hidratação, produzindo mismatch de
* atributos no overlay do Next.
*/
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
const DISTANCE = 64;
function fromVars(dir: Direction, dist: number) {
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
if (dir === "left") return { ...base, x: -dist };
if (dir === "right") return { ...base, x: dist };
if (dir === "top") return { ...base, y: -dist * 0.75 };
if (dir === "bottom") return { ...base, y: dist };
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
return { ...base, scale: 1.02 };
}
const TO_VARS = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
filter: "blur(0px)",
ease: "none",
};
export default function ReelDirector() {
useEffect(() => {
let cancelled = false;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined;
let teardown = () => {};
let raf1 = 0;
let raf2 = 0;
let timer = 0;
const boot = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const done = new WeakSet<Element>();
const ctx = gsap.context(() => {
const scan = () => {
if (cancelled) return;
for (const el of document.querySelectorAll<HTMLElement>(
"[data-reel]",
)) {
if (done.has(el)) continue;
done.add(el);
const dir = (el.dataset.reel || "bottom") as Direction;
const dist = Number(el.dataset.reelDistance || DISTANCE);
gsap.fromTo(el, fromVars(dir, dist), {
...TO_VARS,
scrollTrigger: {
trigger: el,
start: el.dataset.reelStart || "top 88%",
end: el.dataset.reelEnd || "top 26%",
scrub: 0.6,
},
});
}
for (const container of document.querySelectorAll<HTMLElement>(
"[data-reel-stagger]",
)) {
if (done.has(container)) continue;
done.add(container);
const dir = (container.dataset.reelStagger ||
"bottom") as Direction;
const dist = Number(container.dataset.reelDistance || DISTANCE);
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) continue;
gsap.fromTo(children, fromVars(dir, dist), {
...TO_VARS,
stagger: { each: 0.09 },
scrollTrigger: {
trigger: container,
start: container.dataset.reelStart || "top 85%",
end: container.dataset.reelEnd || "top 25%",
scrub: 0.6,
},
});
}
};
// debounce: nós de seções dinâmicas (next/dynamic) chegam depois
let pending = 0;
const scheduleScan = () => {
window.clearTimeout(pending);
pending = window.setTimeout(scan, 120);
};
// espera a hidratação assentar antes de tocar o DOM
raf1 = window.requestAnimationFrame(() => {
raf2 = window.requestAnimationFrame(() => {
timer = window.setTimeout(scan, 250);
});
});
const observer = new MutationObserver(scheduleScan);
observer.observe(document.body, { childList: true, subtree: true });
teardown = () => {
window.clearTimeout(pending);
observer.disconnect();
ctx.revert();
};
});
};
void boot();
return () => {
cancelled = true;
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
window.clearTimeout(timer);
teardown();
};
}, []);
return null;
}
+48 -17
View File
@@ -1,7 +1,10 @@
import { type PortfolioProject, portfolio } from "@/lib/portfolio-data";
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = portfolio.projects as PortfolioProject[];
const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => {
it("returns the four ordered projects with local screenshots", () => {
@@ -31,26 +34,54 @@ describe("selectNotebookProjects", () => {
).toBe(false);
});
it("omits a project without an approved demo URL", () => {
const projectsWithoutDemoUrl = projectsFixture.map((project) =>
project.id === "nutriscan" ? { ...project, demoUrl: null } : project,
) as unknown as PortfolioProject[];
const result = selectNotebookProjects(projectsWithoutDemoUrl);
it("omits a project with an unapproved demo URL", () => {
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
project.id === "nutriscan"
? { ...project, demoUrl: "javascript:alert(1)" }
: project,
);
const result = selectNotebookProjects(projectsWithUnsafeUrl);
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
});
it("copies only the browser contract fields", () => {
const [project] = selectNotebookProjects(projectsFixture);
it("keeps a project without a demo so its local evidence remains visible", () => {
const projectsWithoutDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoUrl: null,
demoAvailability: "none" as const,
demoCheckedAt: null,
}
: project,
) as unknown as PortfolioProjectView[];
expect(project).toEqual({
id: "nutriscan",
title: "NutriScan",
subtitle: "Análise Nutricional Inteligente",
demoUrl: "https://nutriscan.evandro.dev.br",
technologies: expect.arrayContaining(["Next.js 14", "TypeScript"]),
screenshotSrc: "/assets/prints/print-nutriscan.png",
expect(
selectNotebookProjects(projectsWithoutDemo).some(
(project) => project.id === "nutriscan",
),
).toBe(true);
});
it("keeps an unavailable project when its local evidence and URL exist", () => {
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
expect(
selectNotebookProjects(projectsWithUnavailableDemo).find(
(project) => project.id === "nutriscan",
),
).toMatchObject({
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
});
expect(project).not.toHaveProperty("description");
});
});
+12 -5
View File
@@ -1,10 +1,12 @@
import type { PortfolioProject } from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
export type NotebookProject = {
id: string;
title: string;
subtitle: string;
demoUrl: string;
demoUrl: PortfolioProjectView["demoUrl"];
demoAvailability: PortfolioProjectView["demoAvailability"];
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[];
screenshotSrc: string;
};
@@ -27,19 +29,22 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
};
function isApprovedDemoUrl(
value: PortfolioProject["demoUrl"],
value: PortfolioProjectView["demoUrl"],
): value is string {
return typeof value === "string" && /^https?:\/\//.test(value);
}
export function selectNotebookProjects(
projects: readonly PortfolioProject[],
projects: readonly PortfolioProjectView[],
): NotebookProject[] {
return NOTEBOOK_ORDER.flatMap((id) => {
const project = projects.find((candidate) => candidate.id === id);
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
if (!project || !isApprovedDemoUrl(project.demoUrl)) {
if (
!project ||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
) {
return [];
}
@@ -49,6 +54,8 @@ export function selectNotebookProjects(
title: project.title,
subtitle: project.subtitle,
demoUrl: project.demoUrl,
demoAvailability: project.demoAvailability,
demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies],
screenshotSrc,
},
+49
View File
@@ -0,0 +1,49 @@
import { render, screen, within } from "@testing-library/react";
import { metadata as rootMetadata } from "../layout";
import CurriculoPage from "./page";
jest.mock("next/font/google", () => ({
Geist_Mono: () => ({ variable: "font-geist-mono" }),
Space_Grotesk: () => ({ variable: "font-space-grotesk" }),
}));
jest.mock("next/font/local", () => () => ({
variable: "font-monument-extended",
}));
const authorizedRegion =
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
it("uses one factual regional description across home metadata", () => {
const description = rootMetadata.description;
expect(typeof description).toBe("string");
expect(description).toContain(authorizedRegion);
expect(description).not.toMatch(
/Joinville|São Francisco do Sul|Itapoá|Jaraguá do Sul|Curitiba/,
);
expect(rootMetadata.openGraph).toMatchObject({ description });
expect(rootMetadata.twitter).toMatchObject({ description });
});
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
render(CurriculoPage());
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
"Início",
"Currículo",
]);
expect(breadcrumbLinks[0]).toHaveAttribute("href", "/");
expect(breadcrumbLinks[1]).toHaveAttribute("href", "/curriculo");
expect(breadcrumbLinks[1]).toHaveAttribute("aria-current", "page");
const hiringLink = screen.getByRole("link", { name: /oportunidade/i });
const consultingLink = screen.getByRole("link", { name: /consultoria/i });
expect(hiringLink).toHaveAttribute("href", "/#contact");
expect(consultingLink).toHaveAttribute("href", "/servicos");
expect(
hiringLink.compareDocumentPosition(consultingLink) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
+37 -27
View File
@@ -2,19 +2,20 @@ import type { Metadata } from "next";
import Link from "next/link";
import { safeJsonLd } from "@/lib/jsonld";
import { portfolio } from "@/lib/portfolio-data";
import { buildBreadcrumbList, buildProfilePageJsonLd } from "@/lib/seo";
import { siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/curriculo`;
export const metadata: Metadata = {
title: "Currículo — Engenheiro e Desenvolvedor de Software",
title: "Currículo de Evandro Fonseca Junior para Contratação",
description:
"Currículo de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba. Experiência em Next.js, TypeScript, Node.js, Rust, IoT e sistemas críticos.",
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
alternates: { canonical: pageUrl },
openGraph: {
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
description:
"Engenheiro da computação e desenvolvedor de software disponível para oportunidades CLT, PJ e projetos estratégicos no Norte de SC, Curitiba e remoto.",
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
url: pageUrl,
siteName,
type: "profile",
@@ -24,25 +25,13 @@ export const metadata: Metadata = {
export default function CurriculoPage() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "ProfilePage",
dateModified: new Date().toISOString(),
mainEntity: {
"@type": "Person",
"@id": `${siteUrl}/#person`,
name: portfolio.personal.name,
jobTitle:
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
description: portfolio.personal.summary,
url: siteUrl,
sameAs: [portfolio.personal.github, portfolio.personal.linkedin],
homeLocation: {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
},
knowsAbout: portfolio.skills.technical.flatMap(
(category) => category.skills,
),
},
"@graph": [
buildProfilePageJsonLd(),
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Currículo", pageUrl],
] as const),
],
};
return (
@@ -50,15 +39,33 @@ export default function CurriculoPage() {
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/curriculo"
>
Currículo
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Currículo profissional</p>
<h1 className="ds-section-title text-white">
Engenheiro da Computação e Desenvolvedor de Software para Garuva,
Joinville, Curitiba e remoto.
Engenheiro da Computação e Desenvolvedor de Software.
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
{portfolio.personal.summary} Estou disponível para oportunidades CLT
e PJ, com prioridade para times e empresas do Norte de Santa
Catarina e Curitiba que precisam de execução técnica consistente.
Base em Garuva, atendimento a projetos no Norte de Santa Catarina e
atuação remota. Este currículo reúne experiência profissional,
formação e competências técnicas para avaliação de contratação.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<a className="ds-link ds-link-accent" href="/Profile.pdf" download>
@@ -67,6 +74,9 @@ export default function CurriculoPage() {
<Link className="ds-link ds-link-muted" href="/#contact">
Falar sobre uma oportunidade
</Link>
<Link className="ds-link ds-link-muted" href="/servicos">
Procurando consultoria? Ver serviços
</Link>
</div>
</div>
</section>
+232 -11
View File
@@ -63,6 +63,10 @@
margin: 0;
padding: 0;
}
a {
color: inherit;
}
}
html,
@@ -94,9 +98,9 @@ html [id] {
}
.site-header-menu-toggle {
width: 32px;
height: 32px;
flex: 0 0 32px;
width: 44px;
height: 44px;
flex: 0 0 44px;
}
@media (min-width: 1025px) {
@@ -144,6 +148,26 @@ html [id] {
outline-offset: 3px;
}
.film-grain {
position: fixed;
inset: 0;
z-index: 9990;
pointer-events: none;
background:
repeating-radial-gradient(
circle at 20% 30%,
var(--paper) 0 0.6px,
transparent 0.8px 3px
),
repeating-radial-gradient(
circle at 70% 60%,
var(--ink) 0 0.5px,
transparent 0.7px 4px
);
mix-blend-mode: overlay;
opacity: 0.08;
}
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
.film-grain {
display: none;
@@ -173,10 +197,6 @@ svg {
}
}
a {
color: inherit;
}
button,
input,
select,
@@ -506,23 +526,63 @@ textarea {
}
@keyframes marquee {
0% {
from {
transform: translateX(0);
}
100% {
to {
transform: translateX(-50%);
}
}
@keyframes marquee-reverse {
0% {
from {
transform: translateX(-50%);
}
100% {
to {
transform: translateX(0);
}
}
.scroll-velocity-text {
position: relative;
color: inherit;
}
.scroll-velocity-viewport {
overflow: hidden;
white-space: nowrap;
}
.scroll-velocity-track,
.scroll-velocity-group {
display: flex;
width: max-content;
}
.scroll-velocity-group {
flex-shrink: 0;
min-width: 100vw;
}
.scroll-velocity-item {
display: inline-flex;
align-items: center;
flex-shrink: 0;
font-family: var(--font-display);
font-size: clamp(3rem, 10vw, 8rem);
font-weight: 800;
line-height: 1;
letter-spacing: -0.04em;
text-transform: uppercase;
}
.scroll-velocity-dot {
margin-inline: var(--sp-8);
color: var(--slash);
}
.animate-marquee {
animation: marquee 40s linear infinite;
}
@@ -531,6 +591,71 @@ textarea {
animation: marquee-reverse 45s linear infinite;
}
.scroll-velocity-text:hover .scroll-velocity-track,
.scroll-velocity-text:focus-within .scroll-velocity-track {
animation-play-state: paused;
}
.scroll-velocity-control {
position: absolute;
top: var(--sp-2);
right: var(--sp-3);
z-index: 1;
min-height: 44px;
border: 1px solid var(--slash);
background: var(--ink);
color: var(--paper);
padding-inline: var(--sp-3);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.scroll-velocity-text {
color: var(--paper);
}
.scroll-velocity-track {
animation: none;
transform: none;
}
.scroll-velocity-viewport {
overflow: visible;
white-space: normal;
}
.scroll-velocity-track,
.scroll-velocity-group {
display: block;
width: 100%;
}
.scroll-velocity-group {
min-width: 0;
}
.scroll-velocity-group[aria-hidden="true"] {
display: none;
}
.scroll-velocity-item {
display: block;
padding: var(--sp-6);
font-size: clamp(2rem, 8vw, 4rem);
line-height: 1.1;
white-space: normal;
}
.scroll-velocity-dot {
display: none;
}
}
.ds-skeleton-card {
display: flex;
flex-direction: column;
@@ -783,9 +908,11 @@ textarea {
transform: translateX(-100%);
opacity: 0;
}
50% {
opacity: 0.8;
}
100% {
transform: translateX(100%);
opacity: 0;
@@ -798,12 +925,106 @@ textarea {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(2);
opacity: 1;
}
}
.section-divider {
pointer-events: none;
}
.section-divider-gradient {
position: relative;
width: 100%;
height: 1px;
overflow: hidden;
}
.section-divider-gradient span {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, var(--slash), transparent);
opacity: 0.3;
}
.section-divider-gradient span:last-child {
animation: shimmerDivider 3s ease-in-out infinite;
opacity: 0.6;
}
.section-divider-pulse {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding-block: var(--sp-8);
}
.section-divider-pulse span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--slash);
animation: pulseDot 2s ease-in-out infinite;
}
.section-divider-pulse span:nth-child(2) {
animation-delay: 0.15s;
}
.section-divider-pulse span:nth-child(3) {
animation-delay: 0.3s;
}
.section-divider-pulse span:nth-child(4) {
animation-delay: 0.45s;
}
.section-divider-pulse span:nth-child(5) {
animation-delay: 0.6s;
}
.section-divider-waveform {
width: 100%;
height: 60px;
color: var(--slash);
}
.section-divider-waveform svg {
display: block;
width: 100%;
height: 60px;
}
.section-divider-waveform path {
fill: none;
stroke: currentColor;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.section-divider-waveform path:nth-child(1) {
opacity: 0.1;
}
.section-divider-waveform path:nth-child(2) {
opacity: 0.07;
}
.section-divider-waveform path:nth-child(3) {
opacity: 0.04;
}
@media (prefers-reduced-motion: reduce) {
.section-divider-gradient span:last-child,
.section-divider-pulse span {
animation: none;
}
}
/* Cinematic card hover effect */
.ds-card {
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
-56
View File
@@ -1,56 +0,0 @@
"use client";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useEffect, useRef } from "react";
export function useScrollReveal(options?: {
y?: number;
duration?: number;
delay?: number;
ease?: string;
start?: string;
end?: string;
scrub?: boolean | number;
}) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (!ref.current) return;
// Lazy registration to avoid Next.js hydration mismatch issues
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
gsap.fromTo(
ref.current,
{ y: options?.y ?? 60, opacity: 0 },
{
y: 0,
opacity: 1,
duration: options?.duration ?? 0.9,
delay: options?.delay ?? 0,
ease: options?.ease ?? "power3.out",
scrollTrigger: {
trigger: ref.current,
start: options?.start ?? "top 85%",
end: options?.end ?? "top 50%",
scrub: options?.scrub ?? false,
},
},
);
}, ref);
return () => ctx.revert();
}, [
options?.y,
options?.duration,
options?.delay,
options?.ease,
options?.start,
options?.end,
options?.scrub,
]);
return ref;
}
+14 -202
View File
@@ -3,19 +3,16 @@ import { Geist_Mono, Space_Grotesk } from "next/font/google";
import localFont from "next/font/local";
import type { ReactNode } from "react";
import { safeJsonLd } from "@/lib/jsonld";
import { regionCities, siteUrl } from "@/lib/site-content";
import { getWhatsAppNumber } from "@/lib/whatsapp";
import { buildPersonJsonLd, buildWebSiteJsonLd } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content";
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header";
import { RouteTransition } from "./components/RouteTransition";
import { AnimationProvider } from "./providers/AnimationProvider";
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
import "./globals.css";
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
const seoDescription =
"Evandro Fonseca Junior é engenheiro da computação, desenvolvedor de software e criação de sites para empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul, Norte de Santa Catarina e Curitiba. Atua com Next.js, TypeScript, Rust, IoT, integração de sistemas e produtos críticos.";
"Evandro Fonseca Junior é engenheiro da computação e desenvolvedor de software com atuação em Next.js, TypeScript, Rust, IoT e integrações. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
@@ -54,33 +51,6 @@ export const metadata: Metadata = {
template: "%s | Evandro Fonseca Junior",
},
description: seoDescription,
keywords: [
"desenvolvedor de software em Garuva",
"engenheiro da computação em Garuva",
"desenvolvedor de sites em Garuva",
"desenvolvedor de software em Joinville",
"engenheiro da computação em Joinville",
"desenvolvedor de sites em Joinville",
"criação de sites em Joinville",
"programador em Joinville",
"desenvolvedor de software em Curitiba",
"engenheiro da computação em Curitiba",
"criação de sites em Curitiba",
"desenvolvedor de sites em Curitiba",
"programador em Curitiba",
"desenvolvedor de software em São Francisco do Sul",
"desenvolvedor de software em Itapoá",
"desenvolvedor de software em Jaraguá do Sul",
"desenvolvedor full stack Santa Catarina",
"engenharia de software Norte de Santa Catarina",
"criação de sites Norte de Santa Catarina",
"desenvolvimento de sistemas web Santa Catarina",
"Next.js",
"TypeScript",
"Rust",
"IoT",
"arquitetura de software",
],
authors: [{ name: "Evandro Fonseca Junior" }],
creator: "Evandro Fonseca Junior",
publisher: "Evandro Fonseca Junior",
@@ -145,159 +115,7 @@ interface RootLayoutProps {
export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: siteUrl,
name: "Evandro.dev.br",
inLanguage: "pt-BR",
description: seoDescription,
},
{
"@type": "ProfilePage",
"@id": `${siteUrl}/curriculo#profile`,
url: `${siteUrl}/curriculo`,
inLanguage: "pt-BR",
mainEntity: { "@id": `${siteUrl}/#person` },
},
{
"@type": "Person",
"@id": `${siteUrl}/#person`,
name: "Evandro Fonseca Junior",
alternateName: ["Evandro.dev", "evandrodevbr"],
jobTitle:
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
description: seoDescription,
url: siteUrl,
image: `${siteUrl}/opengraph-image`,
telephone: `+${getWhatsAppNumber()}`,
sameAs: [
"https://github.com/evandrodevbr",
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
"https://www.instagram.com/evandrodevbr/",
],
address: {
"@type": "PostalAddress",
addressLocality: "Garuva",
addressRegion: "SC",
addressCountry: "BR",
},
homeLocation: {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
geo: {
"@type": "GeoCoordinates",
latitude: -26.0281,
longitude: -48.8467,
},
},
hasOccupation: {
"@type": "Occupation",
name: "Engenheiro da Computação e Desenvolvedor de Software",
occupationalCategory: "15-1252.00 Software Developers",
},
knowsAbout: [
"Engenharia de Software",
"Next.js",
"Tauri",
"Rust",
"Arquitetura de Software",
"Sistemas Críticos",
"Integração hardware e software",
"IoT",
"TypeScript",
"Cloud Computing",
],
},
{
"@type": "ProfessionalService",
"@id": `${siteUrl}/#services`,
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Técnica",
url: siteUrl,
image: `${siteUrl}/opengraph-image`,
telephone: `+${getWhatsAppNumber()}`,
priceRange: "$$",
address: {
"@type": "PostalAddress",
addressLocality: "Garuva",
addressRegion: "SC",
addressCountry: "BR",
},
geo: {
"@type": "GeoCoordinates",
latitude: -26.0281,
longitude: -48.8467,
},
description:
"Serviço de engenharia de software, criação de sites, arquitetura de software, integrações críticas e consultoria técnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
areaServed: [
...regionCities.map((name) => ({ "@type": "City", name })),
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
{ "@type": "AdministrativeArea", name: "Paraná" },
{
"@type": "GeoCircle",
geoMidpoint: {
"@type": "GeoCoordinates",
latitude: -26.0281,
longitude: -48.8467,
},
geoRadius: "150000",
},
{ "@type": "Country", name: "Brasil" },
{ "@type": "Place", name: "Atuação remota" },
],
provider: {
"@id": `${siteUrl}/#person`,
},
serviceType: [
"Engenharia de Software",
"Criação de Sites",
"Consultoria Técnica",
"Arquitetura de Software",
"Integração hardware + software",
"Desenvolvimento Next.js",
"Desenvolvimento Tauri",
"Desenvolvimento Rust",
"IoT",
"Sistemas Críticos",
],
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Modelos de contratação",
itemListElement: [
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Criação de sites institucionais e sistemas web",
},
},
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Auditoria e diagnóstico técnico",
},
},
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Ciclos de engenharia",
},
},
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Fractional Tech Lead",
},
},
],
},
},
],
"@graph": [buildWebSiteJsonLd(), buildPersonJsonLd()],
};
return (
@@ -306,22 +124,16 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
>
<body>
<SmoothScrollProvider>
<AnimationProvider>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<a className="skip-link" href="#main-content">
Pular para o conteúdo
</a>
<Header />
<CinematicOverlays />
<RouteTransition>
<div id="main-content" tabIndex={-1}>
{children}
</div>
</RouteTransition>
<GoogleAnalytics />
</AnimationProvider>
</SmoothScrollProvider>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<a className="skip-link" href="#main-content">
Pular para o conteúdo
</a>
<Header />
<CinematicOverlays />
<div id="main-content" tabIndex={-1}>
{children}
</div>
<GoogleAnalytics />
</body>
</html>
);
-70
View File
@@ -1,70 +0,0 @@
/**
* Animation Configuration
* Centralized configuration for all animations in the portfolio
*/
export const animationConfig = {
// Easing curves
easings: {
easeOut: "cubic-bezier(0.16, 1, 0.3, 1)",
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
spring: {
default: { stiffness: 200, damping: 20 },
hover: { stiffness: 260, damping: 20 },
magnetic: { stiffness: 150, damping: 15 },
gentle: { stiffness: 60, damping: 20 },
},
},
// Durations (in seconds)
durations: {
instant: 0.15,
fast: 0.3,
normal: 0.6,
slow: 0.9,
verySlow: 1.2,
},
// Scroll trigger
scrollTrigger: {
threshold: 0.2,
once: true,
margin: "0px",
},
// Stagger
stagger: {
default: 0.08,
fast: 0.04,
slow: 0.12,
reducedMotion: 0.03,
},
// Parallax
parallax: {
speed: 0.5, // 50% mais lento que scroll normal
maxDistance: 100, // pixels
},
// Magnetic effect
magnetic: {
radius: 80, // pixels
strength: 12, // max movement in pixels
},
// Cursor
cursor: {
size: 40,
expandedSize: 60,
followDelay: 0.1,
},
// Reduced motion overrides
reducedMotion: {
duration: 0.15,
stagger: 0.03,
disableParallax: true,
disableMagnetic: true,
disableCursor: true,
},
} as const;
-98
View File
@@ -1,98 +0,0 @@
/**
* Animation Variants
* Preset variants for Framer Motion animations
*/
export const variants = {
// Fade in
fadeIn: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
// Fade in + slide up
fadeInUp: {
initial: { opacity: 0, y: 24, filter: "blur(8px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: -24, filter: "blur(8px)" },
},
// Fade in + slide down
fadeInDown: {
initial: { opacity: 0, y: -24, filter: "blur(8px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: 24, filter: "blur(8px)" },
},
// Fade in + slide left
fadeInLeft: {
initial: { opacity: 0, x: 24, filter: "blur(8px)" },
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: { opacity: 0, x: -24, filter: "blur(8px)" },
},
// Fade in + slide right
fadeInRight: {
initial: { opacity: 0, x: -24, filter: "blur(8px)" },
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: { opacity: 0, x: 24, filter: "blur(8px)" },
},
// Scale in
scaleIn: {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
},
// Image reveal
imageReveal: {
initial: {
opacity: 0,
scale: 0.98,
filter: "blur(8px)",
clipPath: "inset(0 100% 0 0)",
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
clipPath: "inset(0 0% 0 0)",
},
},
// Text reveal (per word)
textReveal: {
initial: {
opacity: 0,
scale: 0.95,
filter: "blur(6px)",
clipPath: "inset(0 100% 0 0)",
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
clipPath: "inset(0 0% 0 0)",
},
},
// Stagger container
staggerContainer: {
animate: {
transition: {
staggerChildren: 0.08,
},
},
},
// Reduced motion variants
reducedMotion: {
fadeIn: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
},
} as const;
+9 -40
View File
@@ -1,49 +1,18 @@
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt =
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default async function Image() {
return new ImageResponse(
<div
style={{
background: "#030712",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "white",
fontFamily: "system-ui",
}}
>
<div
style={{
fontSize: 64,
fontWeight: "bold",
marginBottom: 20,
textAlign: "center",
}}
>
Evandro Fonseca Junior
</div>
<div
style={{
fontSize: 32,
color: "#3b82f6",
marginTop: 10,
textAlign: "center",
}}
>
Engenheiro da Computação e Desenvolvedor de Software
</div>
</div>,
{ ...size },
<OgCard
eyebrow="Portfólio de engenharia de software"
title="Evandro Fonseca Junior"
description="Engenheiro da Computação e Desenvolvedor de Software"
/>,
OG_SIZE,
);
}
+2 -4
View File
@@ -1,12 +1,10 @@
import { loadLiveFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import CinematicHome from "./components/cinematic/CinematicHome";
export const revalidate = 300;
export default async function Home() {
return (
<CinematicHome
projects={await loadLiveFeaturedProjects()}
projects={await loadFeaturedProjects()}
experiences={portfolio.experiences}
skillCategories={portfolio.skills.technical}
/>
-148
View File
@@ -1,148 +0,0 @@
import { render, screen } from "@testing-library/react";
import { AnimationProvider, useAnimationConfig } from "./AnimationProvider";
// Mock do useReducedMotion
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: jest.fn(),
}));
// Test component that uses the hook
function TestComponent() {
const config = useAnimationConfig();
return (
<div>
<div data-testid="reduced-motion">{String(config.reducedMotion)}</div>
<div data-testid="ease-out">{config.easings.easeOut}</div>
<div data-testid="duration-normal">{config.durations.normal}</div>
<div data-testid="spring-default-stiffness">
{config.easings.spring.default.stiffness}
</div>
</div>
);
}
describe("AnimationProvider", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("provides animation config with reducedMotion false", () => {
const { useReducedMotion } = jest.requireMock(
"@/app/hooks/useReducedMotion",
);
useReducedMotion.mockReturnValue(false);
render(
<AnimationProvider>
<TestComponent />
</AnimationProvider>,
);
expect(screen.getByTestId("reduced-motion")).toHaveTextContent("false");
expect(screen.getByTestId("ease-out")).toHaveTextContent(
"cubic-bezier(0.16, 1, 0.3, 1)",
);
expect(screen.getByTestId("duration-normal")).toHaveTextContent("0.6");
expect(screen.getByTestId("spring-default-stiffness")).toHaveTextContent(
"200",
);
});
it("provides animation config with reducedMotion true", () => {
const { useReducedMotion } = jest.requireMock(
"@/app/hooks/useReducedMotion",
);
useReducedMotion.mockReturnValue(true);
render(
<AnimationProvider>
<TestComponent />
</AnimationProvider>,
);
expect(screen.getByTestId("reduced-motion")).toHaveTextContent("true");
});
it("provides all easing configurations", () => {
const { useReducedMotion } = jest.requireMock(
"@/app/hooks/useReducedMotion",
);
useReducedMotion.mockReturnValue(false);
render(
<AnimationProvider>
<TestComponent />
</AnimationProvider>,
);
const config = screen.getByTestId("ease-out").textContent;
expect(config).toBe("cubic-bezier(0.16, 1, 0.3, 1)");
});
it("provides all duration configurations", () => {
const { useReducedMotion } = jest.requireMock(
"@/app/hooks/useReducedMotion",
);
useReducedMotion.mockReturnValue(false);
render(
<AnimationProvider>
<TestComponent />
</AnimationProvider>,
);
const duration = screen.getByTestId("duration-normal").textContent;
expect(duration).toBe("0.6");
});
it("throws error when useAnimationConfig is used outside provider", () => {
// Suppress console.error for this test
const consoleError = jest
.spyOn(console, "error")
.mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useAnimationConfig must be used within AnimationProvider");
consoleError.mockRestore();
});
});
describe("useAnimationConfig", () => {
it("returns config object with correct structure", () => {
const { useReducedMotion } = jest.requireMock(
"@/app/hooks/useReducedMotion",
);
useReducedMotion.mockReturnValue(false);
function ConfigStructureTest() {
const config = useAnimationConfig();
return (
<div>
<div data-testid="has-reduced-motion">
{String("reducedMotion" in config)}
</div>
<div data-testid="has-easings">{String("easings" in config)}</div>
<div data-testid="has-durations">{String("durations" in config)}</div>
<div data-testid="has-spring">
{String("spring" in config.easings)}
</div>
</div>
);
}
render(
<AnimationProvider>
<ConfigStructureTest />
</AnimationProvider>,
);
expect(screen.getByTestId("has-reduced-motion")).toHaveTextContent("true");
expect(screen.getByTestId("has-easings")).toHaveTextContent("true");
expect(screen.getByTestId("has-durations")).toHaveTextContent("true");
expect(screen.getByTestId("has-spring")).toHaveTextContent("true");
});
});
-93
View File
@@ -1,93 +0,0 @@
"use client";
import { createContext, type ReactNode, useContext } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { animationConfig } from "@/app/lib/animations/config";
/**
* Animation Configuration Context
* Provides global animation settings including reduced motion detection
*/
interface AnimationConfig {
reducedMotion: boolean;
easings: typeof animationConfig.easings;
durations: typeof animationConfig.durations;
}
const AnimationContext = createContext<AnimationConfig | undefined>(undefined);
interface AnimationProviderProps {
children: ReactNode;
}
/**
* AnimationProvider
*
* Provider component that supplies global animation configuration through React context.
* Automatically detects user's reduced motion preference and provides centralized
* animation settings (easings, durations) to all child components.
*
* @example
* ```tsx
* // In app/layout.tsx
* <AnimationProvider>
* <YourApp />
* </AnimationProvider>
* ```
*
* @see useAnimationConfig - Hook to consume this context
*/
export function AnimationProvider({ children }: AnimationProviderProps) {
const reducedMotion = useReducedMotion();
const config: AnimationConfig = {
reducedMotion,
easings: animationConfig.easings,
durations: animationConfig.durations,
};
return (
<AnimationContext.Provider value={config}>
{children}
</AnimationContext.Provider>
);
}
/**
* useAnimationConfig
*
* Hook to access global animation configuration from AnimationProvider context.
* Provides reduced motion state, easing curves, and duration presets.
*
* @returns {AnimationConfig} Animation configuration object
* @throws {Error} If used outside of AnimationProvider
*
* @example
* ```tsx
* function MyComponent() {
* const { reducedMotion, easings, durations } = useAnimationConfig();
*
* return (
* <motion.div
* animate={{
* opacity: 1,
* y: reducedMotion ? 0 : 20
* }}
* transition={{
* duration: durations.normal,
* ease: easings.easeOut
* }}
* />
* );
* }
* ```
*/
export function useAnimationConfig(): AnimationConfig {
const context = useContext(AnimationContext);
if (context === undefined) {
throw new Error("useAnimationConfig must be used within AnimationProvider");
}
return context;
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
export function SmoothScrollProvider({ children }: { children: ReactNode }) {
const teardownRef = useRef<() => void>(() => {});
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const hasCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
if (prefersReducedMotion || hasCoarsePointer) {
return undefined;
}
let isMounted = true;
const setupSmoothScroll = async () => {
const [{ default: Lenis }, { gsap }, { ScrollTrigger }] =
await Promise.all([
import("lenis"),
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (!isMounted) {
return;
}
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
smoothWheel: true,
});
const handleScroll = () => ScrollTrigger.update();
const tick = (time: number) => {
lenis.raf(time * 1000);
};
lenis.on("scroll", handleScroll);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
teardownRef.current = () => {
lenis.off?.("scroll", handleScroll);
gsap.ticker.remove(tick);
lenis.destroy();
};
};
void setupSmoothScroll();
return () => {
isMounted = false;
teardownRef.current();
teardownRef.current = () => {};
};
}, []);
return <>{children}</>;
}
+48
View File
@@ -0,0 +1,48 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
import { getServicePage, servicePages } from "@/lib/site-content";
function getImageMetadata(service: (typeof servicePages)[number]) {
return {
id: service.slug,
alt: `${service.navLabel} — Evandro Fonseca Junior`,
size: OG_SIZE,
contentType: OG_CONTENT_TYPE,
};
}
export function generateImageMetadata({
params,
}: {
params: { slug?: string };
}) {
if (!params.slug) {
return servicePages.map(getImageMetadata);
}
const service = getServicePage(params.slug);
if (!service) notFound();
return [getImageMetadata(service)];
}
export default async function Image({
params,
id,
}: {
params: Promise<{ slug: string }>;
id: Promise<string | number>;
}) {
const [{ slug }, imageId] = await Promise.all([params, id]);
const service = getServicePage(slug);
if (!service || imageId !== service.slug) notFound();
return new ImageResponse(
<OgCard
eyebrow={service.eyebrow}
title={service.headline}
description={service.answer}
/>,
OG_SIZE,
);
}
+251
View File
@@ -0,0 +1,251 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { SEO_IDS } from "@/lib/seo";
import { caseStudies, getServicePage, siteUrl } from "@/lib/site-content";
import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
const service = getServicePage("consultoria-nextjs");
if (!service) throw new Error("Missing representative service");
const publicServiceSlugs = [
"criacao-de-sites",
"engenharia-de-software",
"consultoria-nextjs",
"tauri-rust",
"integracao-hardware-software-iot",
"arquitetura-de-software",
] as const;
const publicServiceHrefs = publicServiceSlugs.map(
(slug) => `/servicos/${slug}`,
);
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
);
if (scripts.length === 0) throw new Error("Missing JSON-LD");
return Array.from(scripts).flatMap((script) => {
if (!script.textContent) throw new Error("Empty JSON-LD");
const value = JSON.parse(script.textContent) as Record<string, unknown>;
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
});
}
it("keeps static params and route metadata canonical", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
const metadata = await generateMetadata({
params: Promise.resolve({ slug: service.slug }),
});
const url = `${siteUrl}/servicos/${service.slug}`;
expect(metadata).toMatchObject({
title: service.seoTitle,
description: service.metaDescription,
alternates: { canonical: url },
openGraph: {
title: service.seoTitle,
description: service.metaDescription,
url,
},
twitter: {
title: service.seoTitle,
description: service.metaDescription,
},
});
expect(metadata).not.toHaveProperty("keywords");
});
it("renders answer-first consulting, reciprocal case links and minimal schema", async () => {
const ui = await ServicePage({
params: Promise.resolve({ slug: service.slug }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(service.answer);
expect(answer).toBeVisible();
expect(heading.nextElementSibling).toBe(answer);
const primaryCtas = screen.getAllByRole("link", {
name: service.ctaLabel,
});
expect(primaryCtas).toHaveLength(2);
for (const cta of primaryCtas) {
expect(cta).toHaveAttribute("href", "/#contact");
}
const expectedCaseLinks = service.relatedCaseIds
.map((id) => {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing related case ${id}`);
return {
name: `Ler case: ${caseStudy.title}`,
href: `/cases/${id}`,
};
})
.sort((a, b) => a.href.localeCompare(b.href));
const internalCaseLinks = screen.getAllByRole("link", {
name: /^Ler case:/,
});
expect(
internalCaseLinks
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
}))
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
).toEqual(expectedCaseLinks);
for (const internalLink of internalCaseLinks) {
const firstExternalLink = internalLink
.closest("article")
?.querySelector('a[target="_blank"]');
if (firstExternalLink) {
expect(
internalLink.compareDocumentPosition(firstExternalLink) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
}
}
const externalLinks = container.querySelectorAll('a[target="_blank"]');
expect(externalLinks.length).toBeGreaterThan(0);
for (const externalLink of externalLinks) {
expect(externalLink).toHaveAttribute(
"rel",
expect.stringMatching(/noreferrer/),
);
}
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"Service",
"BreadcrumbList",
]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService/,
);
const serviceSchema = graph.find((node) => node["@type"] === "Service");
expect(serviceSchema).toMatchObject({
provider: { "@id": SEO_IDS.person },
});
const breadcrumb = graph.find(
(node) => node["@type"] === "BreadcrumbList",
) as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
item: string;
}>;
};
expect(breadcrumb.itemListElement).toEqual([
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Serviços",
item: `${siteUrl}/servicos`,
},
{
"@type": "ListItem",
position: 3,
name: service.navLabel,
item: `${siteUrl}/servicos/${service.slug}`,
},
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Serviços", "/servicos"],
[service.navLabel, `/servicos/${service.slug}`],
]);
});
it("keeps hub cards and structured items aligned", () => {
const { container } = render(<ServicesIndexPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(
/desenvolvimento.+consultoria.+engenharia de software/i,
);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Serviços", "/servicos"],
]);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CollectionPage",
"ItemList",
"BreadcrumbList",
]);
const itemList = graph[1] as {
itemListElement: Array<{ position: number; url: string }>;
};
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
1, 2, 3, 4, 5, 6,
]);
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
expect(structuredUrls).toHaveLength(6);
expect(new Set(structuredUrls).size).toBe(6);
expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
);
const visibleCards = screen.getAllByRole("link", { name: "Abrir página" });
const visibleHrefs = visibleCards.map((link) => link.getAttribute("href"));
expect(visibleHrefs).toHaveLength(6);
expect(new Set(visibleHrefs).size).toBe(6);
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
expect(graph[2]).toMatchObject({
"@type": "BreadcrumbList",
itemListElement: [
{ position: 1, name: "Início", item: siteUrl },
{ position: 2, name: "Serviços", item: `${siteUrl}/servicos` },
],
});
});
+40 -50
View File
@@ -1,12 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildServiceJsonLd } from "@/lib/seo";
import {
getRelatedCases,
getServicePage,
regionCities,
servicePages,
siteName,
siteUrl,
@@ -71,54 +72,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Service",
"@id": `${pageUrl}#service`,
name: service.navLabel,
description: service.metaDescription,
serviceType: service.navLabel,
provider: {
"@type": "Person",
name: "Evandro Fonseca Junior",
url: siteUrl,
},
areaServed: [
...regionCities.map((name) => ({ "@type": "City", name })),
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
{
"@type": "Country",
name: "Brasil",
},
{
"@type": "Place",
name: "Remote worldwide",
},
],
url: pageUrl,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Início",
item: siteUrl,
},
{
"@type": "ListItem",
position: 2,
name: "Serviços",
item: `${siteUrl}/servicos`,
},
{
"@type": "ListItem",
position: 3,
name: service.navLabel,
item: pageUrl,
},
],
},
buildServiceJsonLd(service),
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Serviços", `${siteUrl}/servicos`],
[service.navLabel, pageUrl],
] as const),
],
};
@@ -130,12 +89,37 @@ export default async function ServicePage({ params }: ServicePageProps) {
<HeroBackground />
<div className="ds-section-shell relative z-10">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/servicos">
Serviços
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
{service.navLabel}
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
{service.headline}
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{service.intro}
{service.answer}
</p>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<MagneticButton
@@ -268,6 +252,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
+47 -39
View File
@@ -1,26 +1,26 @@
import type { Metadata } from "next";
import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import {
serviceHubLinks,
servicePages,
siteName,
siteUrl,
} from "@/lib/site-content";
import { buildBreadcrumbList } from "@/lib/seo";
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Serviços de Engenharia de Software e Consultoria Técnica";
const pageDescription =
"Desenvolvimento e consultoria em engenharia de software, Next.js, Tauri, Rust, arquitetura e integração hardware + software para produtos e sistemas críticos.";
export const metadata: Metadata = {
title: "Serviços de Engenharia de Software e Consultoria Técnica",
description:
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
title: pageTitle,
description: pageDescription,
alternates: {
canonical: `${siteUrl}/servicos`,
canonical: pageUrl,
},
openGraph: {
title: "Serviços de Engenharia de Software e Consultoria Técnica",
description:
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
url: `${siteUrl}/servicos`,
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
@@ -32,33 +32,24 @@ export default function ServicesIndexPage() {
"@graph": [
{
"@type": "CollectionPage",
"@id": `${siteUrl}/servicos#page`,
name: "Serviços de Engenharia de Software e Consultoria Técnica",
url: `${siteUrl}/servicos`,
description:
"Hub de serviços para engenharia de software, consultoria técnica, Next.js, Tauri, Rust, IoT e arquitetura de software.",
"@id": `${pageUrl}#page`,
name: pageTitle,
url: pageUrl,
description: pageDescription,
},
{
"@type": "ItemList",
itemListElement: servicePages.map((service, index) => ({
itemListElement: serviceHubLinks.map((service, index) => ({
"@type": "ListItem",
position: index + 1,
url: `${siteUrl}/servicos/${service.slug}`,
name: service.navLabel,
url: `${siteUrl}${service.href}`,
name: service.label,
})),
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Serviços",
item: `${siteUrl}/servicos`,
},
],
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Serviços", pageUrl],
] as const),
],
};
@@ -70,15 +61,32 @@ export default function ServicesIndexPage() {
<HeroBackground />
<div className="ds-section-shell relative z-10">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/servicos"
>
Serviços
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Hub de serviços</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Serviços de engenharia de software para contextos técnicos de
alta exigência.
Serviços de engenharia de software para contextos técnicos de alta
exigência.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Organizei as páginas de serviço por intenção real de compra:
engenharia de software, consultoria Next.js, Tauri e Rust,
arquitetura de software e integrações hardware + software.
{pageDescription}
</p>
</div>
</div>
+8 -36
View File
@@ -2,44 +2,16 @@ import type { MetadataRoute } from "next";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return [
{
url: siteUrl,
lastModified: now,
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${siteUrl}/curriculo`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.95,
},
{
url: `${siteUrl}/servicos`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.9,
},
{
url: `${siteUrl}/cases`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.9,
},
...servicePages.map((service) => ({
url: `${siteUrl}/servicos/${service.slug}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.8,
{ url: siteUrl },
{ url: `${siteUrl}/curriculo` },
{ url: `${siteUrl}/servicos` },
{ url: `${siteUrl}/cases` },
...servicePages.map(({ slug }) => ({
url: `${siteUrl}/servicos/${slug}`,
})),
...caseStudies.map((caseStudy) => ({
url: `${siteUrl}/cases/${caseStudy.id}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.75,
...caseStudies.map(({ id }) => ({
url: `${siteUrl}/cases/${id}`,
})),
];
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-305
View File
@@ -1,305 +0,0 @@
"use client";
import { gsap } from "gsap";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
gsap.registerPlugin(InertiaPlugin);
const throttle = (func: (...args: any[]) => void, limit: number) => {
let lastCall = 0;
return function (this: any, ...args: any[]) {
const now = performance.now();
if (now - lastCall >= limit) {
lastCall = now;
func.apply(this, args);
}
};
};
interface Dot {
cx: number;
cy: number;
xOffset: number;
yOffset: number;
_inertiaApplied: boolean;
}
export interface DotGridProps {
dotSize?: number;
gap?: number;
baseColor?: string;
activeColor?: string;
proximity?: number;
speedTrigger?: number;
shockRadius?: number;
shockStrength?: number;
maxSpeed?: number;
resistance?: number;
returnDuration?: number;
className?: string;
style?: React.CSSProperties;
}
function hexToRgb(hex: string) {
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!m) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
};
}
const DotGrid: React.FC<DotGridProps> = ({
dotSize = 16,
gap = 32,
baseColor = "#5227FF",
activeColor = "#5227FF",
proximity = 150,
speedTrigger = 100,
shockRadius = 250,
shockStrength = 5,
maxSpeed = 5000,
resistance = 750,
returnDuration = 1.5,
className = "",
style,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const dotsRef = useRef<Dot[]>([]);
const pointerRef = useRef({
x: 0,
y: 0,
vx: 0,
vy: 0,
speed: 0,
lastTime: 0,
lastX: 0,
lastY: 0,
});
const baseRgb = useMemo(() => hexToRgb(baseColor), [baseColor]);
const activeRgb = useMemo(() => hexToRgb(activeColor), [activeColor]);
const circlePath = useMemo(() => {
if (typeof window === "undefined" || !window.Path2D) return null;
const p = new Path2D();
p.arc(0, 0, dotSize / 2, 0, Math.PI * 2);
return p;
}, [dotSize]);
const buildGrid = useCallback(() => {
const wrap = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const { width, height } = wrap.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
const cols = Math.floor((width + gap) / (dotSize + gap));
const rows = Math.floor((height + gap) / (dotSize + gap));
const cell = dotSize + gap;
const gridW = cell * cols - gap;
const gridH = cell * rows - gap;
const extraX = width - gridW;
const extraY = height - gridH;
const startX = extraX / 2 + dotSize / 2;
const startY = extraY / 2 + dotSize / 2;
const dots: Dot[] = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const cx = startX + x * cell;
const cy = startY + y * cell;
dots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });
}
}
dotsRef.current = dots;
}, [dotSize, gap]);
useEffect(() => {
if (!circlePath) return;
let rafId: number;
const proxSq = proximity * proximity;
const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { x: px, y: py } = pointerRef.current;
for (const dot of dotsRef.current) {
const ox = dot.cx + dot.xOffset;
const oy = dot.cy + dot.yOffset;
const dx = dot.cx - px;
const dy = dot.cy - py;
const dsq = dx * dx + dy * dy;
let style = baseColor;
if (dsq <= proxSq) {
const dist = Math.sqrt(dsq);
const t = 1 - dist / proximity;
const r = Math.round(baseRgb.r + (activeRgb.r - baseRgb.r) * t);
const g = Math.round(baseRgb.g + (activeRgb.g - baseRgb.g) * t);
const b = Math.round(baseRgb.b + (activeRgb.b - baseRgb.b) * t);
style = `rgb(${r},${g},${b})`;
}
ctx.save();
ctx.translate(ox, oy);
ctx.fillStyle = style;
ctx.fill(circlePath);
ctx.restore();
}
rafId = requestAnimationFrame(draw);
};
draw();
return () => cancelAnimationFrame(rafId);
}, [proximity, baseColor, activeRgb, baseRgb, circlePath]);
useEffect(() => {
buildGrid();
let ro: ResizeObserver | null = null;
if ("ResizeObserver" in window) {
ro = new ResizeObserver(buildGrid);
wrapperRef.current && ro.observe(wrapperRef.current);
} else {
(window as Window).addEventListener("resize", buildGrid);
}
return () => {
if (ro) ro.disconnect();
else window.removeEventListener("resize", buildGrid);
};
}, [buildGrid]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
const now = performance.now();
const pr = pointerRef.current;
const dt = pr.lastTime ? now - pr.lastTime : 16;
const dx = e.clientX - pr.lastX;
const dy = e.clientY - pr.lastY;
let vx = (dx / dt) * 1000;
let vy = (dy / dt) * 1000;
let speed = Math.hypot(vx, vy);
if (speed > maxSpeed) {
const scale = maxSpeed / speed;
vx *= scale;
vy *= scale;
speed = maxSpeed;
}
pr.lastTime = now;
pr.lastX = e.clientX;
pr.lastY = e.clientY;
pr.vx = vx;
pr.vy = vy;
pr.speed = speed;
const rect = canvasRef.current!.getBoundingClientRect();
pr.x = e.clientX - rect.left;
pr.y = e.clientY - rect.top;
for (const dot of dotsRef.current) {
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);
if (speed > speedTrigger && dist < proximity && !dot._inertiaApplied) {
dot._inertiaApplied = true;
gsap.killTweensOf(dot);
const pushX = dot.cx - pr.x + vx * 0.005;
const pushY = dot.cy - pr.y + vy * 0.005;
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance },
onComplete: () => {
gsap.to(dot, {
xOffset: 0,
yOffset: 0,
duration: returnDuration,
ease: "elastic.out(1,0.75)",
});
dot._inertiaApplied = false;
},
});
}
}
};
const onClick = (e: MouseEvent) => {
const rect = canvasRef.current!.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
for (const dot of dotsRef.current) {
const dist = Math.hypot(dot.cx - cx, dot.cy - cy);
if (dist < shockRadius && !dot._inertiaApplied) {
dot._inertiaApplied = true;
gsap.killTweensOf(dot);
const falloff = Math.max(0, 1 - dist / shockRadius);
const pushX = (dot.cx - cx) * shockStrength * falloff;
const pushY = (dot.cy - cy) * shockStrength * falloff;
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance },
onComplete: () => {
gsap.to(dot, {
xOffset: 0,
yOffset: 0,
duration: returnDuration,
ease: "elastic.out(1,0.75)",
});
dot._inertiaApplied = false;
},
});
}
}
};
const throttledMove = throttle(onMove, 50);
window.addEventListener("mousemove", throttledMove, { passive: true });
window.addEventListener("click", onClick);
return () => {
window.removeEventListener("mousemove", throttledMove);
window.removeEventListener("click", onClick);
};
}, [
maxSpeed,
speedTrigger,
proximity,
resistance,
returnDuration,
shockRadius,
shockStrength,
]);
return (
<section
className={`p-4 flex items-center justify-center h-full w-full relative ${className}`}
style={style}
>
<div ref={wrapperRef} className="w-full h-full relative">
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
/>
</div>
</section>
);
};
export default DotGrid;
-446
View File
@@ -1,446 +0,0 @@
"use client";
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
type Vec2 = [number, number];
export interface FaultyTerminalProps
extends React.HTMLAttributes<HTMLDivElement> {
scale?: number;
gridMul?: Vec2;
digitSize?: number;
timeScale?: number;
pause?: boolean;
scanlineIntensity?: number;
glitchAmount?: number;
flickerAmount?: number;
noiseAmp?: number;
chromaticAberration?: number;
dither?: number | boolean;
curvature?: number;
tint?: string;
mouseReact?: boolean;
mouseStrength?: number;
dpr?: number;
pageLoadAnimation?: boolean;
brightness?: number;
}
const vertexShader = `
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const fragmentShader = `
precision mediump float;
varying vec2 vUv;
uniform float iTime;
uniform vec3 iResolution;
uniform float uScale;
uniform vec2 uGridMul;
uniform float uDigitSize;
uniform float uScanlineIntensity;
uniform float uGlitchAmount;
uniform float uFlickerAmount;
uniform float uNoiseAmp;
uniform float uChromaticAberration;
uniform float uDither;
uniform float uCurvature;
uniform vec3 uTint;
uniform vec2 uMouse;
uniform float uMouseStrength;
uniform float uUseMouse;
uniform float uPageLoadProgress;
uniform float uUsePageLoadAnimation;
uniform float uBrightness;
float time;
float hash21(vec2 p){
p = fract(p * 234.56);
p += dot(p, p + 34.56);
return fract(p.x * p.y);
}
float noise(vec2 p)
{
return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
}
mat2 rotate(float angle)
{
float c = cos(angle);
float s = sin(angle);
return mat2(c, -s, s, c);
}
float fbm(vec2 p)
{
p *= 1.1;
float f = 0.0;
float amp = 0.5 * uNoiseAmp;
mat2 modify0 = rotate(time * 0.02);
f += amp * noise(p);
p = modify0 * p * 2.0;
amp *= 0.454545;
mat2 modify1 = rotate(time * 0.02);
f += amp * noise(p);
p = modify1 * p * 2.0;
amp *= 0.454545;
mat2 modify2 = rotate(time * 0.08);
f += amp * noise(p);
return f;
}
float pattern(vec2 p, out vec2 q, out vec2 r) {
vec2 offset1 = vec2(1.0);
vec2 offset0 = vec2(0.0);
mat2 rot01 = rotate(0.1 * time);
mat2 rot1 = rotate(0.1);
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
return fbm(p + r);
}
float digit(vec2 p){
vec2 grid = uGridMul * 15.0;
vec2 s = floor(p * grid) / grid;
p = p * grid;
vec2 q, r;
float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
if(uUseMouse > 0.5){
vec2 mouseWorld = uMouse * uScale;
float distToMouse = distance(s, mouseWorld);
float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
intensity += mouseInfluence;
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
intensity += ripple;
}
if(uUsePageLoadAnimation > 0.5){
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
float cellDelay = cellRandom * 0.8;
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
intensity *= fadeAlpha;
}
p = fract(p);
p *= uDigitSize;
float px5 = p.x * 5.0;
float py5 = (1.0 - p.y) * 5.0;
float x = fract(px5);
float y = fract(py5);
float i = floor(py5) - 2.0;
float j = floor(px5) - 2.0;
float n = i * i + j * j;
float f = n * 0.0625;
float isOn = step(0.1, intensity - f);
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
}
float onOff(float a, float b, float c)
{
return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount;
}
float displace(vec2 look)
{
float y = look.y - mod(iTime * 0.25, 1.0);
float window = 1.0 / (1.0 + 50.0 * y * y);
return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
}
vec3 getColor(vec2 p){
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
bar *= uScanlineIntensity;
float displacement = displace(p);
p.x += displacement;
if (uGlitchAmount != 1.0) {
float extra = displacement * (uGlitchAmount - 1.0);
p.x += extra;
}
float middle = digit(p);
const float off = 0.002;
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
return baseColor;
}
vec2 barrel(vec2 uv){
vec2 c = uv * 2.0 - 1.0;
float r2 = dot(c, c);
c *= 1.0 + uCurvature * r2;
return c * 0.5 + 0.5;
}
void main() {
time = iTime * 0.333333;
vec2 uv = vUv;
if(uCurvature != 0.0){
uv = barrel(uv);
}
vec2 p = uv * uScale;
vec3 col = getColor(p);
if(uChromaticAberration != 0.0){
vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
col.r = getColor(p + ca).r;
col.b = getColor(p - ca).b;
}
col *= uTint;
col *= uBrightness;
if(uDither > 0.0){
float rnd = hash21(gl_FragCoord.xy);
col += (rnd - 0.5) * (uDither * 0.003922);
}
gl_FragColor = vec4(col, 1.0);
}
`;
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3)
h = h
.split("")
.map((c) => c + c)
.join("");
const num = parseInt(h, 16);
return [
((num >> 16) & 255) / 255,
((num >> 8) & 255) / 255,
(num & 255) / 255,
];
}
export default function FaultyTerminal({
scale = 1,
gridMul = [2, 1],
digitSize = 1.5,
timeScale = 0.3,
pause = false,
scanlineIntensity = 0.3,
glitchAmount = 1,
flickerAmount = 1,
noiseAmp = 1,
chromaticAberration = 0,
dither = 0,
curvature = 0.2,
tint = "#ffffff",
mouseReact = true,
mouseStrength = 0.2,
dpr = Math.min(window.devicePixelRatio || 1, 2),
pageLoadAnimation = true,
brightness = 1,
className,
style,
...rest
}: FaultyTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null);
const programRef = useRef<Program>(null);
const rendererRef = useRef<Renderer>(null);
const mouseRef = useRef({ x: 0.5, y: 0.5 });
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
const frozenTimeRef = useRef(0);
const rafRef = useRef<number>(0);
const loadAnimationStartRef = useRef<number>(0);
const timeOffsetRef = useRef<number>(Math.random() * 100);
const tintVec = useMemo(() => hexToRgb(tint), [tint]);
const ditherValue = useMemo(
() => (typeof dither === "boolean" ? (dither ? 1 : 0) : dither),
[dither],
);
const handleMouseMove = useCallback((e: MouseEvent) => {
const ctn = containerRef.current;
if (!ctn) return;
const rect = ctn.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height;
mouseRef.current = { x, y };
}, []);
useEffect(() => {
const ctn = containerRef.current;
if (!ctn) return;
const renderer = new Renderer({ dpr });
rendererRef.current = renderer;
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 1);
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height,
),
},
uScale: { value: scale },
uGridMul: { value: new Float32Array(gridMul) },
uDigitSize: { value: digitSize },
uScanlineIntensity: { value: scanlineIntensity },
uGlitchAmount: { value: glitchAmount },
uFlickerAmount: { value: flickerAmount },
uNoiseAmp: { value: noiseAmp },
uChromaticAberration: { value: chromaticAberration },
uDither: { value: ditherValue },
uCurvature: { value: curvature },
uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) },
uMouse: {
value: new Float32Array([
smoothMouseRef.current.x,
smoothMouseRef.current.y,
]),
},
uMouseStrength: { value: mouseStrength },
uUseMouse: { value: mouseReact ? 1 : 0 },
uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 },
uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 },
uBrightness: { value: brightness },
},
});
programRef.current = program;
const mesh = new Mesh(gl, { geometry, program });
function resize() {
if (!ctn || !renderer) return;
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
program.uniforms.iResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height,
);
}
const resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(ctn);
resize();
const update = (t: number) => {
rafRef.current = requestAnimationFrame(update);
if (pageLoadAnimation && loadAnimationStartRef.current === 0) {
loadAnimationStartRef.current = t;
}
if (!pause) {
const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale;
program.uniforms.iTime.value = elapsed;
frozenTimeRef.current = elapsed;
} else {
program.uniforms.iTime.value = frozenTimeRef.current;
}
if (pageLoadAnimation && loadAnimationStartRef.current > 0) {
const animationDuration = 2000;
const animationElapsed = t - loadAnimationStartRef.current;
const progress = Math.min(animationElapsed / animationDuration, 1);
program.uniforms.uPageLoadProgress.value = progress;
}
if (mouseReact) {
const dampingFactor = 0.08;
const smoothMouse = smoothMouseRef.current;
const mouse = mouseRef.current;
smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
const mouseUniform = program.uniforms.uMouse.value as Float32Array;
mouseUniform[0] = smoothMouse.x;
mouseUniform[1] = smoothMouse.y;
}
renderer.render({ scene: mesh });
};
rafRef.current = requestAnimationFrame(update);
ctn.appendChild(gl.canvas);
if (mouseReact) ctn.addEventListener("mousemove", handleMouseMove);
return () => {
cancelAnimationFrame(rafRef.current);
resizeObserver.disconnect();
if (mouseReact) ctn.removeEventListener("mousemove", handleMouseMove);
if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
gl.getExtension("WEBGL_lose_context")?.loseContext();
loadAnimationStartRef.current = 0;
timeOffsetRef.current = Math.random() * 100;
};
}, [
dpr,
pause,
timeScale,
scale,
gridMul,
digitSize,
scanlineIntensity,
glitchAmount,
flickerAmount,
noiseAmp,
chromaticAberration,
ditherValue,
curvature,
tintVec,
mouseReact,
mouseStrength,
pageLoadAnimation,
brightness,
handleMouseMove,
]);
return (
<div
ref={containerRef}
className={`w-full h-full relative overflow-hidden ${className}`}
style={style}
{...rest}
/>
);
}
-443
View File
@@ -1,443 +0,0 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type LogoItem =
| {
node: React.ReactNode;
href?: string;
title?: string;
ariaLabel?: string;
}
| {
src: string;
alt?: string;
href?: string;
title?: string;
srcSet?: string;
sizes?: string;
width?: number;
height?: number;
};
export interface LogoLoopProps {
logos: LogoItem[];
speed?: number;
direction?: "left" | "right";
width?: number | string;
logoHeight?: number;
gap?: number;
pauseOnHover?: boolean;
fadeOut?: boolean;
fadeOutColor?: string;
scaleOnHover?: boolean;
ariaLabel?: string;
className?: string;
style?: React.CSSProperties;
}
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
} as const;
const toCssLength = (value?: number | string): string | undefined =>
typeof value === "number" ? `${value}px` : (value ?? undefined);
const cx = (...parts: Array<string | false | null | undefined>) =>
parts.filter(Boolean).join(" ");
const useResizeObserver = (
callback: () => void,
elements: Array<React.RefObject<Element | null>>,
dependencies: React.DependencyList,
) => {
useEffect(() => {
if (!window.ResizeObserver) {
const handleResize = () => callback();
window.addEventListener("resize", handleResize);
callback();
return () => window.removeEventListener("resize", handleResize);
}
const observers = elements.map((ref) => {
if (!ref.current) return null;
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
return observer;
});
callback();
return () => {
observers.forEach((observer) => observer?.disconnect());
};
}, dependencies);
};
const useImageLoader = (
seqRef: React.RefObject<HTMLUListElement | null>,
onLoad: () => void,
dependencies: React.DependencyList,
) => {
useEffect(() => {
const images = seqRef.current?.querySelectorAll("img") ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remainingImages = images.length;
const handleImageLoad = () => {
remainingImages -= 1;
if (remainingImages === 0) {
onLoad();
}
};
images.forEach((img) => {
const htmlImg = img as HTMLImageElement;
if (htmlImg.complete) {
handleImageLoad();
} else {
htmlImg.addEventListener("load", handleImageLoad, { once: true });
htmlImg.addEventListener("error", handleImageLoad, { once: true });
}
});
return () => {
images.forEach((img) => {
img.removeEventListener("load", handleImageLoad);
img.removeEventListener("error", handleImageLoad);
});
};
}, dependencies);
};
const useAnimationLoop = (
trackRef: React.RefObject<HTMLDivElement | null>,
targetVelocity: number,
seqWidth: number,
isHovered: boolean,
pauseOnHover: boolean,
) => {
const rafRef = useRef<number | null>(null);
const lastTimestampRef = useRef<number | null>(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (seqWidth > 0) {
offsetRef.current =
((offsetRef.current % seqWidth) + seqWidth) % seqWidth;
track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;
}
if (prefersReduced) {
track.style.transform = "translate3d(0, 0, 0)";
return () => {
lastTimestampRef.current = null;
};
}
const animate = (timestamp: number) => {
if (lastTimestampRef.current === null) {
lastTimestampRef.current = timestamp;
}
const deltaTime =
Math.max(0, timestamp - lastTimestampRef.current) / 1000;
lastTimestampRef.current = timestamp;
const target = pauseOnHover && isHovered ? 0 : targetVelocity;
const easingFactor =
1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;
if (seqWidth > 0) {
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;
offsetRef.current = nextOffset;
const translateX = -offsetRef.current;
track.style.transform = `translate3d(${translateX}px, 0, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastTimestampRef.current = null;
};
}, [targetVelocity, seqWidth, isHovered, pauseOnHover]);
};
export const LogoLoop = React.memo<LogoLoopProps>(
({
logos,
speed = 120,
direction = "left",
width = "100%",
logoHeight = 28,
gap = 32,
pauseOnHover = true,
fadeOut = false,
fadeOutColor,
scaleOnHover = false,
ariaLabel = "Partner logos",
className,
style,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const seqRef = useRef<HTMLUListElement>(null);
const [seqWidth, setSeqWidth] = useState<number>(0);
const [copyCount, setCopyCount] = useState<number>(
ANIMATION_CONFIG.MIN_COPIES,
);
const [isHovered, setIsHovered] = useState<boolean>(false);
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
const directionMultiplier = direction === "left" ? 1 : -1;
const speedMultiplier = speed < 0 ? -1 : 1;
return magnitude * directionMultiplier * speedMultiplier;
}, [speed, direction]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const sequenceWidth =
seqRef.current?.getBoundingClientRect?.()?.width ?? 0;
if (sequenceWidth > 0) {
setSeqWidth(Math.ceil(sequenceWidth));
const copiesNeeded =
Math.ceil(containerWidth / sequenceWidth) +
ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
}
}, []);
useResizeObserver(
updateDimensions,
[containerRef, seqRef],
[logos, gap, logoHeight],
);
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);
useAnimationLoop(
trackRef,
targetVelocity,
seqWidth,
isHovered,
pauseOnHover,
);
const cssVariables = useMemo(
() =>
({
"--logoloop-gap": `${gap}px`,
"--logoloop-logoHeight": `${logoHeight}px`,
...(fadeOutColor && { "--logoloop-fadeColor": fadeOutColor }),
}) as React.CSSProperties,
[gap, logoHeight, fadeOutColor],
);
const rootClasses = useMemo(
() =>
cx(
"relative overflow-x-hidden group",
"[--logoloop-gap:32px]",
"[--logoloop-logoHeight:28px]",
"[--logoloop-fadeColorAuto:#ffffff]",
"dark:[--logoloop-fadeColorAuto:#0b0b0b]",
scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]",
className,
),
[scaleOnHover, className],
);
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsHovered(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsHovered(false);
}, [pauseOnHover]);
const renderLogoItem = useCallback(
(item: LogoItem, key: React.Key) => {
const isNodeItem = "node" in item;
const content = isNodeItem ? (
<span
className={cx(
"inline-flex items-center",
"motion-reduce:transition-none",
scaleOnHover &&
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
)}
aria-hidden={!!(item as any).href && !(item as any).ariaLabel}
>
{(item as any).node}
</span>
) : (
<img
className={cx(
"h-[var(--logoloop-logoHeight)] w-auto block object-contain",
"[-webkit-user-drag:none] pointer-events-none",
"[image-rendering:-webkit-optimize-contrast]",
"motion-reduce:transition-none",
scaleOnHover &&
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
)}
src={(item as any).src}
srcSet={(item as any).srcSet}
sizes={(item as any).sizes}
width={(item as any).width}
height={(item as any).height}
alt={(item as any).alt ?? ""}
title={(item as any).title}
loading="lazy"
decoding="async"
draggable={false}
/>
);
const itemAriaLabel = isNodeItem
? ((item as any).ariaLabel ?? (item as any).title)
: ((item as any).alt ?? (item as any).title);
const inner = (item as any).href ? (
<a
className={cx(
"inline-flex items-center no-underline rounded",
"transition-opacity duration-200 ease-linear",
"hover:opacity-80",
"focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2",
)}
href={(item as any).href}
aria-label={itemAriaLabel || "logo link"}
target="_blank"
rel="noreferrer noopener"
>
{content}
</a>
) : (
content
);
return (
<li
className={cx(
"flex-none mr-[var(--logoloop-gap)] text-[length:var(--logoloop-logoHeight)] leading-[1]",
scaleOnHover && "overflow-visible group/item",
)}
key={key}
role="listitem"
>
{inner}
</li>
);
},
[scaleOnHover],
);
const logoLists = useMemo(
() =>
Array.from({ length: copyCount }, (_, copyIndex) => (
<ul
className="flex items-center"
key={`copy-${copyIndex}`}
role="list"
aria-hidden={copyIndex > 0}
ref={copyIndex === 0 ? seqRef : undefined}
>
{logos.map((item, itemIndex) =>
renderLogoItem(item, `${copyIndex}-${itemIndex}`),
)}
</ul>
)),
[copyCount, logos, renderLogoItem],
);
const containerStyle = useMemo(
(): React.CSSProperties => ({
width: toCssLength(width) ?? "100%",
...cssVariables,
...style,
}),
[width, cssVariables, style],
);
return (
<div
ref={containerRef}
className={rootClasses}
style={containerStyle}
role="region"
aria-label={ariaLabel}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{fadeOut && (
<>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-y-0 left-0 z-[1]",
"w-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-y-0 right-0 z-[1]",
"w-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
</>
)}
<div
className={cx(
"flex w-max will-change-transform select-none",
"motion-reduce:transform-none",
)}
ref={trackRef}
>
{logoLists}
</div>
</div>
);
},
);
LogoLoop.displayName = "LogoLoop";
export default LogoLoop;
-44
View File
@@ -1,44 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface RotatingTextProps {
words: string[];
className?: string;
interval?: number; // Intervalo em milissegundos para a rotação
}
export function RotatingText({
words,
className,
interval = 3000,
}: RotatingTextProps) {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % words.length);
}, interval);
return () => clearInterval(timer);
}, [words, interval]);
return (
<div className={cn("relative inline-block min-h-[1.5em]", className)}>
<AnimatePresence mode="wait">
<motion.span
key={currentIndex}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5 }}
className="block text-center"
>
{words[currentIndex]}
</motion.span>
</AnimatePresence>
</div>
);
}
-158
View File
@@ -1,158 +0,0 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
interface GridOffset {
x: number;
y: number;
}
interface SquaresProps {
direction?: "diagonal" | "up" | "right" | "down" | "left";
speed?: number;
borderColor?: CanvasStrokeStyle;
squareSize?: number;
hoverFillColor?: CanvasStrokeStyle;
}
const Squares: React.FC<SquaresProps> = ({
direction = "right",
speed = 1,
borderColor = "#999",
squareSize = 40,
hoverFillColor = "#222",
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const requestRef = useRef<number | null>(null);
const numSquaresX = useRef<number>(0);
const numSquaresY = useRef<number>(0);
const gridOffset = useRef<GridOffset>({ x: 0, y: 0 });
const hoveredSquareRef = useRef<GridOffset | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const resizeCanvas = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
numSquaresX.current = Math.ceil(canvas.width / squareSize) + 1;
numSquaresY.current = Math.ceil(canvas.height / squareSize) + 1;
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const drawGrid = () => {
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
for (let x = startX; x < canvas.width + squareSize; x += squareSize) {
for (let y = startY; y < canvas.height + squareSize; y += squareSize) {
const squareX = x - (gridOffset.current.x % squareSize);
const squareY = y - (gridOffset.current.y % squareSize);
if (
hoveredSquareRef.current &&
hoveredSquareRef.current.x >= squareX &&
hoveredSquareRef.current.x < squareX + squareSize &&
hoveredSquareRef.current.y >= squareY &&
hoveredSquareRef.current.y < squareY + squareSize
) {
ctx.fillStyle = hoverFillColor;
ctx.fillRect(squareX, squareY, squareSize, squareSize);
}
ctx.strokeStyle = borderColor;
ctx.strokeRect(squareX, squareY, squareSize, squareSize);
}
}
const gradient = ctx.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2,
);
gradient.addColorStop(0, "rgba(0, 0, 0, 0)");
gradient.addColorStop(1, "#060010");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const updateAnimation = () => {
const effectiveSpeed = Math.max(speed, 0.1);
switch (direction) {
case "right":
gridOffset.current.x =
(gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
break;
case "left":
gridOffset.current.x =
(gridOffset.current.x + effectiveSpeed + squareSize) % squareSize;
break;
case "up":
gridOffset.current.y =
(gridOffset.current.y + effectiveSpeed + squareSize) % squareSize;
break;
case "down":
gridOffset.current.y =
(gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
break;
case "diagonal":
gridOffset.current.x =
(gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
gridOffset.current.y =
(gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
break;
default:
break;
}
drawGrid();
requestRef.current = requestAnimationFrame(updateAnimation);
};
const handleMouseMove = (event: MouseEvent) => {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
hoveredSquareRef.current = { x: mouseX, y: mouseY };
};
const handleMouseLeave = () => {
hoveredSquareRef.current = null;
};
window.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseleave", handleMouseLeave); // Use document for leaving window
requestRef.current = requestAnimationFrame(updateAnimation);
return () => {
window.removeEventListener("resize", resizeCanvas);
if (requestRef.current) cancelAnimationFrame(requestRef.current);
window.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseleave", handleMouseLeave);
};
}, [direction, speed, borderColor, hoverFillColor, squareSize]);
return (
<canvas
ref={canvasRef}
className="w-full h-full border-none block max-w-full"
></canvas>
);
};
export default Squares;
-209
View File
@@ -1,209 +0,0 @@
"use client";
import { gsap } from "gsap";
import {
createElement,
type ElementType,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface TextTypeProps {
className?: string;
showCursor?: boolean;
hideCursorWhileTyping?: boolean;
cursorCharacter?: string | React.ReactNode;
cursorBlinkDuration?: number;
cursorClassName?: string;
text: string | string[];
as?: ElementType;
typingSpeed?: number;
initialDelay?: number;
pauseDuration?: number;
deletingSpeed?: number;
loop?: boolean;
textColors?: string[];
variableSpeed?: { min: number; max: number };
onSentenceComplete?: (sentence: string, index: number) => void;
startOnVisible?: boolean;
reverseMode?: boolean;
}
const TextType = ({
text,
as: Component = "div",
typingSpeed = 50,
initialDelay = 0,
pauseDuration = 2000,
deletingSpeed = 30,
loop = true,
className = "",
showCursor = true,
hideCursorWhileTyping = false,
cursorCharacter = "|",
cursorClassName = "",
cursorBlinkDuration = 0.5,
textColors = [],
variableSpeed,
onSentenceComplete,
startOnVisible = false,
reverseMode = false,
...props
}: TextTypeProps & React.HTMLAttributes<HTMLElement>) => {
const [displayedText, setDisplayedText] = useState("");
const [currentCharIndex, setCurrentCharIndex] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const [isVisible, setIsVisible] = useState(!startOnVisible);
const cursorRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLElement>(null);
const textArray = useMemo(
() => (Array.isArray(text) ? text : [text]),
[text],
);
const getRandomSpeed = useCallback(() => {
if (!variableSpeed) return typingSpeed;
const { min, max } = variableSpeed;
return Math.random() * (max - min) + min;
}, [variableSpeed, typingSpeed]);
const getCurrentTextColor = () => {
if (textColors.length === 0) return "#ffffff";
return textColors[currentTextIndex % textColors.length];
};
useEffect(() => {
if (!startOnVisible || !containerRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
});
},
{ threshold: 0.1 },
);
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [startOnVisible]);
useEffect(() => {
if (showCursor && cursorRef.current) {
gsap.set(cursorRef.current, { opacity: 1 });
gsap.to(cursorRef.current, {
opacity: 0,
duration: cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: "power2.inOut",
});
}
}, [showCursor, cursorBlinkDuration]);
useEffect(() => {
if (!isVisible) return;
let timeout: NodeJS.Timeout;
const currentText = textArray[currentTextIndex];
const processedText = reverseMode
? currentText.split("").reverse().join("")
: currentText;
const executeTypingAnimation = () => {
if (isDeleting) {
if (displayedText === "") {
setIsDeleting(false);
if (currentTextIndex === textArray.length - 1 && !loop) {
return;
}
if (onSentenceComplete) {
onSentenceComplete(textArray[currentTextIndex], currentTextIndex);
}
setCurrentTextIndex((prev) => (prev + 1) % textArray.length);
setCurrentCharIndex(0);
timeout = setTimeout(() => {}, pauseDuration);
} else {
timeout = setTimeout(() => {
setDisplayedText((prev) => prev.slice(0, -1));
}, deletingSpeed);
}
} else {
if (currentCharIndex < processedText.length) {
timeout = setTimeout(
() => {
setDisplayedText(
(prev) => prev + processedText[currentCharIndex],
);
setCurrentCharIndex((prev) => prev + 1);
},
variableSpeed ? getRandomSpeed() : typingSpeed,
);
} else if (textArray.length > 1) {
timeout = setTimeout(() => {
setIsDeleting(true);
}, pauseDuration);
}
}
};
if (currentCharIndex === 0 && !isDeleting && displayedText === "") {
timeout = setTimeout(executeTypingAnimation, initialDelay);
} else {
executeTypingAnimation();
}
return () => clearTimeout(timeout);
}, [
currentCharIndex,
displayedText,
isDeleting,
typingSpeed,
deletingSpeed,
pauseDuration,
textArray,
currentTextIndex,
loop,
initialDelay,
isVisible,
reverseMode,
variableSpeed,
onSentenceComplete,
]);
const shouldHideCursor =
hideCursorWhileTyping &&
(currentCharIndex < textArray[currentTextIndex].length || isDeleting);
return createElement(
Component,
{
ref: containerRef,
className: `inline-block whitespace-pre-wrap tracking-tight ${className}`,
...props,
},
<span className="inline" style={{ color: getCurrentTextColor() }}>
{displayedText}
</span>,
showCursor && (
<span
ref={cursorRef}
className={`ml-1 inline-block opacity-100 ${shouldHideCursor ? "hidden" : ""} ${cursorClassName}`}
>
{cursorCharacter}
</span>
),
);
};
export default TextType;
@@ -1,36 +0,0 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
interface AnimatedGradientTextProps {
children: React.ReactNode;
className?: string;
}
export function AnimatedGradientText({
children,
className,
}: AnimatedGradientTextProps) {
return (
<motion.span
className={cn(
"bg-gradient-to-r from-cyan-400 via-blue-500 to-blue-600 bg-clip-text text-transparent font-bold",
className,
)}
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
backgroundSize: "200% 200%",
}}
>
{children}
</motion.span>
);
}
-33
View File
@@ -1,33 +0,0 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
interface AuroraTextProps {
children: React.ReactNode;
className?: string;
}
export function AuroraText({ children, className }: AuroraTextProps) {
return (
<motion.span
className={cn(
"bg-gradient-to-r from-blue-400 via-cyan-500 to-blue-500 bg-clip-text text-transparent",
className,
)}
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "linear",
}}
style={{
backgroundSize: "200% 200%",
}}
>
{children}
</motion.span>
);
}
-54
View File
@@ -1,54 +0,0 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
interface BentoGridProps {
children: React.ReactNode;
className?: string;
}
interface BentoGridItemProps {
children: React.ReactNode;
className?: string;
colSpan?: number;
rowSpan?: number;
}
export function BentoGrid({ children, className }: BentoGridProps) {
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-6xl mx-auto justify-center",
className,
)}
>
{children}
</div>
);
}
export function BentoGridItem({
children,
className,
colSpan = 1,
rowSpan = 1,
}: BentoGridItemProps) {
return (
<motion.div
className={cn(
"relative overflow-hidden rounded-lg border border-white/20 bg-white/5 backdrop-blur-sm p-6",
`col-span-${colSpan} row-span-${rowSpan}`,
className,
)}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
whileHover={{ scale: 1.02 }}
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-blue-400/10 to-cyan-500/10 opacity-0 hover:opacity-100 transition-opacity duration-300" />
<div className="relative z-10">{children}</div>
</motion.div>
);
}
-153
View File
@@ -1,153 +0,0 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import type { MotionProps } from "motion/react";
import {
type MotionValue,
motion,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import React, { type PropsWithChildren, useRef } from "react";
import { cn } from "@/lib/utils";
export interface DockProps extends VariantProps<typeof dockVariants> {
className?: string;
iconSize?: number;
iconMagnification?: number;
disableMagnification?: boolean;
iconDistance?: number;
direction?: "top" | "middle" | "bottom";
children: React.ReactNode;
}
const DEFAULT_SIZE = 40;
const DEFAULT_MAGNIFICATION = 60;
const DEFAULT_DISTANCE = 140;
const DEFAULT_DISABLEMAGNIFICATION = false;
const dockVariants = cva(
"supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto mt-8 flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md",
);
const Dock = React.forwardRef<HTMLDivElement, DockProps>(
(
{
className,
children,
iconSize = DEFAULT_SIZE,
iconMagnification = DEFAULT_MAGNIFICATION,
disableMagnification = DEFAULT_DISABLEMAGNIFICATION,
iconDistance = DEFAULT_DISTANCE,
direction = "middle",
...props
},
ref,
) => {
const mouseX = useMotionValue(Infinity);
const renderChildren = () => {
return React.Children.map(children, (child) => {
if (
React.isValidElement<DockIconProps>(child) &&
child.type === DockIcon
) {
return React.cloneElement(child, {
...child.props,
mouseX: mouseX,
size: iconSize,
magnification: iconMagnification,
disableMagnification: disableMagnification,
distance: iconDistance,
});
}
return child;
});
};
return (
<motion.div
ref={ref}
onMouseMove={(e) => mouseX.set(e.pageX)}
onMouseLeave={() => mouseX.set(Infinity)}
{...props}
className={cn(dockVariants({ className }), {
"items-start": direction === "top",
"items-center": direction === "middle",
"items-end": direction === "bottom",
})}
>
{renderChildren()}
</motion.div>
);
},
);
Dock.displayName = "Dock";
export interface DockIconProps
extends Omit<MotionProps & React.HTMLAttributes<HTMLDivElement>, "children"> {
size?: number;
magnification?: number;
disableMagnification?: boolean;
distance?: number;
mouseX?: MotionValue<number>;
className?: string;
children?: React.ReactNode;
props?: PropsWithChildren;
}
const DockIcon = ({
size = DEFAULT_SIZE,
magnification = DEFAULT_MAGNIFICATION,
disableMagnification,
distance = DEFAULT_DISTANCE,
mouseX,
className,
children,
...props
}: DockIconProps) => {
const ref = useRef<HTMLDivElement>(null);
const padding = Math.max(6, size * 0.2);
const defaultMouseX = useMotionValue(Infinity);
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
return val - bounds.x - bounds.width / 2;
});
const targetSize = disableMagnification ? size : magnification;
const sizeTransform = useTransform(
distanceCalc,
[-distance, 0, distance],
[size, targetSize, size],
);
const scaleSize = useSpring(sizeTransform, {
mass: 0.1,
stiffness: 150,
damping: 12,
});
return (
<motion.div
ref={ref}
style={{ width: scaleSize, height: scaleSize, padding }}
className={cn(
"flex aspect-square cursor-pointer items-center justify-center rounded-full",
disableMagnification && "transition-colors hover:bg-muted-foreground",
className,
)}
{...props}
>
<div>{children}</div>
</motion.div>
);
};
DockIcon.displayName = "DockIcon";
export { Dock, DockIcon, dockVariants };
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { motion } from "motion/react";
import type React from "react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/**
* DotPattern Component Props
*
* @param {number} [width=16] - The horizontal spacing between dots
* @param {number} [height=16] - The vertical spacing between dots
* @param {number} [x=0] - The x-offset of the entire pattern
* @param {number} [y=0] - The y-offset of the entire pattern
* @param {number} [cx=1] - The x-offset of individual dots
* @param {number} [cy=1] - The y-offset of individual dots
* @param {number} [cr=1] - The radius of each dot
* @param {string} [className] - Additional CSS classes to apply to the SVG container
* @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
*/
interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number;
height?: number;
x?: number;
y?: number;
cx?: number;
cy?: number;
cr?: number;
className?: string;
glow?: boolean;
[key: string]: unknown;
}
/**
* DotPattern Component
*
* A React component that creates an animated or static dot pattern background using SVG.
* The pattern automatically adjusts to fill its container and can optionally display glowing dots.
*
* @component
*
* @see DotPatternProps for the props interface.
*
* @example
* // Basic usage
* <DotPattern />
*
* // With glowing effect and custom spacing
* <DotPattern
* width={20}
* height={20}
* glow={true}
* className="opacity-50"
* />
*
* @notes
* - The component is client-side only ("use client")
* - Automatically responds to container size changes
* - When glow is enabled, dots will animate with random delays and durations
* - Uses Motion for animations
* - Dots color can be controlled via the text color utility classes
*/
export function DotPattern({
width = 16,
height = 16,
x = 0,
y = 0,
cx = 1,
cy = 1,
cr = 1,
className,
glow = false,
...props
}: DotPatternProps) {
const id = useId();
const containerRef = useRef<SVGSVGElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
const dots = Array.from(
{
length:
Math.ceil(dimensions.width / width) *
Math.ceil(dimensions.height / height),
},
(_, i) => {
const col = i % Math.ceil(dimensions.width / width);
const row = Math.floor(i / Math.ceil(dimensions.width / width));
return {
x: col * width + cx,
y: row * height + cy,
delay: Math.random() * 5,
duration: Math.random() * 3 + 2,
};
},
);
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full",
className,
)}
{...props}
>
<defs>
<radialGradient id={`${id}-gradient`}>
<stop offset="0%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
</defs>
{dots.map((dot, index) => (
<motion.circle
key={`${dot.x}-${dot.y}`}
cx={dot.x}
cy={dot.y}
r={cr}
fill={glow ? `url(#${id}-gradient)` : "currentColor"}
className="text-neutral-400/80"
initial={glow ? { opacity: 0.4, scale: 1 } : {}}
animate={
glow
? {
opacity: [0.4, 1, 0.4],
scale: [1, 1.5, 1],
}
: {}
}
transition={
glow
? {
duration: dot.duration,
repeat: Infinity,
repeatType: "reverse",
delay: dot.delay,
ease: "easeInOut",
}
: {}
}
/>
))}
</svg>
);
}
-128
View File
@@ -1,128 +0,0 @@
"use client";
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
const MOVEMENT_DAMPING = 1400;
const GLOBE_CONFIG: COBEOptions = {
width: 800,
height: 800,
onRender: () => {},
devicePixelRatio: 2,
phi: 0,
theta: 0.3,
dark: 0,
diffuse: 0.4,
mapSamples: 16000,
mapBrightness: 1.2,
baseColor: [1, 1, 1],
markerColor: [251 / 255, 100 / 255, 21 / 255],
glowColor: [1, 1, 1],
markers: [
{ location: [14.5995, 120.9842], size: 0.03 },
{ location: [19.076, 72.8777], size: 0.1 },
{ location: [23.8103, 90.4125], size: 0.05 },
{ location: [30.0444, 31.2357], size: 0.07 },
{ location: [39.9042, 116.4074], size: 0.08 },
{ location: [-23.5505, -46.6333], size: 0.1 },
{ location: [19.4326, -99.1332], size: 0.1 },
{ location: [40.7128, -74.006], size: 0.1 },
{ location: [34.6937, 135.5022], size: 0.05 },
{ location: [41.0082, 28.9784], size: 0.06 },
],
};
export function Globe({
className,
config = GLOBE_CONFIG,
}: {
className?: string;
config?: COBEOptions;
}) {
let phi = 0;
let width = 0;
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerInteractionMovement = useRef(0);
const r = useMotionValue(0);
const rs = useSpring(r, {
mass: 1,
damping: 30,
stiffness: 100,
});
const updatePointerInteraction = (value: number | null) => {
pointerInteracting.current = value;
if (canvasRef.current) {
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
}
};
const updateMovement = (clientX: number) => {
if (pointerInteracting.current !== null) {
const delta = clientX - pointerInteracting.current;
pointerInteractionMovement.current = delta;
r.set(r.get() + delta / MOVEMENT_DAMPING);
}
};
useEffect(() => {
const onResize = () => {
if (canvasRef.current) {
width = canvasRef.current.offsetWidth;
}
};
window.addEventListener("resize", onResize);
onResize();
const globe = createGlobe(canvasRef.current!, {
...config,
width: width * 2,
height: width * 2,
onRender: (state) => {
if (!pointerInteracting.current) phi += 0.005;
state.phi = phi + rs.get();
state.width = width * 2;
state.height = width * 2;
},
});
setTimeout(() => (canvasRef.current!.style.opacity = "1"), 0);
return () => {
globe.destroy();
window.removeEventListener("resize", onResize);
};
}, [rs, config]);
return (
<div
className={cn(
"absolute inset-0 mx-auto aspect-[1/1] w-full max-w-[600px]",
className,
)}
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]",
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX;
updatePointerInteraction(e.clientX);
}}
onPointerUp={() => updatePointerInteraction(null)}
onPointerOut={() => updatePointerInteraction(null)}
onMouseMove={(e) => updateMovement(e.clientX)}
onTouchMove={(e) =>
e.touches[0] && updateMovement(e.touches[0].clientX)
}
/>
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
interface MagicCardProps {
children: React.ReactNode;
className?: string;
}
export function MagicCard({ children, className }: MagicCardProps) {
return (
<motion.div
className={cn(
"relative overflow-hidden rounded-lg border border-white/20 bg-white/5 backdrop-blur-sm",
className,
)}
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-shimmer" />
{children}
</motion.div>
);
}
-31
View File
@@ -1,31 +0,0 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
interface ShimmerButtonProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
export function ShimmerButton({
children,
className,
onClick,
}: ShimmerButtonProps) {
return (
<motion.button
className={cn(
"relative overflow-hidden rounded-lg bg-gradient-to-r from-cyan-500 to-blue-600 px-6 py-3 font-semibold text-white shadow-lg",
className,
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onClick}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer" />
<span className="relative z-10">{children}</span>
</motion.button>
);
}
-23
View File
@@ -1,23 +0,0 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
interface TextRevealProps {
children: React.ReactNode;
className?: string;
}
export function TextReveal({ children, className }: TextRevealProps) {
return (
<motion.p
className={cn("text-gray-300 leading-relaxed", className)}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
viewport={{ once: true }}
>
{children}
</motion.p>
);
}
-23
View File
@@ -1,23 +0,0 @@
# Verificação automática de projetos na VPS
A lista pública de projetos só exibe demos cujo snapshot atual tenha resposta final HTTP `200` e `Content-Type: text/html`. O snapshot vence após 15 minutos; se não houver uma verificação atual, nenhum projeto é exibido.
## Pré-requisito
O cron deve executar no mesmo diretório de release que contém `data/project-health.json` consumido pelo processo Next.js. Em deploys com diretório versionado, aponte o cron para o symlink `current`.
```cron
*/10 * * * * cd /var/www/evandro.dev.br/current && /usr/bin/node scripts/check-projects.mjs >> /var/log/evandro-project-health.log 2>&1
```
Para validar manualmente:
```bash
cd /var/www/evandro.dev.br/current
npm run check:projects
cat data/project-health.json
```
O script segue redirecionamentos, limita cada requisição a 10 segundos, não aceita `3xx`, `4xx`, `5xx`, DNS/TLS inválido nem conteúdo não HTML. Ele grava em arquivo temporário e renomeia ao final, evitando que o Next.js leia JSON parcial.
Após a primeira instalação, rode `npm run check:projects` antes de iniciar a aplicação. O ISR da home é de 5 minutos; portanto, uma alteração de disponibilidade pode levar até esse intervalo adicional para aparecer no HTML público.
+39
View File
@@ -0,0 +1,39 @@
# Disponibilidade das demos de projetos
A homepage seleciona projetos pelo campo `featured` de `public/portfolio-data.json`. O snapshot de health nunca decide se um card existe; ele serve somente para apresentar o estado do CTA da demo.
## Estados
`lib/project-health.ts` deriva quatro estados:
| Estado | Condição | Comportamento do CTA |
| --- | --- | --- |
| `available` | A demo tem URL e a última resposta conhecida foi `200` com `Content-Type: text/html`. | Exibe “Abrir projeto”. |
| `unavailable` | A demo tem URL e a última resposta conhecida foi conclusiva, mas não atende ao contrato `200` + HTML. | Exibe “Demo indisponível na última verificação” sem link. |
| `unknown` | A demo tem URL, mas não existe observação ou a tentativa terminou em erro/status nulo. | Mantém “Abrir projeto” com aviso acessível de disponibilidade não verificada. |
| `none` | O projeto não tem `demoUrl`. | Não exibe CTA de demo. |
Links de código e os demais dados do card independem desses estados.
## Leitura durante a renderização
`loadFeaturedProjects()` lê `data/project-health.json` quando a homepage é renderizada.
- **Produção:** usa a última observação disponível, mesmo antiga. Não faz fetch externo durante render e continua exibindo todos os projetos `featured` se o arquivo estiver ausente ou inválido.
- **Desenvolvimento:** usa imediatamente um snapshot com até 15 minutos. Se o snapshot estiver ausente ou antigo, consulta as demos em memória. Uma falha conserva a última observação conclusiva daquele projeto quando ela existe; caso contrário, o estado fica `unknown`. Esse refresh não grava o arquivo.
## Atualização manual opcional
Para atualizar a observação versionada:
```bash
pnpm check:projects
```
`scripts/check-projects.mjs` lê as demos de `public/portfolio-data.json`, segue redirecionamentos, aplica timeout de 10 segundos e registra status, content type, URL final, latência, horário e eventual erro. A escrita é atômica: o processo grava `data/project-health.json.<pid>.tmp` e só então renomeia o arquivo para `data/project-health.json`.
O comando consulta serviços externos e altera `data/project-health.json`; execute-o apenas quando uma nova observação deva ser revisada e versionada.
## Papel do snapshot
`data/project-health.json` é a última observação conhecida, não um gate de build, deploy ou conteúdo. Ele pode estar antigo, ausente ou conter falhas sem esconder os cards selecionados. Nenhum cron é necessário para a aplicação funcionar; uma automação externa pode executar a atualização manual, mas não faz parte deste repositório nem da renderização de produção.
@@ -0,0 +1,903 @@
# Portfolio Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task in the current checkout. Do not dispatch writing agents: one editor owns the dirty working tree. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Tornar conteúdo, acessibilidade, movimento, dependências, analytics, deploy e validação do portfólio previsíveis, com menos JavaScript e sem alterar sua identidade visual.
**Architecture:** Conteúdo de projetos continua estático e recebe apenas uma projeção observacional de saúde de demo; falha de monitoramento nunca remove card. O shell deixa de instalar scroll/animação global, mantendo Motion local e GSAP somente na história do notebook. Dependências exatas, lock congelado, consentimento opt-in e CI formam o limite operacional.
**Tech Stack:** Next.js 16.3.0 App Router, React 19.1.0, TypeScript 5.9.2, Tailwind CSS 4.1.12, Motion 12.23.12, GSAP 3.13.0, React Three Fiber 9.5.0, Jest 30.3.0, Biome 2.2.0, Node 22 e pnpm 9.15.9.
**Spec:** `docs/superpowers/specs/2026-09-04-portfolio-hardening-design.md`
## Global Constraints
- Trabalhar no checkout atual; não criar worktree porque mudanças não commitadas são parte do estado-alvo e haverá um único editor.
- Não executar branch, commit, reset, checkout, stash nem comando Git destrutivo.
- Preservar `data/project-health.json` e o `globals.css` não rastreado da raiz sem qualquer edição.
- Evoluir, sem restaurar, o conteúdo atual de `lib/portfolio-data.ts` e `lib/portfolio-data.load.test.ts`.
- Editar somente `app/globals.css`; nunca importar, mover ou apagar o `globals.css` da raiz.
- Manter preto/papel/verde ácido, fontes, hero command-center, globo, starfield, notebook, cartões e conteúdo factual existente.
- Não criar dados, métricas, clientes, reviews, endereços, preços ou claims.
- Não alterar metadata, JSON-LD, sitemap, robots ou conteúdo por SEO/GEO externo; essa integração terá plano separado.
- Não adicionar dependência; remover apenas as dependências que ficarem sem consumidores.
- Não usar suppressions do Biome, audit force, ignore de advisory, override transitivo, alias ou shim.
- Não adicionar Playwright/Cypress; o smoke browser usa o Chromium do harness.
- Antes de editar símbolo exportado, consultar referências via LSP; antes de excluir arquivo, repetir busca de importadores.
- Cada checkpoint deve ficar verde antes do grupo seguinte.
## File Map
### Conteúdo e saúde de demo
- Modify: `lib/project-health.ts` — classificar a última observação sem filtrar conteúdo.
- Modify: `lib/project-health.test.ts` — contrato de disponibilidade.
- Modify: `lib/portfolio-data.ts` — projetar todo featured com estado de demo e limitar refresh a desenvolvimento.
- Modify: `lib/portfolio-data.test.ts` — cards persistem em estados de saúde distintos.
- Modify: `lib/portfolio-data.load.test.ts` — produção sem rede; desenvolvimento atualiza sem esvaziar.
- Modify: `app/page.tsx` — usar o novo loader e remover ISR de monitoramento.
- Modify: `app/components/Projects.tsx` — CTA disponível, desconhecido e indisponível.
- Create: `app/components/Projects.test.tsx` — comportamento acessível do CTA.
### Shell, movimento e performance
- Modify: `app/layout.tsx` — remover wrappers globais; manter metadata/JSON-LD intactos.
- Modify: `app/components/cinematic/CinematicOverlays.tsx` — manter somente overlays leves da home.
- Delete: `app/components/cinematic/PageIntro.tsx`, `app/components/cinematic/PageIntro.test.tsx`.
- Delete: `app/providers/SmoothScrollProvider.tsx`.
- Delete: `app/providers/AnimationProvider.tsx`, `app/providers/AnimationProvider.test.tsx`.
- Delete: `app/lib/animations/config.ts`, `app/lib/animations/variants.ts`.
- Delete: `app/components/RouteTransition.tsx`.
- Modify: `app/globals.css` — camada base de links, reduced motion, grain/marquee/divisor CSS e tokens consolidados.
- Modify: `app/components/cinematic/CinematicHome.tsx` — retirar diretores/wrappers redundantes.
- Modify: `app/components/cinematic/CinematicHome.test.tsx` — testar ordem de conteúdo, não mocks removidos.
- Delete: `app/components/cinematic/CinematicSection.tsx`.
- Delete: `app/components/story/ReelDirector.tsx`.
- Delete: `app/hooks/useScrollReveal.ts`.
- Rewrite: `app/components/cinematic/FilmGrain.tsx` — elemento decorativo sem canvas/RAF.
- Rewrite: `app/components/cinematic/ScrollVelocityText.tsx` — marquee CSS sem hooks por frame.
- Rewrite: `app/components/cinematic/SectionDivider.tsx` — DOM/SVG estático e CSS.
- Delete: `app/components/cinematic/CustomCursor.tsx`.
- Modify: `app/components/cinematic/ControlRoomGlobe.tsx` — interseção e visibilidade separadas.
- Modify: `app/components/story/NotebookStory.tsx` — stage pausado em aba oculta.
- Modify: `app/components/story/NotebookStory.test.tsx` — transição de visibilidade.
- Modify: `app/components/Hero.tsx` — única variante ativa.
- Modify: `app/components/Hero.test.tsx`, `app/components/Hero.reducedMotion.test.tsx` — somente comportamento observável.
- Modify: `app/components/cinematic/HeroScene.tsx` — remover variante ignorada.
### Código morto e testes sem valor
- Delete: `app/components/ContactForm.tsx`, `app/components/ContactInfo.tsx`, `lib/usePortfolioData.ts`.
- Delete: `app/components/CompanyLogos.tsx` e seus três testes.
- Delete: `components/DotGrid.tsx`, `components/TextType.tsx`, `components/FaultyTerminal.tsx`, `components/Squares.tsx`, `components/LogoLoop.tsx`, `components/RotatingText.tsx`.
- Delete: todos os nove arquivos em `components/magicui/` e `components.json`.
- Delete: `app/components/cinematic/FloatingElements.tsx`, `HomeButton.tsx`, `HorizontalScroll.tsx`, `CinematicText.tsx`.
- Delete: os três PBTs do Footer e os três PBTs de Experience.
- Create: `app/components/Experience.test.tsx` — filtro featured e ordem.
- Modify: `app/components/Footer.test.tsx` — remover asserção de classes internas.
### Dependências, segurança, CI e documentação
- Modify: `package.json`, `pnpm-lock.yaml`, `tailwind.config.js`, `nixpacks.toml`, `jest.setup.ts`.
- Create: `.github/workflows/ci.yml`.
- Modify: `app/components/GoogleAnalytics.tsx`.
- Create: `app/components/GoogleAnalytics.test.tsx`.
- Modify: `next.config.ts`.
- Modify: `README.md`.
- Move/replace: `docs/project-health-cron.md` → `docs/project-health.md`.
## Sequência e dependências
```text
1 saúde pura
└─ 2 loader
└─ 3 apresentação de projetos
4 shell/foco
└─ 5 home sem GSAP redundante
└─ 6 efeitos sem loops
└─ 7 pause de renderers
8 hero única
9 código morto e testes
└─ 10 dependências/lock/deploy
└─ 11 analytics/headers
└─ 12 CI/docs/verificação final
```
## Tabela de conflitos de arquivos e interfaces
| Tarefas | Arquivo/interface compartilhada | Tipo de conflito | Regra de serialização |
| --- | --- | --- | --- |
| 1, 2 | `ProjectHealthSnapshot`, classificação de demo | Tipo e semântica | Concluir 1 antes de alterar loader |
| 2, 3 | `PortfolioProjectView`, `loadFeaturedProjects()` | Producer/consumer | Fixar assinatura em 2; migrar UI em 3 |
| 3, 5 | props `projects` de `CinematicHome`/`NotebookStory` | Tipo por interseção | Provar compatibilidade em 3 antes de simplificar home |
| 4, 6, 11 | `app/layout.tsx`, `CinematicOverlays` | Shell e ordem de scripts | Executar 4 → 6 → 11; reler antes de cada edição |
| 4, 6 | `app/globals.css` | Base, reduced motion e efeitos | Aplicar base/reduced em 4; efeitos/tokens em 6 |
| 5, 6 | `CinematicHome.tsx` | Composição e componentes substituídos | Remover wrappers em 5; trocar efeitos em 6 |
| 6, 7 | política de loops/visibilidade | Contrato de execução | Eliminar loops evitáveis antes de instrumentar os restantes |
| 4, 8 | `Hero` sob shell sem wrappers | Hidratação e teste visual | Estabilizar shell antes de podar hero |
| 5, 9, 10 | consumidores de GSAP/dependências | Grafo de imports | Apagar consumidores em 5/9; remover pacotes somente em 10 |
| 9, 10 | `fast-check`, CVA, OGL, Lenis e plugins Tailwind | Último consumidor | Confirmar zero importadores em 9; podar manifesto em 10 |
| 10, 11 | `package.json`, lock, `next.config.ts` | Next patch e build | Congelar toolchain antes de validar analytics/headers |
| 4, 11 | `app/layout.tsx` e `GoogleAnalytics` | Montagem global | Layout limpo em 4; consentimento em 11 sem reintroduzir wrapper |
| 10, 12 | scripts, lock e CI | Comandos contratuais | Workflow somente após comandos locais verdes |
| 2, 12 | docs de project health | Semântica pública | Documentar apenas o comportamento já comprovado |
---
### Task 1: Trocar filtro de projetos por classificação de demo
**Files:**
- Modify: `lib/project-health.ts`
- Modify: `lib/project-health.test.ts`
**Interfaces:**
- Consumes: `ProjectHealthCheck | undefined`.
- Produces:
```ts
export type DemoAvailability = "available" | "unavailable" | "unknown" | "none";
export function getDemoAvailability(
demoUrl: string | null | undefined,
check: ProjectHealthCheck | undefined,
): DemoAvailability;
```
- [ ] **Step 1: Substituir testes de filtragem por testes de classificação**
Criar casos explícitos no teste existente:
```ts
expect(getDemoAvailability(null, undefined)).toBe("none");
expect(getDemoAvailability("https://demo.example", undefined)).toBe("unknown");
expect(getDemoAvailability("https://demo.example", snapshot.projects.online)).toBe("available");
expect(getDemoAvailability("https://demo.example", snapshot.projects.redirect)).toBe("unavailable");
expect(getDemoAvailability("https://demo.example", snapshot.projects.stale)).toBe("available");
```
A última expectativa registra que expiração governa refresh em desenvolvimento, não a existência/estado da última observação em produção.
- [ ] **Step 2: Executar o teste vermelho**
Run:
```bash
pnpm exec jest lib/project-health.test.ts --runInBand
```
Expected: falha porque `getDemoAvailability` ainda não existe ou o filtro antigo retorna lista vazia.
- [ ] **Step 3: Implementar a função pura e remover `filterLiveProjects`/`isCurrentLiveCheck`**
Implementação mínima:
```ts
export function getDemoAvailability(
demoUrl: string | null | undefined,
check: ProjectHealthCheck | undefined,
): DemoAvailability {
if (!demoUrl) return "none";
if (!check) return "unknown";
return check.status === 200 &&
check.contentType?.toLowerCase().startsWith("text/html")
? "available"
: "unavailable";
}
```
Manter `PROJECT_HEALTH_MAX_AGE_MS` e `createProjectHealthSnapshot`; expor um helper de frescor somente para o loader de desenvolvimento.
- [ ] **Step 4: Confirmar o contrato verde**
Run o mesmo Jest. Expected: `PASS`; nenhuma chamada de rede neste teste.
---
### Task 2: Fazer o loader sempre retornar projetos editoriais
**Files:**
- Modify: `lib/portfolio-data.ts`
- Modify: `lib/portfolio-data.test.ts`
- Modify: `lib/portfolio-data.load.test.ts`
- Modify: `app/page.tsx`
**Interfaces:**
- Consumes: `getDemoAvailability`, `ProjectHealthSnapshot`, `PROJECT_HEALTH_MAX_AGE_MS`.
- Produces:
```ts
export type PortfolioProjectView = PortfolioProject & {
demoAvailability: DemoAvailability;
demoCheckedAt: string | null;
};
export function getFeaturedProjects(
snapshot: ProjectHealthSnapshot | null,
): PortfolioProjectView[];
export async function loadFeaturedProjects(): Promise<PortfolioProjectView[]>;
```
- [ ] **Step 1: Atualizar o teste puro**
Fixture deve provar três invariantes em uma única lista: featured permanece, `featured: false` sai, e estado de demo não filtra card.
```ts
const result = getFeaturedProjects(snapshot);
expect(result.map(({ id }) => id)).toEqual(
portfolio.projects.filter(({ featured }) => featured !== false).map(({ id }) => id),
);
expect(result.find(({ id }) => id === "sev-modaintima")?.demoAvailability).toBe("available");
expect(result.find(({ id }) => id === "twins-lab")?.demoAvailability).toBe("unknown");
```
- [ ] **Step 2: Atualizar o teste do loader sem apagar o baseline do usuário**
Produção:
```ts
await expect(loadFeaturedProjects()).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ id: "sev-modaintima" })]),
);
expect(createProjectHealthSnapshot).not.toHaveBeenCalled();
```
Desenvolvimento com snapshot antigo:
```ts
expect(createProjectHealthSnapshot).toHaveBeenCalledTimes(1);
expect(result).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "sev-modaintima", demoAvailability: "available" }),
]));
```
Adicionar falha de refresh: a promise rejeitada não rejeita o loader nem esvazia a lista.
- [ ] **Step 3: Rodar ambos os testes e observar a quebra do contrato antigo**
```bash
pnpm exec jest lib/project-health.test.ts lib/portfolio-data.test.ts lib/portfolio-data.load.test.ts --runInBand
```
Expected: falha nas expectativas “sempre retorna featured”.
- [ ] **Step 4: Implementar projeção e loader**
`getFeaturedProjects` faz um único `filter(featured !== false)` e um `map`; `loadFeaturedProjects` lê snapshot uma vez. Em produção retorna a projeção imediatamente. Em desenvolvimento, atualiza somente se snapshot ausente/antigo, envolve o refresh em `try/catch` e usa snapshot anterior em falha.
- [ ] **Step 5: Migrar a home**
Em `app/page.tsx`:
```tsx
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
export default async function Home() {
return (
<CinematicHome
projects={await loadFeaturedProjects()}
experiences={portfolio.experiences}
skillCategories={portfolio.skills.technical}
/>
);
}
```
Remover `revalidate = 300`; não adicionar outro cron/cache para saúde.
- [ ] **Step 6: Checkpoint do loader**
Run o comando do passo 3. Expected: todos passam; mock de fetch não é chamado em produção; rejeição dev ainda retorna cards.
---
### Task 3: Apresentar disponibilidade sem remover evidência
**Files:**
- Modify: `app/components/Projects.tsx`
- Create: `app/components/Projects.test.tsx`
- Modify: `app/components/story/notebookProjects.ts`
- Modify: `app/components/story/notebookProjects.test.ts`
**Interfaces:**
- Consumes: `PortfolioProjectView[]`.
- Produces: card sempre visível e CTA de demo acessível por estado.
- [ ] **Step 1: Escrever testes de UI por papel e nome acessível**
Cobrir três cards:
```tsx
expect(screen.getByRole("heading", { name: "Demo disponível" })).toBeVisible();
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
"href",
"https://available.example",
);
expect(screen.getByText("Demo indisponível na última verificação")).toHaveAttribute(
"aria-disabled",
"true",
);
expect(screen.getByRole("link", { name: /Abrir projeto.*disponibilidade não verificada/i }))
.toHaveAttribute("href", "https://unknown.example");
```
O teste não inspeciona classes, props de motion ou contagem de wrappers.
- [ ] **Step 2: Rodar teste vermelho**
```bash
pnpm exec jest app/components/Projects.test.tsx --runInBand
```
Expected: falha porque `Projects` ainda trata qualquer `demoUrl` como navegável e não anota estado.
- [ ] **Step 3: Implementar CTA por estado**
- `available`: link atual.
- `unavailable`: `span` visualmente equivalente, `aria-disabled="true"`, sem `href`, `target` ou click.
- `unknown`: link configurado, com texto auxiliar acessível “Disponibilidade não verificada”.
- `none`: sem CTA.
Não esconder `article`, título, descrição, stack, GitHub ou status.
- [ ] **Step 4: Adaptar o seletor do notebook sem torná-lo dependente de saúde**
`selectNotebookProjects` continua usando screenshot local + `demoUrl`; aceita `PortfolioProjectView[]` estruturalmente e não filtra por `demoAvailability`.
- [ ] **Step 5: Checkpoint da apresentação**
```bash
pnpm exec jest app/components/Projects.test.tsx app/components/story/notebookProjects.test.ts lib/portfolio-data.test.ts --runInBand
```
Expected: todos passam e o fixture indisponível continua presente.
---
### Task 4: Remover bloqueadores globais e corrigir foco/reduced motion
**Files:**
- Modify: `app/layout.tsx`
- Modify: `app/components/cinematic/CinematicOverlays.tsx`
- Modify: `app/globals.css`
- Delete: `app/components/cinematic/PageIntro.tsx`
- Delete: `app/components/cinematic/PageIntro.test.tsx`
- Delete: `app/providers/SmoothScrollProvider.tsx`
- Delete: `app/providers/AnimationProvider.tsx`
- Delete: `app/providers/AnimationProvider.test.tsx`
- Delete: `app/lib/animations/config.ts`
- Delete: `app/lib/animations/variants.ts`
- Delete: `app/components/RouteTransition.tsx`
**Interfaces:**
- Produces: layout server-first sem splash, Lenis, contexto ou transição global.
- [ ] **Step 1: Repetir referências e remover `PageIntro` do overlay**
`CinematicOverlays` não importa nem renderiza o portal. Excluir componente e teste no mesmo passo.
- [ ] **Step 2: Achatar o body do layout**
Preservar JSON-LD, skip link, Header, overlays e analytics na ordem abaixo:
```tsx
<body>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<a className="skip-link" href="#main-content">Pular para o conteúdo</a>
<Header />
<CinematicOverlays />
<div id="main-content" tabIndex={-1}>{children}</div>
<GoogleAnalytics />
</body>
```
Não tocar no objeto `metadata` nem no grafo JSON-LD.
- [ ] **Step 3: Excluir infraestrutura sem consumidores**
Apagar os providers, config/variants e `RouteTransition`; repetir busca pelos nomes e exigir zero resultado em código vivo.
- [ ] **Step 4: Corrigir cascata de links**
Mover `a { color: inherit; }` para dentro de `@layer base`, junto do reset, para as utilities da 404 prevalecerem.
- [ ] **Step 5: Adicionar fallback reduced motion global**
```css
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
```
Não usar `display: none` para conteúdo.
- [ ] **Step 6: Checkpoint de shell no browser**
Iniciar a app pelo processo supervisionado, abrir `/`, focar o primeiro controle com `Tab` e confirmar que o skip link aparece imediatamente e move foco para `#main-content`. Abrir uma rota inexistente e confirmar visualmente: CTA primário branco sobre azul; CTA secundário slate e hover branco. Emular reduced motion e confirmar conteúdo completo sem splash/smooth scroll. Console sem erro de provider/hidratação.
---
### Task 5: Retirar GSAP redundante da home
**Files:**
- Modify: `app/components/cinematic/CinematicHome.tsx`
- Modify: `app/components/cinematic/CinematicHome.test.tsx`
- Delete: `app/components/cinematic/CinematicSection.tsx`
- Delete: `app/components/story/ReelDirector.tsx`
- Delete: `app/hooks/useScrollReveal.ts`
**Interfaces:**
- Produces: mesma ordem sem wrapper de revelação ou scanner global.
- [ ] **Step 1: Atualizar teste de integração**
Manter somente o contrato útil: exatamente uma história de notebook e `About` depois dela. Remover mocks/asserções de `ReelDirector` e `CinematicSection`.
- [ ] **Step 2: Simplificar composição**
Remover `ReelDirector` e renderizar `About`, `Skills`, `Experience`, `Projects`, `LighthouseMetrics`, `WorkModalities` e `Contact` diretamente. Os próprios componentes mantêm Motion/reduced motion.
- [ ] **Step 3: Excluir os três módulos sem consumidores**
Apagar `CinematicSection`, `ReelDirector` e `useScrollReveal`. GSAP deve permanecer importado somente por `NotebookStory` e nenhum caminho morto.
- [ ] **Step 4: Checkpoint de ordem**
```bash
pnpm exec jest app/components/cinematic/CinematicHome.test.tsx app/components/story/NotebookStory.test.tsx --runInBand
```
Expected: `PASS`; home mantém hero → notebook → about → demais seções.
---
### Task 6: Trocar efeitos contínuos evitáveis por CSS/SVG
**Files:**
- Rewrite: `app/components/cinematic/FilmGrain.tsx`
- Rewrite: `app/components/cinematic/ScrollVelocityText.tsx`
- Rewrite: `app/components/cinematic/SectionDivider.tsx`
- Modify: `app/components/cinematic/CinematicOverlays.tsx`
- Modify: `app/components/cinematic/CinematicHome.tsx`
- Modify: `app/globals.css`
- Delete: `app/components/cinematic/CustomCursor.tsx`
**Interfaces:**
- `ScrollVelocityText` passa a consumir `direction?: "left" | "right"` no lugar de `baseVelocity`.
- `SectionDivider` mantém `variant` para não espalhar branches no caller.
- [ ] **Step 1: Remover cursor customizado**
Excluir import/render dinâmico e o arquivo. O cursor nativo volta a operar; retirar qualquer regra global `cursor: none` junto com o componente.
- [ ] **Step 2: Fazer grain sem cliente/RAF**
`FilmGrain` retorna somente:
```tsx
export default function FilmGrain() {
return <div aria-hidden="true" className="film-grain" />;
}
```
CSS usa textura estática/repeating gradients, `pointer-events: none`, posição fixa e a opacidade atual. Reduced motion/coarse pointer pode ocultar apenas esse ornamento.
- [ ] **Step 3: Fazer marquee em CSS**
Remover `useAnimationFrame`, `useScroll`, springs, resize state e Motion. Renderizar cópias suficientes em dois trilhos sem estado; `direction` seleciona `animate-marquee` ou `animate-marquee-reverse`. Migrar três callers de `baseVelocity` para direção explícita.
- [ ] **Step 4: Fazer divisor sem canvas**
- `gradient`: duas linhas CSS; animação existente.
- `pulse`: cinco pontos CSS.
- `waveform`: SVG estático com três paths, `aria-hidden="true"`.
Nenhum branch chama `requestAnimationFrame`.
- [ ] **Step 5: Consolidar tokens tocados**
Usar `--slash: #ccff00`, `--ink` e `--paper` como fonte única nas novas regras; remover apenas keyframes/classes que ficaram sem consumidores. Não reformatar o stylesheet nem copiar o arquivo da raiz.
- [ ] **Step 6: Checkpoint visual/performance**
No browser, verificar desktop e mobile: marquee legível, divisores presentes, grain sutil, cursor nativo, nenhum layout shift. Com Performance/console, confirmar ausência de RAF originado em `FilmGrain`, `ScrollVelocityText` e `SectionDivider`.
---
### Task 7: Pausar renderers restantes fora de uso
**Files:**
- Modify: `app/components/story/NotebookStory.tsx`
- Modify: `app/components/story/NotebookStory.test.tsx`
- Modify: `app/components/cinematic/ControlRoomGlobe.tsx`
**Interfaces:**
- Produces: `isVisible = sectionIntersects && !document.hidden` para o Canvas do notebook.
- Globo mantém `intersectsRef` separado de `document.hidden`.
- [ ] **Step 1: Escrever teste de Page Visibility para notebook**
Capturar a prop `isVisible` do mock dinâmico. Após `Object.defineProperty(document, "hidden", { value: true, configurable: true })` e `visibilitychange`, esperar `false`; ao voltar a `false`, o valor depende novamente da interseção.
- [ ] **Step 2: Implementar estado combinado no notebook**
Não duplicar timeline. Adicionar listener `visibilitychange`, limpar no unmount e passar a conjunção ao `NotebookStage3D`.
- [ ] **Step 3: Corrigir o globo**
O callback do Intersection Observer altera apenas `intersectsRef`. O `onRender` retorna cedo quando `!intersectsRef.current || document.hidden`; `visibilitychange` não sobrescreve estado de interseção.
- [ ] **Step 4: Checkpoint de pausa**
```bash
pnpm exec jest app/components/story/NotebookStory.test.tsx app/components/story/NotebookStage3D.test.tsx --runInBand
```
No browser, rolar notebook/globo para fora, ocultar/mostrar aba e verificar que não há salto nem erro WebGL ao retornar.
---
### Task 8: Reduzir Hero à variante ativa
**Files:**
- Modify: `app/components/Hero.tsx`
- Modify: `app/components/Hero.test.tsx`
- Modify: `app/components/Hero.reducedMotion.test.tsx`
- Modify: `app/components/cinematic/HeroScene.tsx`
**Interfaces:**
- Produces: Hero sem prop/seleção de variante; conteúdo command-center é constante.
- [ ] **Step 1: Ajustar testes para o contrato visível**
Remover spy de `Math.random`, mock/asserção de `variant` e inspeção `data-initial`/`data-animate`. Manter heading, CTA WhatsApp, link de cases, CV, links sociais e ausência de 3D em reduced motion.
- [ ] **Step 2: Podar o modelo de variantes**
Trocar `HERO_VARIANTS[0]` por `HERO_CONTENT` sem `weight`, `scene` e `layout`. Remover cinco objetos inalcançáveis, `LAYOUT_CLASSES`, layouts alternativos e todos os branches de `SideVisual`; manter diretamente starfield, globo e telemetria.
- [ ] **Step 3: Simplificar `HeroScene`**
Remover união `HeroSceneVariant`, prop `variant` e `void variant`; caller passa somente `isVisible`.
- [ ] **Step 4: Checkpoint da hero**
```bash
pnpm exec jest app/components/Hero.test.tsx app/components/Hero.reducedMotion.test.tsx --runInBand
```
Expected: conteúdo e ações passam; nenhum teste depende de branch morto.
---
### Task 9: Excluir código morto e podar testes de implementação
**Files:**
- Delete: grupos listados em “Código morto e testes sem valor”.
- Create: `app/components/Experience.test.tsx`.
- Modify: `app/components/Footer.test.tsx`.
**Interfaces:**
- Consumes: `Contact` continua como formulário ativo; `Footer` e `Experience` mantêm APIs atuais.
- Produces: zero importadores órfãos e testes comportamentais pequenos.
- [ ] **Step 1: Excluir caminho antigo de contato**
Confirmar que somente os próprios arquivos referenciam `ContactForm`, `ContactInfo` e `usePortfolioData`; apagar os três. Não alterar `Contact.tsx`.
- [ ] **Step 2: Excluir CompanyLogos e testes**
Apagar componente, `CompanyLogos.test.tsx`, `CompanyLogos.altText.pbt.test.tsx` e `CompanyLogos.reducedMotion.pbt.test.tsx`. Não apagar logos genéricos usados por `Logo`.
- [ ] **Step 3: Excluir biblioteca demo/magicui**
Apagar os seis componentes da raiz de `components/`, os nove de `components/magicui/` e `components.json`. Confirmar que `@/lib/utils` continua consumido por `ControlRoomGlobe` e `TelemetryStrip`.
- [ ] **Step 4: Excluir cinemáticos sem importadores**
Apagar `FloatingElements`, `HomeButton`, `HorizontalScroll` e `CinematicText`.
- [ ] **Step 5: Substituir PBTs por dois testes de Experience**
Excluir os três `Experience.*.pbt.test.tsx`. Novo teste usa fixture fixa e verifica:
```ts
expect(screen.queryByRole("heading", { name: "Oculta" })).not.toBeInTheDocument();
expect(screen.getAllByRole("article").map((article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
)).toEqual(["Mais recente", "Mais antiga"]);
```
- [ ] **Step 6: Remover PBTs redundantes do Footer**
Excluir os três arquivos `Footer.*.pbt.test.tsx`. Em `Footer.test.tsx`, manter contato, navegação, nomes acessíveis, `target`/`rel` e ano; remover somente teste que fixa `.ds-grid-2`, quantidade de cards e classes.
- [ ] **Step 7: Checkpoint de corte**
```bash
pnpm exec tsc --noEmit
pnpm exec jest app/components/Experience.test.tsx app/components/Footer.test.tsx --runInBand
```
Expected: TypeScript sem import quebrado; testes comportamentais passam.
---
### Task 10: Fixar dependências, runtime e deploy
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Modify: `tailwind.config.js`
- Modify: `nixpacks.toml`
- Modify: `jest.setup.ts`
**Interfaces:**
- Produces: Node `22.x`, pnpm `9.15.9`, Next `16.3.0`, scripts `typecheck` e versões exatas.
- [ ] **Step 1: Fixar manifesto**
Adicionar:
```json
"engines": { "node": "22.x" },
"packageManager": "pnpm@9.15.9"
```
Adicionar script:
```json
"typecheck": "tsc --noEmit"
```
Fixar às versões resolvidas do spec; mover `@types/three` para dev e `@types/node` para `22.20.1`.
- [ ] **Step 2: Remover dependências sem consumidores**
Remover do manifesto: `@gsap/react`, `class-variance-authority`, `lenis`, `ogl`, `tailwindcss-animate`, `tw-animate-css`, `fast-check`, `@types/resize-observer-browser`, `resize-observer-polyfill`.
Manter `@react-three/drei`, `@react-three/fiber`, `three`, `cobe`, `gsap`, `motion`, `clsx`, `tailwind-merge`, Jest e Testing Library.
- [ ] **Step 3: Retirar plugin e mock órfãos**
Em `tailwind.config.js`, deixar `plugins: []`. Em `jest.setup.ts`, remover import de `./__mocks__/resizeObserver` somente se o mock nativo definido no arquivo cobre todos os testes; apagar `__mocks__/resizeObserver.js` se ficar sem importador.
- [ ] **Step 4: Regenerar lock com a versão fixada**
Ativar pnpm 9.15.9 via Corepack e executar:
```bash
corepack prepare pnpm@9.15.9 --activate
pnpm install --lockfile-only
pnpm install --frozen-lockfile
```
Expected: `pnpm-lock.yaml` sem seção `overrides`; Next resolve `16.3.0`; manifesto e lock concordam.
- [ ] **Step 5: Corrigir cadeias auditadas sem force/override**
Executar as auditorias separadas. Se uma cadeia direta ainda resolver versão vulnerável, atualizar o pacote direto que a introduz para a menor versão compatível corrigida, fixar essa versão e repetir os três comandos do passo 4.
```bash
pnpm audit --prod --audit-level=high
pnpm audit --dev --audit-level=high
```
Expected: ambos código zero. É proibido usar `--force`, ignore ou override para obter verde.
- [ ] **Step 6: Congelar Nixpacks**
Manter `nodejs_22` e `pnpm-9_x`; trocar somente install por:
```toml
[phases.install]
cmds = ["pnpm install --frozen-lockfile"]
```
- [ ] **Step 7: Checkpoint de toolchain**
```bash
pnpm install --frozen-lockfile
pnpm typecheck
pnpm lint
```
Expected: instalação não altera lock, TypeScript e Biome encerram em zero. Corrigir diagnósticos nos arquivos responsáveis; não alterar `biome.json` para desligar regras.
---
### Task 11: Implementar consentimento e corrigir headers/cache
**Files:**
- Modify: `app/components/GoogleAnalytics.tsx`
- Create: `app/components/GoogleAnalytics.test.tsx`
- Modify: `next.config.ts`
**Interfaces:**
- Storage key: `evandro.analytics-consent.v1`.
- Values persistidos: `granted | denied`.
- GA ID permanece `G-QTT9QT0T3S`.
- [ ] **Step 1: Escrever testes de consentimento**
Testes devem provar:
```ts
expect(screen.queryByTestId("google-analytics-script")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aceitar analytics" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
expect(localStorage.getItem("evandro.analytics-consent.v1")).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
"src",
"https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S",
);
```
Adicionar recusa sem script e revogação que grava `denied`, define `ga-disable-G-QTT9QT0T3S` e expira cookies `_ga`/`_ga_*`.
- [ ] **Step 2: Rodar teste vermelho**
```bash
pnpm exec jest app/components/GoogleAnalytics.test.tsx --runInBand
```
Expected: falha porque loader atual monta GA sem decisão e não oferece controles.
- [ ] **Step 3: Implementar UI e loader no arquivo existente**
Inicializar estado após mount lendo somente a chave versionada. Sem decisão, renderizar um diálogo não modal acessível com descrição curta e aceitar/recusar. Após escolha, manter botão de preferências. Montar `next/script` direto apenas em `granted`; advertising consent fica sempre denied.
- [ ] **Step 4: Implementar revogação completa alcançável**
Ao revogar:
```ts
window["ga-disable-G-QTT9QT0T3S"] = true;
window.gtag?.("consent", "update", {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
});
```
Expirar somente cookies cujos nomes começam com `_ga`, para path `/` e domínio atual quando aplicável. Não apagar storage/cookies alheios.
- [ ] **Step 5: Remover rewrites e cache incorreto**
Excluir `rewrites()` inteiro de `next.config.ts`. Remover headers customizados `immutable` de `/static`, `/fonts`, `/assets`, `/logos`.
- [ ] **Step 6: Ajustar CSP com risco controlado**
Manter `Content-Security-Policy-Report-Only`. Incluir `default-src 'self'` e `form-action 'self'`; construir `script-src` com `unsafe-eval` somente em desenvolvimento. Manter `unsafe-inline` e origens oficiais do Google. Não criar nonce/proxy nesta rodada.
- [ ] **Step 7: Checkpoint automatizado e browser**
```bash
pnpm exec jest app/components/GoogleAnalytics.test.tsx --runInBand
pnpm typecheck
```
No browser de produção local: limpar a chave/cookies; confirmar zero request Google antes de escolher; recusar e recarregar, ainda zero; aceitar e observar um único carregamento direto; revogar e confirmar flag/consent denied e cookies acessíveis removidos. Conferir console sem violações que quebrem a app.
---
### Task 12: Criar CI, atualizar documentação e fechar a iniciativa
**Files:**
- Create: `.github/workflows/ci.yml`
- Modify: `README.md`
- Move/replace: `docs/project-health-cron.md` → `docs/project-health.md`
**Interfaces:**
- CI usa scripts reais de `package.json` e lock final.
- Docs descrevem somente o comportamento verificado nas tarefas 1–11.
- [ ] **Step 1: Criar workflow único**
Conteúdo operacional:
```yaml
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm build
- run: pnpm audit --prod --audit-level=high
- run: pnpm audit --dev --audit-level=high
```
`pnpm/action-setup` lê `packageManager`; não duplicar versão no workflow.
- [ ] **Step 2: Reescrever README com fatos do repositório**
Atualizar badge/stack para Next 16.3.0; remover Lenis, demos apagados e PBTs; registrar Node 22, pnpm 9.15.9, quick start, todos os scripts existentes, App Router, conteúdo estático, notebook 3D progressivo, consentimento, CI e Nixpacks frozen. Não criar seção de resultados comerciais nem promessas de performance não medidas.
- [ ] **Step 3: Substituir documentação de cron**
Remover `docs/project-health-cron.md` e criar `docs/project-health.md` com:
- cards derivados de `featured`, nunca de saúde;
- estados `available`, `unavailable`, `unknown`, `none`;
- produção sem fetch externo durante render;
- atualização dev em memória quando snapshot está antigo;
- `pnpm check:projects` como atualização manual atômica opcional;
- `data/project-health.json` como última observação versionada, não requisito de conteúdo.
Não editar o snapshot ao documentá-lo.
- [ ] **Step 4: Rodar gate completo uma única vez**
```bash
pnpm install --frozen-lockfile
pnpm lint
pnpm typecheck
pnpm test -- --runInBand
pnpm build
pnpm audit --prod --audit-level=high
pnpm audit --dev --audit-level=high
```
Expected: todos encerram com código zero; install não modifica lock; build lista as rotas atuais.
- [ ] **Step 5: Smoke browser final**
Exercitar `/`, `/cases`, `/servicos`, `/curriculo` e uma 404 em desktop e viewport móvel. Confirmar projetos, foco, menu, notebook/fallback, formulário/WhatsApp, reduced motion, consentimento e ausência de erro de console. Não instalar runner de browser.
- [ ] **Step 6: Conferir arquivos protegidos e corte limpo**
Por leitura/comparação com snapshots iniciais, confirmar:
- `data/project-health.json` inalterado;
- `globals.css` da raiz inalterado;
- alterações em `lib/portfolio-data.ts` e seu teste preservam o refresh dev existente enquanto removem fail-closed;
- nenhum import aponta para arquivo excluído;
- nenhum artifact temporário, log, relatório ou cache foi criado no repo.
## Auto-revisão de cobertura do plano
| Requisito aprovado | Cobertura |
| --- | --- |
| Cards sempre visíveis; health não remove; dev atualiza; prod sem cron | Tasks 1–3 e 12 |
| Next 16.3.0, advisories, sem force/overrides, Node 22, pnpm 9, frozen lock | Task 10 |
| Remover PageIntro, corrigir 404, reduced motion, pausar loops | Tasks 4, 6 e 7 |
| Código morto, Hero única, PBTs, deps órfãs, Biome verde | Tasks 5, 8, 9 e 10 |
| Tirar Lenis/GSAP global, reduzir JS/long tasks, tokens/cache | Tasks 4–6, 10 e 11 |
| CI com install/lint/tsc/test/build/audit; sem browser pesado | Task 12 |
| Sem bypass de ad blocker; consentimento/revogação; CSP compatível | Task 11 |
| README/deploy/project-health grounded | Task 12 |
| Preservar identidade e não inventar claims | Restrições globais e smoke final |
| Não integrar SEO externo nesta rodada | Restrições globais e não-objetivo do spec |
| Preservar mudanças do usuário e não usar worktree/commits | Restrições globais, rollback do spec e Task 12 |
A auto-revisão confirmou que todas as interfaces, ordens, comandos, estados e critérios necessários estão definidos. Não há passo de commit. A execução termina somente após o gate completo e o smoke real.
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more