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:
- 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 diminui de 48px para 24px
- 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 mobile—a que mais importa—também atingindo marcas perfeitas:

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
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