Pular para o conteúdo principal
Luiz Pansarini
← Voltar ao blog

Construindo este portfolio

· 7 min de leitura

  • #nextjs
  • #tailwind
  • #i18n
  • #mdx
  • #biome
  • #accessibility
Conteúdo
  1. Por que reconstruí
  2. Justificativa de stack
  3. Bilíngue via next-intl
  4. Tematização Jedi/Sith
  5. Reuso da pipeline MDX
  6. A ferramentaria
  7. O que ficou de fora

Por que reconstruí

Todo engenheiro Principal acaba reconstruindo o próprio portfolio em algum momento. Este é o diário arquitetural do meu.

Não é um post sobre "olha que efeitos de hover bonitos." É sobre as decisões técnicas que importaram, as que não importaram e as que não sobreviveram a uma auditoria de contraste. O portfolio mira em dois públicos em paralelo: recrutadores no Brasil e fora dele que dão uma olhada rápida pelo celular, e desenvolvedores que rolam até o código-fonte. As restrições que esses dois públicos impõem — mobile-first, WCAG 2.1 AA, carregamento abaixo de três segundos em conexão lenta — guiaram cada escolha de stack.

O ângulo é "construa com o que você mostra." Mesmo engenheiro, mesma stack, mesmas convenções que defendo no trabalho. O resto do post é sobre o quê e por quê, não sobre quem.

Justificativa de stack

A stack travada é Next.js 16.2, Tailwind v4 e Shadcn/UI. Três motivos concretos, um por parágrafo.

Next.js 16.2 com App Router e React Server Components. É o que entrego na Machinery Partner toda semana. Familiaridade vira feature quando o objetivo é construir um portfolio em duas noites, não aprender uma stack. Turbopack como default no 16 deixa o ciclo de dev na casa dos ~80ms em vez de ~800ms. O React 19 trouxe useActionState e useFormStatus, o que tirou o motivo para usar react-hook-form: a futura camada de formulário de contato cabe inteira em uma Server Action mais um schema Zod. Sem biblioteca de formulário no cliente.

Tailwind v4 com cores OKLCH. A virada do v4 para configuração CSS-first — sem tailwind.config.js, só um bloco @theme no globals.css — tornou a paleta Jedi/Sith natural de expressar como tokens semânticos. OKLCH dá cor perceptualmente uniforme: o vermelho do sabre Sith parar em oklch(54% 0.21 28) em vez de um HSL arbitrário foi uma história de contraste (seção seguinte). O motor de build migrou para Lightning CSS; o efeito colateral é uma compilação cerca de dez vezes mais rápida, o que se acumula nas quatro rodadas da matriz axe-core no CI.

Shadcn em vez de uma biblioteca de componentes. Shadcn não é uma biblioteca. É um catálogo de primitivos sem estilo, no esquema copia-e-cola. Soa retrógrado até a terceira vez que uma biblioteca pré-pronta força um workaround para um bug de contraste no modo Sith — aí ter o código-fonte fica óbvio. A desvantagem é que não existe "upgrade do Shadcn 1.0 para o 2.0." A vantagem é a mesma: não existe upgrade do Shadcn 1.0 para o 2.0.

Bilíngue via next-intl

A história bilíngue tem três camadas: roteamento, requisição e navegação.

Roteamento. localePrefix: 'always' significa que toda URL carrega o prefixo /en ou /pt. É a história de SEO mais limpa — URLs canônicas sem ambiguidade — e o modelo mental mais simples. O cookie de locale chama NEXT_LOCALE, com TTL de um ano, o que sobrevive ao "vou voltar daqui a seis meses" do recrutador.

Requisição. Um arquivo proxy.ts na raiz do repo (renomeado de middleware.ts no Next 16) intercepta / e faz redirect 307 para /en ou /pt baseado no header Accept-Language na primeira visita, e no cookie em todas as visitas seguintes. Navegadores brasileiros mandam pt-BR de forma confiável; navegadores em inglês mandam en-*; o fallback para ambiguidade é en. Sobre 307 versus 308: o next-intl emite 307 (temporário). A escolha pedante-correta seria 308 (permanente), já que a regra de redirect nunca muda — mas o default 307 do next-intl funciona bem na prática.

Navegação. Todo link interno passa por src/lib/i18n/navigation.ts, que re-exporta o <Link> consciente de locale do next-intl. Esse <Link> troca de locale preservando o caminho. Uma regra noRestrictedImports do Biome bloqueia import 'next/link' e import 'next/navigation' em qualquer outro lugar do código, com uma override explícita. A regra pegou duas regressões durante a revisão da Phase 2.

O catálogo são dois JSONs flat: messages/en.json e messages/pt.json. Até o número de namespaces passar de uns 200, dividir por arquivo é overhead. O gate de build é paridade bilíngue total: toda chave em EN precisa existir em PT e vice-versa, validado antes do next build.

Tematização Jedi/Sith

O next-themes cuida da persistência e do bootstrap sem flash. O Tailwind v4 resolve as variáveis via @custom-variant dark (&:where(.dark, .dark *)) no globals.css, substituindo o config darkMode: 'class' do v3 que não existe mais.

A história interessante é a correção de contraste. O primeiro vermelho de sabre Sith parou em oklch(58% 0.21 28) — visualmente icônico em uma tela boa, mas text-primary sobre o fundo do body marcou 3.67:1 na sondagem de contraste do axe-core. WCAG 2.1 AA pede 4.5:1 para texto de corpo. A primeira rodada da matriz já falhou na primeira build verde.

Dois caminhos. Escurecer o tom do sabre, ou mudar o padrão de uso. Fizemos os dois. O primário Sith foi para oklch(54% 0.21 28) no toggle e em botões, onde a direção é bg-primary text-primary-foreground — esse par é AA-clean por construção. Para acentos inline como o link "Ver todos" do FeaturedProjectsTeaser, o padrão virou text-foreground decoration-primary decoration-2 underline-offset-4. Mesmo visual de sabre, foreground AA-clean.

O destaque de sintaxe dual-theme da Phase 3 bateu na mesma parede. O github-light puro falhava em AA nos tokens amarelo-laranja: #e36209 contra #ffffff mede 3.48:1. Troquei os dois temas por github-light-high-contrast e github-dark-high-contrast. Mesmo contrato de variáveis CSS dual-theme; AA-clean. A lição é escolher espaços de cor perceptualmente uniformes, rodar a auditoria em cada variante de tema, e corrigir a superfície, não o modelo.

Reuso da pipeline MDX

A pipeline MDX foi entregue na Phase 3 para os case studies de projetos. Na Phase 4 o blog também precisava dela. Dois caminhos: copiar e colar o loader, ou extrair uma fábrica. Ir de fábrica era o caminho óbvio — os únicos parâmetros do loader eram o diretório de conteúdo, o schema Zod e uma string kind usada em mensagens de erro.

Os wrappers — projects.ts e blog.ts — viraram módulos de seis linhas. A checagem de paridade, a validação Zod e o cache() foram herdados sem modificação.

O renderer é next-mdx-remote@6 com os defaults blockJS: true e blockDangerousJS: true preservados. Block-JS significa que MDX não roda JavaScript arbitrário: só o mapa fechado de mdxComponents (Callout, Note, Warning, Stat, mais um override de pre para o botão de copy-code) é chamável. Autores escrevem Markdown mais essas tags; nada além disso executa. É a postura de segurança para conteúdo escrito por um único humano confiável, mas o princípio escala.

O destaque de sintaxe em build-time via rehype-pretty-code escreve data-theme="github-light-high-contrast github-dark-high-contrast" em todo bloco de código fenced, mais variáveis CSS dual-theme. Um único toggle da classe html.dark troca os dois com custo zero em runtime. O @next/bundle-analyzer confirma zero shiki e zero prism nos chunks do cliente; um gate caseiro pnpm verify:no-highlighter faz grep em .next/static/chunks em todo PR para impedir essa invariante de regredir.

A ferramentaria

Três decisões menores que valem o relato, porque cada uma poupou tempo depois.

Biome em vez de ESLint mais Prettier. Um binário, uma config, mais rápido em toda execução, menos configs para manter sincronizadas. A regra noRestrictedImports substituiu a do ESLint; a intenção semântica é idêntica. Removi o eslint.config.mjs do output do create-next-app e nunca olhei para trás.

Aurebesh como fonte só decorativa, com plano de fallback. A primeira tentativa de baixar FT Aurebesh de um site de distribuição de fontes bateu em um gate de distribuição — a URL devolveu 60KB de HTML de formulário em vez do binário. O fallback, AurekFonts/Aurebesh_Rodian (licença MIT), funcionou e pesa 6.8KB em woff2. Licença e procedência ficam em public/fonts/LICENSE-aurebesh.txt. A lição: ao consumir assets OFL ou MIT, verifique o cabeçalho do binário (file caminho/da/fonte.ttf) antes de assumir que o download deu certo. Um .html disfarçado de .ttf corrompe silenciosamente a OG image gerada via Satori.

Realidade do free tier do Vercel. O planejamento citava "2.500 eventos por mês" para Web Analytics, vindo de dados de treinamento mais antigos. O plano Hobby de 2026 entrega 50.000 eventos por mês de Web Analytics, mais 10.000 pontos de dados por mês de Speed Insights. Os dois são duas a três ordens de magnitude acima do tráfego realista de um portfolio. Lição: verifique preço no momento do planejamento, não da memória do training-cutoff.

O que ficou de fora

Cinco coisas adiadas para a v2, cada uma com motivo.

Um command palette Cmd+K. Bom para power users; recrutadores não usam. Phase 5 se o launch indicar necessidade.

View Transitions no toggle de tema. Chromium e Safari 18+ entregam a API, e usuários com prefers-reduced-motion recebem o fallback estático automático. Phase 5 polish — o toggle já é o momento mais ornado de UX no site.

O domínio próprio pansarini.tech. Comprado, ainda não apontado. A URL *.vercel.app resolve bem para o launch v1 e ainda ganha de graça os headers de preview-noindex automáticos do Vercel. A virada na Phase 5 é uma edição de uma linha em NEXT_PUBLIC_SITE_URL.

RSS. O blog tem um post no launch. RSS faz sentido a partir de três. Adia.

Testes end-to-end comportamentais com Playwright. A matriz axe-core já cobre acessibilidade; o spec de viewport iPhone SE já cobre overflow mobile. Testes comportamentais para o toggle de locale e o toggle de tema têm ROI menor do que postar mais. Phase 5 se o launch revelar lacunas.

O portfolio foi entregue em quatro phases ao longo de uma semana — dezoito ou mais commits no main, todos com CI verde. Construa com o que você mostra.