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:
portfoliomobile 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.servicosmobile 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:
- 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.
- Reduzir o Render Delay de 0,69 s, que agora é a segunda maior fatia e é trabalho de main thread, não de rede.
- 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.imagechamaResponsiveImage::availableVariants(),availableWebpVariants()(8exists()somados) eResponsiveImage::dimensions()(que baixa o arquivo inteiro) a cada render, sem cache. No discolocalsão leituras de filesystem; nor2sã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ápreconnectno<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.