# 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 ```bash # 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 ``. 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 ``. O `` 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 `` 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 `` é 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 `` com variantes geradas: nem `ContentSeeder` nem `VisualContentSeeder` geram variantes. Nesses cenários, `availableVariants()` volta vazio e o componente renderiza `` 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 `` — DNS e TLS entram antes do LCP. Para medir: rodar `TARGET=staging BASE_URL= bash scripts/perf/lighthouse.sh` de uma rede sem o filtro corporativo.