← Todos os Posts

De 76 a 100: Alcançando uma Pontuação Perfeita no Lighthouse

TL;DR: Um site de portfólio pessoal passou de uma pontuação de desempenho mobile de 76 no Lighthouse com 0.493 de CLS para um perfeito 100/100/100/100 em todas as categorias. A jornada revelou problemas sutis de carregamento de CSS, bugs de regex e uma substituição de variável CSS particularmente traiçoeira que causava mudanças de layout no mobile.


O Ponto de Partida

O site era um portfólio FastAPI + Jinja2 com HTMX e Alpine.js para interatividade. Auditoria inicial do Lighthouse mobile:

Métrica Pontuação
Performance 76
Acessibilidade 91
Melhores Práticas 100
SEO 100
CLS 0.493

Esse número de CLS é brutal. O Google considera qualquer coisa acima de 0.1 como “ruim.” Com 0.493, a página estava visivelmente pulando durante o carregamento.


Fase 1: Vitórias Rápidas de Acessibilidade

Antes de atacar a performance, corrigi problemas de acessibilidade para estabelecer uma linha de base:

Labels de Formulário

Mudei spans decorativos para elementos <label> apropriados:

<!-- Antes: span com aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">

<!-- Depois: associação correta de label -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">

Taxas de Contraste

Os títulos do rodapé e labels de formulário estavam usando --color-text-tertiary (branco com 40% de opacidade). Aumentei para --color-text-secondary (65% de opacidade) para atender aos requisitos de contraste WCAG AA de 4.5:1.

Texto Alt Redundante

Ícones de redes sociais tinham texto alt como “Ícone do LinkedIn” quando estavam dentro de links que já tinham aria-labels. Mudei para alt="" com aria-hidden="true" para evitar repetição em leitores de tela.

Texto de Link Idêntico

Múltiplos links “Ver Estudo de Caso” eram indistinguíveis para leitores de tela. Adicionei atributos aria-label com nomes de projetos:

<a href="/work/introl" aria-label="Ver estudo de caso de Branding Introl">
  Ver Estudo de Caso
</a>

Resultado: Acessibilidade saltou de 91 para 100.


Fase 2: O Problema do CSS Bloqueante

O Lighthouse mostrou um “Atraso de renderização do elemento” de 2.460ms no detalhamento do LCP. O culpado: um arquivo CSS síncrono bloqueando a primeira pintura.

<!-- O problema: folha de estilo bloqueando renderização -->
<link rel="stylesheet" href="/static/css/styles.min.css">

O navegador precisava baixar e processar toda a folha de estilo de 25KB antes de pintar qualquer coisa.

A Solução: CSS Crítico + Carregamento Assíncrono

Passo 1: Extrair o CSS crítico (acima da dobra) para um arquivo separado que é incorporado inline no <head>.

Passo 2: Carregar a folha de estilo completa de forma assíncrona usando o truque do media="print":

<!-- CSS crítico inline para primeira pintura instantânea -->
<style>{% include "components/_critical.css" %}</style>

<!-- CSS completo carregado async (não bloqueia renderização) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>

O atributo media="print" diz ao navegador “esta folha de estilo não é necessária para renderização em tela” então ela não bloqueia. Uma vez carregada, o handler onload muda para media="all".

O Script de Extração de CSS Crítico

Escrevi um script Python para extrair automaticamente o CSS crítico para componentes acima da dobra:

CRITICAL_PREFIXES = [
    ".container",
    ".header",
    ".nav",
    ".hero",
    ".personal-photos",
]

def extract_critical_css(css_content: str) -> str:
    # Remove comentários para prevenir poluição do regex
    css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

    # Extrair variáveis :root
    # Extrair regras base de reset
    # Extrair regras de componentes por prefixo
    # Extrair media queries contendo seletores críticos
    ...

Resultado: Atraso de renderização do elemento LCP caiu de 2.460ms para ~300ms.


Fase 3: O Pesadelo do CLS Começa

Depois de implementar CSS assíncrono, o CLS na verdade piorou—pulando para 0.119. O Lighthouse mostrou <main> como o elemento que estava mudando.

Bug #1: Comentários CSS Poluindo o Regex

O script de extração usava um regex para encontrar seletores:

rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"

Problema: Os comentários CSS não estavam sendo removidos antes da extração. Um comentário como:

/* Hero Section - Editorial */
.hero { ... }

Fazia o regex casar de “Hero Section” até a chave de abertura, criando seletores malformados que falhavam na verificação de prefixo. Estilos críticos do .hero estavam sendo silenciosamente descartados.

Correção: Remover comentários antes de qualquer operação regex:

css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

Bug #2: Regras de Media Query Extraídas como Independentes

O regex estava casando regras dentro de media queries e extraindo-as como regras independentes:

/* Estrutura CSS completa */
.hero__title { font-size: 5rem; }           /* Desktop */

@media (max-width: 768px) {
  .hero__title { font-size: 1.875rem; }     /* Mobile */
}

O script extraía ambas as regras no nível superior:

/* CSS crítico quebrado */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; }  /* Sobrescreve desktop! */

No mobile, o título estava renderizando no tamanho mobile do CSS crítico, e então… ficava ali porque o CSS completo tinha as mesmas regras. Mas no desktop, a sobrescrita mobile estava se aplicando incorretamente.

Correção: Rastrear posições de media query e pular regras dentro delas:

# Encontrar todos os intervalos de media query
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
    media_ranges.append((match.start(), match.end()))

def is_inside_media_query(pos: int) -> bool:
    return any(start <= pos < end for start, end in media_ranges)

# Pular regras dentro de media queries durante extração
for match in re.finditer(rule_pattern, css_no_comments):
    if is_inside_media_query(match.start()):
        continue  # Será incluída com o bloco completo de media query
    # ... extrair regra

Fase 4: A Hipótese do 100vh

O CLS ainda estava em 0.116. Teoria: se nada abaixo da dobra é visível na pintura inicial, não pode contribuir para o CLS.

Mudei o hero de 85vh para 100vh:

.hero {
  /* 100vh garante que nada abaixo da dobra seja visível na pintura inicial */
  min-height: 100vh;
}

Resultado: Sem mudança. CLS ainda 0.116. A mudança estava acontecendo dentro da viewport.


Fase 5: A Prova Definitiva

O filmstrip do Lighthouse mostrou o texto do hero visivelmente mudando de posição entre frames. Não estava desvanecendo—estava movendo horizontalmente.

Análise profunda de quais estilos afetam posicionamento horizontal: - .hero__content usa padding: 0 var(--gutter) - --gutter é definido como 48px em :root

Então encontrei:

/* No CSS completo, mas NÃO no CSS crítico */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 24px */
  }
}

A sequência no mobile:

  1. CSS crítico carrega: --gutter: 48px
  2. Hero renderiza com 48px de padding lateral
  3. CSS completo carrega de forma assíncrona
  4. Media query define --gutter: 24px
  5. Padding do hero diminui de 48px para 24px
  6. Texto reflui e muda = CLS 0.116

A Correção

Extrair media queries do :root como parte do CSS crítico:

# Extrair media queries do :root (sobrescritas de variáveis são críticas)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
    critical_rules.append(match.group())

Agora o CSS crítico inclui:

:root { --gutter: 48px; /* ... */ }

@media (max-width: 768px) {
  :root { --gutter: var(--spacing-md); }
}

Mobile renderiza com o gutter correto de 24px desde o início. Quando o CSS completo carrega, --gutter já é 24px—sem mudança, sem deslocamento.


Fase 6: CSP e Alpine.js

Mais um problema: Alpine.js estava lançando erros de console sobre violações de Content Security Policy. Alpine usa avaliação dinâmica de expressões internamente.

# No middleware de headers de segurança
CSP_DIRECTIVES = {
    "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
    # ...
}

A diretiva 'unsafe-eval' é necessária para o parsing de expressões do Alpine.js. A alternativa é usar a build compatível com CSP do Alpine, que tem algumas limitações.


Resultados Finais

Métrica Antes Depois
Performance 76 100
Acessibilidade 91 100
Melhores Práticas 100 100
SEO 100 100
CLS 0.493 0.033
LCP 2.6s 0.8s

A Prova

Auditoria desktop mostrando pontuações perfeitas em todas as quatro categorias:

Auditoria Lighthouse desktop mostrando pontuações 100/100/100/100

Auditoria mobile—a que mais importa—também atingindo marcas perfeitas:

Auditoria Lighthouse mobile mostrando pontuações 100/100/100/100


Principais Aprendizados

1. Variáveis CSS Podem Causar CLS

Sobrescritas de media query de propriedades CSS customizadas são invisíveis no painel de estilos computados do DevTools—você só vê o valor final. Se seu CSS crítico não inclui sobrescritas de variáveis, o layout vai mudar quando a folha de estilo completa carregar.

2. Extração de CSS Crítico é Complicada

Abordagens simples com regex falham em: - Comentários CSS (podem poluir o matching de seletores) - Regras dentro de media queries (devem permanecer dentro de seus blocos) - Media queries do :root (mudanças de variáveis afetam tudo)

3. O Truque do media="print" Funciona

Carregar CSS não-crítico com media="print" onload="this.media='all'" é uma forma legítima de adiar o carregamento de folhas de estilo sem complexidade JavaScript.

4. Debug de CLS Requer o Filmstrip

A visualização de filmstrip do Lighthouse mostra exatamente quando as mudanças ocorrem. Sem ela, você está adivinhando. O elemento destacado como “mudando” pode ser apenas um sintoma—procure o que está causando a mudança.

5. Seções Hero de 100vh Têm Benefícios Além da Estética

Se seu hero preenche a viewport, conteúdo abaixo da dobra não pode contribuir para o CLS porque não é medido até o usuário rolar.


A Stack

  • Backend: FastAPI + Jinja2
  • Frontend: HTMX + Alpine.js (self-hosted, sem CDN)
  • CSS: CSS puro com propriedades customizadas, sem pré-processadores
  • Otimização: Script Python customizado para extração de CSS crítico
  • Hospedagem: Railway com compressão GZip e cache imutável

Ferramentas Usadas

  • Lighthouse (Chrome DevTools)
  • Visualização de filmstrip do Lighthouse para debug de CLS
  • grep e regex101.com para análise de CSS
  • Python para o script de extração de CSS crítico
  • iTerm 2
  • Claude Code CLI com Opus 4.5 e muito ultrathink