Files
amare/docs/evidence/lighthouse

Lighthouse — MAN-109

Medições versionadas porque a rodada anterior (PR #34) sobreviveu apenas como uma tabela digitada à mão num comentário do Linear: storage/app/lighthouse é gitignored, então não havia contra o quê comparar. Os relatórios brutos pesam ~48 MB por passada e continuam fora do repositório; o que fica versionado são os resumos, que já carregam a decomposição do LCP e a lista de requests até o LCP.

Como reproduzir

# Sobe a imagem de produção. O seed roda no host, não no container:
# ContentSeeder é no-op fora de local/staging/testing (database/seeders/ContentSeeder.php:44),
# então sob APP_ENV=production ele não semeia nada e o Lighthouse mede páginas vazias.
docker build -t amare-app:man109 .
php artisan migrate --force
php artisan db:seed --class=ContentSeeder --force
php artisan media:generate-variants     # sem isso o LCP da home infla ~2,5 s
docker run -d --name amare-web -p 8000:8000 -e APP_ENV=production ... amare-app:man109

TARGET=local bash scripts/perf/lighthouse.sh storage/app/lighthouse

Cada página é auditada 3 vezes por preset e o relatório de LCP mediano é o reportado — nunca a média. O LCP se move alguns décimos entre execuções na mesma build, e uma única execução não sustenta comparação.

Resultado

Preset mobile padrão do Lighthouse 12.8.2: throttling simulado de 150 ms de RTT, ~1,6 Mbps, CPU 4× mais lenta. Chrome estável do sistema (não o Chromium do Playwright), imagem de produção local, seeder ContentSeeder.

Antes: commit 2e43fde. As colunas intermediárias mostram cada correção isoladamente, medida com uma passada completa antes da seguinte entrar.

página preset antes + fontes e marca + webp e 720w + sizes correto perf antes → depois bytes antes → depois
contato desktop 0.53 s 0.36 s 0.37 s 0.36 s 100 → 100 262 → 106 KiB
contato mobile 2.55 s 1.51 s 1.51 s 1.50 s 97 → 100 262 → 106 KiB
home desktop 0.87 s 0.67 s 0.53 s 0.53 s 99 → 100 798 → 347 KiB
home mobile 4.58 s 3.39 s 2.87 s 2.49 s 83 → 98 798 → 347 KiB
portfolio desktop 0.83 s 0.65 s 0.37 s 0.36 s 99 → 100 609 → 259 KiB
portfolio mobile 3.98 s 1.58 s 2.64 s 1.58 s 87 → 100 609 → 259 KiB
portfolio-detalhe desktop 0.97 s 0.79 s 0.66 s 0.65 s 99 → 100 930 → 508 KiB
portfolio-detalhe mobile 3.98 s 3.01 s 2.71 s 2.18 s 87 → 99 785 → 365 KiB
servicos desktop 0.77 s 0.36 s 0.37 s 0.36 s 100 → 100 552 → 222 KiB
servicos mobile 3.68 s 2.63 s 1.58 s 2.03 s 89 → 99 552 → 222 KiB
sobre desktop 0.61 s 0.37 s 0.37 s 0.36 s 100 → 100 322 → 127 KiB
sobre mobile 3.01 s 1.58 s 1.60 s 1.58 s 94 → 100 322 → 127 KiB

FCP cai de 1,51 s para 0,91 s em todas as páginas no mobile. Acessibilidade, boas práticas e SEO marcam 100 em todas as páginas nos dois presets, antes e depois. CLS é 0,000 e TBT é 0 ms em todas — as metas de §6.6 para essas três métricas já passavam e continuam passando.

Contra a meta de LCP ≤ 2,5 s da §6.6: todas as páginas passam nos dois presets. Desktop com folga (máximo 0,65 s). No mobile o pior caso é a home a 2,49 s, ou seja em cima da linha — a pior das três execuções dela deu 2,57 s. Tratar a home como aprovada por margem, não com folga.

Duas colunas intermediárias merecem leitura cuidadosa em vez de conclusão:

  • portfolio mobile aparece pior na coluna do webp (2,64 s) do que na anterior (1,58 s). É variância, não regressão: as três execuções daquela passada foram 1,59 / 2,64 / 2,78 s. A página é a mais instável do conjunto e a mediana pulou de ponta. Na passada final as três deram 1,58 s.
  • servicos mobile sobe de 1,58 s para 2,03 s da terceira para a quarta coluna, pelo mesmo motivo (1,58 / 1,58 / 2,03).

É exatamente por isso que o script roda três vezes e reporta a mediana; ainda assim, diferenças abaixo de meio segundo entre passadas não devem ser lidas como efeito de uma correção.

Ressalva: a medição é local, então latência de origem e TLS não entram, e o throttling de rede é simulado. Trate o LCP como piso, não como valor de campo.

O que cada correção comprou

Fontes servidas em dobro — 88 KiB fora do caminho crítico. Bunny entrega cada peso de EB Garamond em woff2 e woff, e o plugin de fontes emitia uma regra @font-face para cada, woff2 primeiro e woff depois. Duas regras com a mesma família, peso, estilo e unicode-range fazem a última vencer: o navegador renderizava a partir dos woff e descartava os woff2 pré-carregados.

A prova está no log de rede da home antes da correção: 3 woff em prioridade VeryHigh (88 KiB) — a prioridade mais alta da página, à frente do elemento de LCP — somados a 3 woff2 em High (74 KiB) que só foram baixados porque estavam em <link rel="preload">. 162 KiB de tráfego de fonte para 74 KiB de fonte útil.

woff2 é suportado por todo navegador que este site atende desde 2016, então as regras woff não eram fallback e sim peso morto. O plugin amare:fonts-woff2-only em vite.config.js remove as regras do CSS e do manifest e tira os arquivos do bundle. É o que derruba o FCP de 1,51 s para 0,91 s em todas as páginas.

Ativos de marca reencodados — 102 KiB fora do caminho crítico. O logotipo era servido a 512 px de largura para renderizar em 48 px (lockup, cabeçalho e rodapé) e 32 px (mark, home): 84 KiB + 42 KiB com loading="eager" em todas as páginas. Reencodados a 3× do maior render — lockup 149×144 (14 KiB) e mark 191×96 (10 KiB) —, mantendo os mesmos nomes de arquivo para não invalidar cache. As variantes on-dark foram reencodadas junto por consistência; nenhuma view as usa hoje.

Isto absorve MAN-122: os ativos de marca foram reencodados sem alterar a forma ou a cor percebida, reduzindo apenas o peso transferido.

Os arquivos versionados aqui são: 2026-08-10-local-antes.md (commit 2e43fde), 2026-08-10-local-etapa-fontes-e-marca.md (passada intermediária) e 2026-08-10-local-depois.md (estado final).

Variantes WebP nas imagens de conteúdo. Com fontes e marca resolvidas, o elemento de LCP de toda página no mobile era a imagem do hero, e o Load Delay de 1,88 s era contenção de banda pura: 143 KiB de JPEG q82 a 960 px, com as três capas do portfólio somando outros 348 KiB. ResponsiveImage::generate() passa a escrever uma variante .webp ao lado de cada variante no formato original, e x-media.image a oferece num <source type="image/webp">. O <img> continua apontando para o formato original, então nada quebra em quem não decodifica webp, e mídia antiga sem irmãos webp renderiza <img> puro como antes.

sizes que descreve a realidade. Nenhuma imagem do site ocupa a viewport inteira: todas ficam dentro de container-amare, que reserva 1,5rem de padding de cada lado. Declarar 100vw fazia uma viewport de 412 px em DPR 1,75 pedir 721 px e pular para a variante de 960 para desenhar uma caixa de 637 px — errar por um pixel custava um terço a mais de bytes. Com calc(100vw - 3rem) a home passa a usar a variante de 720 (74 KiB, contra 143 KiB no início).

Foi também por isso que 720 entrou em ResponsiveImage::WIDTHS: sem ela o salto de 480 para 960 é grande demais para a viewport mobile mais comum.

Decomposição final do LCP da home no mobile:

fase tempo
TTFB 0,45 s
Load Delay 1,24 s
Load Time 0,10 s
Render Delay 0,69 s

O ganho de imagem só aparece depois do deploy regenerar as variantes

Toda a medição acima usou FILESYSTEM_DISK=local. Em staging e produção o disco é r2, e lá as variantes .webp e a largura 720 ainda não existem para a mídia já publicada. Até media:generate-variants rodar, availableWebpVariants() volta vazio, o <source> é omitido e o srcset do formato original perde a entrada de 720w — ou seja, nenhum dos dois ganhos de imagem aparece.

O serviço migrate do docker-compose.deploy.yml roda php artisan media:generate-variants sem condição a cada deploy, depois de migrate e do seed, e o comando só pula um caminho quando o arquivo original não existe (MediaGenerateVariantsCommand) — regenera mesmo quando já há variantes. Então o primeiro deploy desta branch produz as variantes novas por conta própria.

Enquanto isso não acontece, há um custo sem contrapartida: x-media.image faz agora 8 chamadas exists() por imagem (4 larguras × 2 formatos) contra o object store, no lugar de 3. Na home são ~32 round trips remotos por request em vez de ~12. Isso agrava a hipótese não validada abaixo em vez de melhorá-la, e é mais um motivo para cachear esses metadados.

Se a home precisar de mais folga

O Load Delay de 1,24 s ainda é contenção: as três capas do portfólio somam 155 KiB e, embora sejam loading="lazy" e prioridade Low, o navegador as busca porque entram no limiar de lazy loading da viewport emulada. Os levers restantes, em ordem de custo:

  1. Baixar a qualidade webp de 80 para 75 (medido: 109 → 92 KiB na imagem do hero a 960 px). Barato, mas mexe na qualidade de imagem de uma marca cujo posicionamento é acabamento editorial — decisão de produto, não de engenharia.
  2. Reduzir o Render Delay de 0,69 s, que agora é a segunda maior fatia e é trabalho de main thread, não de rede.
  3. FrankenPHP worker mode para o TTFB de 0,45 s. Tem gatilho objetivo em SPEC §22 e não deve ser puxado antes dele.

Cobertura que este trabalho não tem

Os testes browser não exercitam srcset nem <picture> com variantes geradas: nem ContentSeeder nem VisualContentSeeder geram variantes. Nesses cenários, availableVariants() volta vazio e o componente renderiza <img> puro. A cobertura do caminho com variantes fica nos testes de feature (MediaImageComponentTest).

Staging

Não medido. A origem de staging responde 303 para blocked.teams.cloudflare.com a partir da rede corporativa da Creditas ("O conteúdo deste site viola a Política de Segurança da Informação"), inclusive em /up. O preflight de HTTP 200 do script barra a execução antes de gastar minutos auditando páginas de bloqueio.

Duas hipóteses seguem não validadas porque só existem com FILESYSTEM_DISK=r2, que é configuração de staging e produção:

  • x-media.image chama ResponsiveImage::availableVariants(), availableWebpVariants() (8 exists() somados) e ResponsiveImage::dimensions() (que baixa o arquivo inteiro) a cada render, sem cache. No disco local são leituras de filesystem; no r2 são ~9 round trips remotos por imagem no lado servidor, ~36 na home. Teste discriminante: comparar o TTFB de /contato (zero imagens) com o de /portfolio (N imagens). Se o TTFB escalar com a contagem de imagens, está confirmado. Este é o item mais urgente da lista, porque as variantes webp multiplicaram o número de chamadas.
  • A mídia vem de R2_URL, uma origem cross-origin, e não há preconnect no <head> — DNS e TLS entram antes do LCP.

Para medir: rodar TARGET=staging BASE_URL=<origem> bash scripts/perf/lighthouse.sh de uma rede sem o filtro corporativo.