Files
portifolio-evandrodevbr/docs/superpowers/plans/2026-09-04-portfolio-hardening.md
T

904 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": "[email protected]"
```
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 [email protected] --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.