De 76 a 100: Alcançando uma Pontuação Perfeita no Lighthouse
Resumo: Um site pessoal de portfólio saiu de uma pontuação de 76 no Lighthouse mobile 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 com FastAPI + Jinja2, usando HTMX e Alpine.js para interatividade. Auditoria inicial do Lighthouse mobile:
| Métrica | Pontuação |
|---|---|
| Performance | 76 |
| Acessibilidade | 91 |
| Boas Práticas | 100 |
| SEO | 100 |
| CLS | 0,493 |
Esse número de CLS é brutal. O Google considera qualquer valor acima de 0,1 como “ruim”. Com 0,493, a página visivelmente pulava durante o carregamento.
Fase 1: Vitórias Rápidas em Acessibilidade
Antes de atacar a performance, corrigi problemas de acessibilidade para estabelecer uma base:
Labels de Formulário
Troquei spans decorativos por elementos <label> adequados:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<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 usavam --color-text-tertiary (branco com 40% de opacidade). Ajustei para --color-text-secondary (65% de opacidade) para atender ao requisito de contraste WCAG AA de 4,5:1.
Texto Alt Redundante
Ícones de redes sociais tinham texto alt como “LinkedIn icon” quando já estavam dentro de links com aria-labels. Alterei para alt="" com aria-hidden="true" para evitar repetição em leitores de tela.
Texto de Link Idêntico
Múltiplos links “View Case Study” eram indistinguíveis para leitores de tela. Adicionei atributos aria-label com os nomes dos projetos:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Resultado: Acessibilidade saltou de 91 para 100.
Fase 2: O Problema do CSS Bloqueando a Renderização
O Lighthouse mostrava um “Element render delay” de 2.460ms no detalhamento do LCP. O culpado: um arquivo CSS síncrono bloqueando a primeira pintura.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
O navegador precisava baixar e processar toda a folha de estilos 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) em um arquivo separado que é injetado inline no <head>.
Passo 2: Carregar a folha de estilos completa de forma assíncrona usando o truque do media="print":
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<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 “essa folha de estilos 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 dos componentes acima da dobra:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Resultado: O atraso de renderização do elemento LCP caiu de 2.460ms para ~300ms.
Fase 3: O Pesadelo do CLS Começa
Após implementar o CSS assíncrono, o CLS na verdade piorou — saltando para 0,119. O Lighthouse mostrava o <main> como o elemento que se deslocava.
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 com regex:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Bug #2: Regras de Media Query Extraídas como Independentes
O regex estava encontrando regras dentro de media queries e extraindo-as como regras independentes:
/* Full CSS structure */
.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 raiz:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
No mobile, o título renderizava no tamanho mobile a partir do CSS crítico, e então… permanecia assim porque o CSS completo tinha as mesmas regras. Mas no desktop, a substituição mobile estava sendo aplicada incorretamente.
Correção: Rastrear as posições das media queries e pular regras dentro delas:
# Find all media query ranges
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)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
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.
Alterei o hero de 85vh para 100vh:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Resultado: Nenhuma mudança. CLS ainda em 0,116. O deslocamento estava acontecendo dentro da viewport.
Fase 5: A Prova Definitiva
O filmstrip do Lighthouse mostrava o texto do hero visivelmente mudando de posição entre frames. Não aparecendo gradualmente — se movendo horizontalmente.
Investigação profunda sobre quais estilos afetam o posicionamento horizontal:
- .hero__content usa padding: 0 var(--gutter)
- --gutter é definido como 48px em :root
Então encontrei:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
A sequência no mobile:
- CSS crítico carrega:
--gutter: 48px - Hero renderiza com 48px de padding lateral
- CSS completo carrega de forma assíncrona
- Media query define
--gutter: 24px - Padding do hero encolhe de 48px para 24px
- Texto reflui e se desloca = CLS 0,116
A Correção
Extrair media queries do :root como parte do CSS crítico:
# Extract :root media queries (variable overrides are critical)
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); }
}
O 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 no console sobre violações de Content Security Policy. Alpine usa avaliação dinâmica de expressões internamente.
# In security headers middleware
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 o build compatível com CSP do Alpine, que tem algumas limitações.
Resultados Finais
| Métrica | Antes | Depois |
|---|---|---|
| Performance | 76 | 100 |
| Acessibilidade | 91 | 100 |
| Boas 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 mobile — a que mais importa — também atingindo notas perfeitas:

Principais Aprendizados
1. Variáveis CSS Podem Causar CLS
Substituições de propriedades CSS customizadas em media queries 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 as substituições de variáveis, o layout vai se deslocar quando a folha de estilos completa carregar.
2. Extração de CSS Crítico é Complicada
Abordagens simples com regex falham em:
- Comentários CSS (podem poluir a correspondência 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 estilos sem complexidade de JavaScript.
4. Depurar CLS Requer o Filmstrip
A visualização em filmstrip do Lighthouse mostra exatamente quando os deslocamentos ocorrem. Sem ela, você está adivinhando. O elemento destacado como “deslocando” pode ser apenas um sintoma — procure o que está causando o deslocamento.
5. Seções Hero com 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 página.
A Stack
- Backend: FastAPI + Jinja2
- Frontend: HTMX + Alpine.js (auto-hospedado, 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 Utilizadas
- Lighthouse (Chrome DevTools)
- Visualização em filmstrip do Lighthouse para depuração de CLS
greperegex101.compara 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