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

36 KiB
Raw Blame History

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

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:
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:

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:

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:

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:
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.

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:

await expect(loadFeaturedProjects()).resolves.toEqual(
  expect.arrayContaining([expect.objectContaining({ id: "sev-modaintima" })]),
);
expect(createProjectHealthSnapshot).not.toHaveBeenCalled();

Desenvolvimento com snapshot antigo:

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
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:

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:

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
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
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:

<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
@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
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:

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
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
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:

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
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:

"engines": { "node": "22.x" },
"packageManager": "[email protected]"

Adicionar script:

"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:

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.

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:

[phases.install]
cmds = ["pnpm install --frozen-lockfile"]
  • Step 7: Checkpoint de toolchain
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:

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
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:

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
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:

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
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.