11 de junho de 2026

Como construí o eliel.work

Um portfólio de engenheiro full-stack sênior é um objeto estranho. Quem lê sabe ler código, então animação bonita sozinha não impressiona. Mas a página também precisa carregar no celular em menos de dois segundos, ranquear de forma orgânica e convencer um recrutador que não conhece a sua stack de que você leva o ofício a sério. As exigências são altas:

  • Lighthouse no verde em todas as métricas no mobile com throttle, e de forma honesta, medido no PageSpeed e não apenas no meu computador.
  • Animação que remeta ao cuidado da Apple sem virar um parque de diversões.
  • Tipografia editorial que sustente a voz da página, em vez de uma pilha de utilitários fingindo ter personalidade.
  • Suporte a dois idiomas (inglês como padrão e português como espelho) sem dobrar o custo de manutenção.

A seguir, como o site que você está lendo ficou pronto, incluindo os erros que cometi no caminho.

A stack

Sou usuário diário de Next.js, mas não foi ele que escolhi aqui.

Um portfólio é mais ou menos 80% de conteúdo estático e 20% de animação. O Next.js carrega o runtime do React em cada página, mesmo quando ela é estruturalmente estática, o que custa de 50 a 80 KB de JavaScript para hidratar componentes que nem se movem. Em um perfil de celular com throttle, essa diferença separa uma nota 100 de uma 92 no desempenho do Lighthouse. O Astro gera HTML no momento do build e só hidrata as ilhas que você declara.

A escolha, então, foi o Astro 5 com output: 'static', ilhas de React para o GSAP, coleções de conteúdo em MDX para os estudos de caso e para este blog, e Tailwind v4 com um bloco @theme que define todos os tokens a partir do arquivo DESIGN.md. O deploy fica no Cloudflare Pages, totalmente estático, sem necessidade de runtime na borda nesta primeira versão.

O custo dessa decisão é real: o Astro não me dá o mesmo modelo mental do Next.js que afiei ao longo de anos. Mas prefiro pagar esse preço uma vez a arcar com o peso no desempenho a cada carregamento de página.

Fraunces e Geist no lugar de Domaine e ABC Favorit

O design é inspirado de forma consciente na Resend: fundo preto, títulos editoriais em serifa de tamanho generoso, brilhos atmosféricos em cores de baixa opacidade e nenhuma sombra. A Resend usa as fontes Domaine Display e ABC Favorit, ambas proprietárias e caras.

Para um portfólio pessoal, pagar de 200 a 600 dólares por estilo de fonte é exagero. Estas foram as substituições:

  • A Fraunces (variável, com optical sizing) substitui a Domaine Display nos títulos de 76 a 96 px. Com os recursos ss01 e liga ativados, é a serifa gratuita mais próxima em personalidade.
  • A Geist substitui a ABC Favorit no corpo de texto. Tem menos personalidade que a original em 16 px, o que compenso com um espaçamento entre letras um pouco mais apertado.
  • A Inter cuida dos rótulos de interface e a Geist Mono, do código.

As quatro são hospedadas no próprio site, via fontsource. A Fraunces variável é pré-carregada para o topo da página, e as demais carregam com font-display: swap.

Um detalhe me custou uma hora de depuração: o fontsource declara a família como 'Geist Sans', e não 'Geist'. Se você erra o nome, o navegador recai para a fonte do sistema sem reclamar.

GSAP dentro de ilhas de React

O mapa de animação tem quatro momentos pontuais, não um desfile:

  1. O título do topo. O SplitText do GSAP quebra o <h1> em palavras e as revela com um leve atraso entre elas, saindo de um desfoque e subindo para a posição final.
  2. A prévia dos trabalhos. O ScrollTrigger prende a seção pela altura de uma tela enquanto três cartões de estudo de caso aparecem em sequência.
  3. A faixa de números da FortCred. Os contadores animam de zero até o valor final em 1,6 segundo. Números acima de mil são exibidos com separador de milhar de acordo com o idioma.
  4. As transições entre páginas. O <ClientRouter /> nativo do Astro usa a View Transitions API do navegador. Os títulos dos cartões carregam um view-transition-name, de modo que o título se transforma suavemente ao navegar da listagem para a página dedicada.

Cada animação vive em um componente React que consome o GSAP pelo hook useGSAP, do pacote @gsap/react. O hook roda a animação dentro de um gsap.context() e faz a limpeza automaticamente quando o componente é desmontado, o que importa porque as transições de página do Astro destroem os componentes durante a navegação.

Importo essas ilhas na página .astro correspondente com client:visible, então o código da faixa de números só é baixado quando o usuário rola até ela. O topo da página, que aparece de imediato, usa client:load, já que não faz sentido esperar a verificação de visibilidade quando a seção é a primeira coisa na tela.

Um detalhe menos óbvio: a preferência prefers-reduced-motion: reduce desliga todas as ilhas. O conteúdo estático já está no estado visual final, então quem prefere menos movimento vê tudo na hora, sem nenhuma animação rodando.

A seta do botão que todo mundo copia

Os botões do site fazem aquele movimento visto em tantas landing pages: a seta aparece ao passar o mouse, deslizando para dentro enquanto o texto se ajusta um pouco. A implementação mais comum anima a largura com overflow-hidden, o que trava no Safari. A versão mais limpa usa colunas de grid do CSS:

<button class="group inline-flex items-center">
  <span class="transition-transform group-hover:-translate-x-0.5">
    {label}
  </span>
  <span class="grid grid-cols-[0fr] group-hover:grid-cols-[1fr]
               transition-[grid-template-columns] duration-200">
    <span class="overflow-hidden">
      <ArrowRight class="ml-1.5 -translate-x-1.5 opacity-0
                         group-hover:translate-x-0 group-hover:opacity-100
                         transition-all duration-200" />
    </span>
  </span>
</button>

A coluna da seta anima de 0fr para 1fr, ou seja, a largura natural do conteúdo, sem nenhum valor fixo. O ícone faz um fade combinado com deslocamento, e o texto recebe um leve empurrão para a esquerda como ênfase. São 200 milissegundos com ease-out e nenhuma trava.

Performance: a tela de abertura que custou dois segundos

A primeira medição real do Lighthouse, feita no site publicado e não no meu computador, voltou com 82 no mobile. O bloqueio de thread e o deslocamento de layout já estavam zerados, porque o JavaScript é leve e nada se move durante o carregamento. O problema estava todo na renderização, com a maior pintura de conteúdo (o LCP) levando 3,6 segundos.

A causa era, com alguma ironia, o elemento de que eu mais gostava: a tela de abertura com a marca. O texto “eliel.work” surge com uma animação no primeiro carregamento e, da forma como eu havia construído, a parte “.work” começava invisível e só aparecia depois que o React hidratava o componente. O Lighthouse apontou justamente esse elemento como o LCP da página, e ele ficava oculto por cerca de dois segundos, à espera da hidratação. Na prática, eu havia transformado a minha própria marca no elemento mais lento do site.

A correção foi simples a ponto de ser constrangedora: entregar o texto completo já visível no HTML inicial. A animação de saída continua igual, ela apenas deixou de esconder o conteúdo antes da primeira pintura. Só essa mudança derrubou o Speed Index de 5,8 para 2,8 segundos.

Fica um aprendizado: uma animação de entrada bonita pode ser justamente o que atrasa a sua página. Antes de otimizar qualquer outra coisa, vale conferir qual elemento o Lighthouse aponta como LCP.

Dois ajustes menores completaram o trabalho:

  • O avatar era uma imagem de 384 por 384 exibida em um espaço de 96 pixels. Um srcset com versões de 128, 256 e 384 deixa o navegador escolher a menor adequada, o que no celular significa cerca de 5 KB em vez de 10.
  • O CSS era carregado como um arquivo separado, bloqueando a renderização. A opção build.inlineStylesheets: 'always' do Astro insere os 10 KB diretamente no HTML e elimina essa requisição extra.

Onde tudo isso chegou:

Relatório do PageSpeed Insights no mobile: Desempenho 95, Acessibilidade 100, Práticas recomendadas 100 e SEO 100.
PageSpeed Insights, mobile com throttle: 95 / 100 / 100 / 100.
Relatório do PageSpeed Insights no desktop: Desempenho 99, Acessibilidade 100, Práticas recomendadas 100 e SEO 100.
PageSpeed Insights, desktop: 99 / 100 / 100 / 100.

São 95 no mobile, 99 no desktop e 100 em acessibilidade, práticas recomendadas e SEO nos dois. Os cinco pontos que faltam no celular vêm da tela de abertura e da animação do topo, uma escolha consciente. Eu poderia remover as duas e alcançar 100, mas considero que o momento de marca compensa esses pontos. O importante é saber com clareza o que se está trocando.

Houve ainda uma rodada dedicada à acessibilidade, que resolveu o que o Lighthouse corretamente apontava: um texto de rodapé com contraste pouco abaixo do mínimo recomendado, um título que pulava de h1 para h3, a logo da Forge Labs sendo anunciada duas vezes por leitores de tela, um link “Saiba mais” sem contexto e um seletor de idioma cujo rótulo acessível não correspondia ao texto visível. Nada disso é perceptível para a maioria das pessoas, mas é a diferença entre 92 e 100 e, mais importante, entre um site que qualquer pessoa consegue usar e um que exclui parte do público.

i18n sem dobrar o código

O roteamento de idiomas do Astro 5 coloca o inglês na raiz (/) e o português em /pt/. As coleções de conteúdo são organizadas por pasta de idioma (src/content/work/en/ e src/content/work/pt/), e os helpers de busca em src/lib/content.ts filtram pelo prefixo. As tags hreflang, incluindo x-default, são geradas em todas as páginas por um componente <SeoHead>, e o sitemap relaciona cada URL ao seu equivalente no outro idioma.

Trato o lado em inglês como canônico para a busca internacional. O espelho em português atende à parte do meu trabalho que acontece no Brasil: clientes, conteúdo para a comunidade e qualquer pessoa que prefira ler em português. Adicionar um novo estudo de caso é criar um único arquivo MDX em cada pasta de idioma, sem tocar em código.

Levando o site ao Google e um alerta de phishing inesperado

Ter um site rápido é metade do trabalho. A outra metade é fazer o Google saber que ele existe. A parte mecânica é pouco glamourosa, mas merece cuidado: criar uma propriedade do tipo Domínio no Search Console (verificada por um registro TXT no DNS, o que cobre todos os subdomínios de uma vez), enviar o sitemap e submeter as páginas principais pela Inspeção de URL para acelerar a indexação. O Bing leva poucos minutos, porque importa os dados direto do Search Console, e hoje o índice do Bing também alimenta as buscas com inteligência artificial.

Um detalhe que registro para quem passar pelo mesmo susto: o Search Console acusou “não foi possível buscar” o sitemap por vários dias. O arquivo estava correto, respondendo com status 200 e XML válido, inclusive para o robô do Google. É um comportamento conhecido. O índice é processado de imediato, mas os sitemaps internos entram em uma fila separada e exibem esse status até o robô realmente acessá-los. Em um domínio novo, isso pode levar dias. Não há o que corrigir, é só aguardar.

Em seguida veio a parte que eu não esperava. O Search Console acusou um problema de segurança, possível phishing, em um portfólio estático e sem nenhum formulário.

A explicação está na própria propriedade do tipo Domínio, que abrange todos os subdomínios. Eu mantenho uma instância do n8n, uma ferramenta de automação, hospedada por conta própria em um subdomínio. O robô de segurança do Google encontrou a tela de login dessa ferramenta, um formulário de um produto conhecido em um domínio pessoal que ele não reconhecia, e a classificou como possível roubo de credenciais. É um falso positivo comum para quem hospeda esse tipo de aplicação. O problema é que o alerta não faz essa distinção e pode exibir um aviso vermelho de “site enganoso” no domínio inteiro, inclusive no portfólio que envio a recrutadores.

A correção foi algo que, de qualquer forma, eu deveria ter feito desde o início: colocar o login da ferramenta atrás do Cloudflare Access, a camada Zero Trust disponível no plano gratuito. Agora qualquer acesso público ao subdomínio é interceptado pela tela de autenticação da própria Cloudflare, um padrão que o Google reconhece como legítimo, e o login nunca fica exposto. O único cuidado foi com os recursos que precisam continuar públicos, como os webhooks de entrada. Para eles, criei uma exceção específica e mantive todo o restante protegido.

O aprendizado mais valioso desta etapa não é técnico. Uma propriedade do tipo Domínio audita o seu domínio por completo, o que inclui projetos paralelos, ambientes de teste e ferramentas internas. Se você expõe serviços em subdomínios da sua marca pessoal, trate-os com o mesmo cuidado da página principal. O ideal é manter qualquer ferramenta com login em um domínio separado e protegida antes que um robô a encontre.

O que eu faria diferente

Se começasse de novo, provavelmente escolheria a mesma stack. Os pontos que eu ajustaria:

  • O cache das imagens de Open Graph. Hoje elas são geradas a cada build pelo satori. Um controle baseado em hash do conteúdo reduziria o tempo de build na integração contínua.
  • O Lenis em aparelhos mais lentos. Eu reduziria um pouco o coeficiente de inércia para Androids de entrada, já que o padrão fica ótimo em um MacBook com chip M1, mas um pouco pesado em um celular de dois anos atrás.
  • A organização dos subdomínios desde o início. O alerta de phishing não teria acontecido se os projetos paralelos não dividissem o domínio da minha marca. Hoje, eu colocaria qualquer ferramenta com login em um domínio separado e protegida desde o primeiro dia.

No fim, o conteúdo é só metade da história. O código completo está no repositório, e dá para ver como cada decisão se sustenta navegando pelos estudos de caso, que é onde o raciocínio de engenharia aparece diante de restrições reais de produção.