← Todos os Posts

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.

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:

  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 encolhe de 48px para 24px
  6. 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 Lighthouse desktop mostrando pontuações 100/100/100/100

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

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


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

Artigos relacionados

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 min de leitura

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 min de leitura

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 min de leitura