# 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; ``` - [ ] **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 ( ); } ``` 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 Pular para o conteúdo
{children}
``` 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