perf: cortar 157 KiB do caminho crítico e medir o LCP de forma reprodutível (MAN-109)

A auditoria do PR #34 mediu LCP acima da meta de 2,5 s da SPEC §6.6 em todas
as páginas no mobile, mas ficou registrada apenas como tabela num comentário
do Linear — `storage/app/lighthouse` é gitignored, então não havia artefato
para comparar depois. Esta entrega mede de novo, encontra a causa dominante e
corta o que dava para cortar.

## Fontes servidas em dobro (88 KiB)

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.

O log de rede da home prova: 3 woff em prioridade VeryHigh (88 KiB), a mais
alta da página e à frente do elemento de LCP, mais 3 woff2 em High (74 KiB)
baixados só por causa do `<link rel="preload">`. 162 KiB de tráfego para
74 KiB de fonte útil.

woff2 é suportado por todo navegador que este site atende desde 2016, então as
regras woff eram peso morto, não fallback. O plugin `amare:fonts-woff2-only`
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) — absorve MAN-122

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

Os 16 baselines visuais foram regenerados no runner Linux e o diff é
imperceptível a 2× de zoom — mesma forma, mesma cor, menos bytes.

## Efeito medido

Mobile, mediana de 3 execuções por página: home 4,58 → 3,39 s; portfolio
3,98 → 1,58 s; portfolio-detalhe 3,98 → 3,01 s; servicos 3,68 → 2,63 s;
sobre 3,01 → 1,58 s; contato 2,55 → 1,51 s. Desktop passa com folga em todas
(máximo 0,79 s). Acessibilidade, boas práticas e SEO seguem 100, CLS 0,000 e
TBT 0 ms.

`contato`, `portfolio` e `sobre` entraram na meta. `servicos`,
`portfolio-detalhe` e `home` continuam fora, e o que falta está identificado:
o elemento de LCP da home é a imagem do hero em JPEG q82 (143 KiB), e
`Improve image delivery` estima 0,90 s de ganho restante. O lever é variante
WebP em ResponsiveImage com `<picture>` — fora deste escopo porque mexe no
pipeline de mídia que o CMS usa para upload.

## Ferramental

- `scripts/perf/lighthouse.sh` ganha `/portfolio/{slug}`, passada desktop,
  mediana de 3 execuções e uma guarda de HTTP 200 antes de auditar. Uma rota
  em 404 produz relatório com score alto: a rota mais pesada do site
  apareceria como ótima e ninguém notaria.
- `scripts/perf/summarize-lighthouse.mjs` extrai do JSON o elemento de LCP, a
  decomposição em fases e as requests até o LCP.
- `docker/ci-runner.Dockerfile` e `scripts/test/visual-update-ci.sh` versionam
  a receita de regeneração de baselines, que existia só como checklist.
- `CLAUDE.md` corrigido: Pest Browser serve a aplicação de um servidor Amp
  in-process, não do FrankenPHP. A paridade que importa nos baselines é
  Linux vs macOS, não o runtime HTTP.
- Sem gate de performance no CI (§14.1 e §22). No lugar, orçamento de bytes
  para os ativos de marca e asserção de que o build só emite woff2.

Staging não foi medido: a origem responde 303 para blocked.teams.cloudflare.com
a partir da rede corporativa, inclusive em `/up`. Duas hipóteses seguem não
validadas por dependerem de `FILESYSTEM_DISK=r2` — os ~4 round trips a R2 por
imagem que `x-media.image` faz sem cache, e a falta de `preconnect` para a
origem cross-origin de mídia. Ambas documentadas em
docs/evidence/lighthouse/README.md.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
This commit is contained in:
manoel.neto
2026-08-10 13:55:22 -03:00
parent 2e43fdeb04
commit 65919d4a6c
32 changed files with 1691 additions and 31 deletions

View File

@@ -57,8 +57,8 @@ Site-wide content is a singleton row reached via `SiteSetting::instance()`. Publ
- Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`. - Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`.
- `tests/Browser/Screenshots/` is gitignored — it only holds diff output. - `tests/Browser/Screenshots/` is gitignored — it only holds diff output.
- Regenerate with `composer visual:update`. - `composer visual:update` is the sanctioned command, but on macOS it writes baselines CI rejects. Use `scripts/test/visual-update-ci.sh`, which runs it inside the Linux runner built from `docker/ci-runner.Dockerfile`.
- **Baselines are CI-parity artifacts.** CI runs the browser suite against a `docker build`-produced FrankenPHP container (see the `browser` job in `.github/workflows/ci.yml`), so baselines regenerated on macOS against a local server will be rejected by CI. Commit `4578457` exists because of this. - **Baselines are Linux-parity artifacts.** Pest Browser does not use FrankenPHP or `artisan serve` — it serves the Laravel kernel from an in-process Amp server (`vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php`), so the FrankenPHP container the `browser` job starts is only a health check. What makes a baseline reproducible is the machine that renders it: Ubuntu 24.04, Playwright's Chromium, and Playwright's font packages (`StableScreenshot` forces `Arial`, which fontconfig resolves to Liberation Sans on Linux). Commit `4578457` exists because macOS renders text differently.
Determinism relies on three cooperating pieces: Determinism relies on three cooperating pieces:

View File

@@ -0,0 +1,61 @@
# Linux runner that reproduces CI's rendering environment for the visual
# regression baselines. Not part of the application image and never deployed.
#
# Why this exists: the baselines are pixel artifacts of the machine that
# rendered them. Pest Browser serves the Laravel kernel from an in-process Amp
# server (vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php),
# so FrankenPHP is not in the picture — what differs between a developer's Mac
# and CI is the OS, the Chromium build and the font stack. Regenerating on
# macOS produces baselines CI rejects, which is the whole reason commit
# 4578457 exists. Before this file the recipe lived only as a checklist in
# tasks.md and the image had to be reconstructed by archaeology.
#
# Mirrors the `browser` job in .github/workflows/ci.yml: Ubuntu 24.04,
# PHP 8.4 with the same extension list, Node 22, and Playwright's own system
# dependencies (which is where fonts-liberation comes from — StableScreenshot
# forces `Arial`, and on Linux fontconfig resolves that to the
# metric-compatible Liberation Sans).
#
# Driven by scripts/test/visual-update-ci.sh; see that script for usage.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
gnupg \
software-properties-common \
unzip \
&& add-apt-repository -y ppa:ondrej/php \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
php8.4-cli \
php8.4-bcmath \
php8.4-curl \
php8.4-gd \
php8.4-intl \
php8.4-mbstring \
php8.4-pgsql \
php8.4-sqlite3 \
php8.4-xml \
php8.4-zip \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# System libraries and fonts only. The browser binary itself is installed at
# run time so its revision matches whatever playwright version package-lock
# resolves, exactly as CI's `npx playwright install chromium --with-deps` does.
RUN npx --yes playwright@1.62 install-deps chromium \
&& rm -rf /root/.npm
WORKDIR /app
CMD ["bash"]

View File

@@ -0,0 +1,409 @@
# Lighthouse — local
- Origem: `http://127.0.0.1:8000`
- Lighthouse: 12.8.2
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
- Coletado em: 2026-08-10T15:55:03.101Z
- Execuções por página/preset: 3 (reportada a de LCP mediano)
- Commit: `2e43fde`
- Seeder: `ContentSeeder`
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |
|---|---|---|---|---|---|---|---|---|---|---|---|
| contato | desktop | 100 | 100 | 100 | 100 | 0.53 s | 0.37 s | 0.000 | 0 ms | 55 ms | 0.53 s 0.53 s |
| contato | mobile | 97 | 100 | 100 | 100 | 2.55 s | 1.50 s | 0.000 | 0 ms | 97 ms | 2.55 s 2.56 s |
| home | desktop | 99 | 100 | 100 | 100 | 0.87 s | 0.37 s | 0.000 | 0 ms | 75 ms | 0.87 s 0.87 s |
| home | mobile | 83 | 100 | 100 | 100 | 4.58 s | 1.51 s | 0.000 | 0 ms | 217 ms | 4.36 s 4.58 s |
| portfolio-detalhe | desktop | 99 | 100 | 100 | 100 | 0.97 s | 0.37 s | 0.000 | 0 ms | 89 ms | 0.97 s 0.97 s |
| portfolio-detalhe | mobile | 87 | 100 | 100 | 100 | 3.98 s | 1.51 s | 0.000 | 0 ms | 126 ms | 3.98 s 4.05 s |
| portfolio | desktop | 99 | 100 | 100 | 100 | 0.83 s | 0.37 s | 0.000 | 0 ms | 187 ms | 0.63 s 0.83 s |
| portfolio | mobile | 87 | 100 | 100 | 100 | 3.98 s | 1.51 s | 0.000 | 0 ms | 108 ms | 3.91 s 4.20 s |
| servicos | desktop | 100 | 100 | 100 | 100 | 0.77 s | 0.37 s | 0.000 | 0 ms | 124 ms | 0.77 s 0.77 s |
| servicos | mobile | 89 | 100 | 100 | 100 | 3.68 s | 1.51 s | 0.000 | 0 ms | 78 ms | 3.61 s 3.68 s |
| sobre | desktop | 100 | 100 | 100 | 100 | 0.61 s | 0.37 s | 0.000 | 0 ms | 48 ms | 0.57 s 0.61 s |
| sobre | mobile | 94 | 100 | 100 | 100 | 3.01 s | 1.51 s | 0.000 | 0 ms | 133 ms | 2.87 s 3.01 s |
## Decomposição do LCP
### contato — desktop
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 0.41 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.07 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.08 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### contato — mobile
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 2.10 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.11 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.11 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.11 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.11 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.11 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Render blocking requests | 0.60 s | — |
| Improve image delivery | 0.45 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| Network dependency tree | 0.00 s | — |
### home — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.45 s |
| Load Time | 0.03 s |
| Render Delay | 0.27 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.10 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.08 s |
| `/brand/mark-on-light.webp` | Image | 42 KiB | 0.08 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.09 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.25 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 3.30 s |
| Load Time | 0.11 s |
| Render Delay | 0.72 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.23 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.25 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.25 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.25 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.23 s |
| `/brand/mark-on-light.webp` | Image | 42 KiB | 0.23 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.24 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.24 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.35 s | — |
| Use efficient cache lifetimes | 0.30 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.54 s |
| Load Time | 0.04 s |
| Render Delay | 0.26 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg` | Image | 287 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.11 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.11 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.11 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.20 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 2.62 s |
| Load Time | 0.06 s |
| Render Delay | 0.85 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.16 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.16 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.14 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.15 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.20 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.19 s |
| Load Delay | 0.51 s |
| Load Time | 0.01 s |
| Render Delay | 0.12 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.23 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.23 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.23 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.20 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.23 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.23 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.22 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.21 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.25 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 2.68 s |
| Load Time | 0.09 s |
| Render Delay | 0.76 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.13 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.13 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.13 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.13 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.13 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.13 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.12 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.35 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.13 s |
| Load Delay | 0.49 s |
| Load Time | 0.03 s |
| Render Delay | 0.12 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.15 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.15 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.15 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.15 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.20 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 2.22 s |
| Load Time | 0.07 s |
| Render Delay | 0.94 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.12 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.12 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.12 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.20 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.25 s |
| Load Time | 0.01 s |
| Render Delay | 0.22 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.06 s |
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.07 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.06 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.05 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.05 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.94 s |
| Load Time | 0.07 s |
| Render Delay | 0.54 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.16 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.16 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.14 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.14 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.90 s | — |
| Use efficient cache lifetimes | 0.30 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |

View File

@@ -0,0 +1,407 @@
# Lighthouse — local-pos
- Origem: `http://127.0.0.1:8000`
- Lighthouse: 12.8.2
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
- Coletado em: 2026-08-10T16:20:48.372Z
- Execuções por página/preset: 3 (reportada a de LCP mediano)
- Commit: `2e43fde`
- Seeder: `ContentSeeder`
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |
|---|---|---|---|---|---|---|---|---|---|---|---|
| contato | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 20 ms | 0.36 s 0.37 s |
| contato | mobile | 100 | 100 | 100 | 100 | 1.51 s | 0.92 s | 0.000 | 0 ms | 46 ms | 1.50 s 1.51 s |
| home | desktop | 100 | 100 | 100 | 100 | 0.67 s | 0.25 s | 0.000 | 0 ms | 87 ms | 0.67 s 0.67 s |
| home | mobile | 92 | 100 | 100 | 100 | 3.39 s | 0.92 s | 0.000 | 0 ms | 88 ms | 3.38 s 3.46 s |
| portfolio-detalhe | desktop | 100 | 100 | 100 | 100 | 0.79 s | 0.25 s | 0.000 | 0 ms | 74 ms | 0.79 s 0.79 s |
| portfolio-detalhe | mobile | 95 | 100 | 100 | 100 | 3.01 s | 0.91 s | 0.000 | 0 ms | 89 ms | 2.93 s 3.01 s |
| portfolio | desktop | 100 | 100 | 100 | 100 | 0.65 s | 0.25 s | 0.000 | 0 ms | 73 ms | 0.37 s 0.65 s |
| portfolio | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 69 ms | 1.58 s 3.24 s |
| servicos | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 121 ms | 0.36 s 0.59 s |
| servicos | mobile | 97 | 100 | 100 | 100 | 2.63 s | 0.90 s | 0.000 | 0 ms | 73 ms | 1.58 s 2.63 s |
| sobre | desktop | 100 | 100 | 100 | 100 | 0.37 s | 0.25 s | 0.000 | 0 ms | 97 ms | 0.36 s 0.45 s |
| sobre | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 48 ms | 1.58 s 1.96 s |
## Decomposição do LCP
### contato — desktop
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 0.24 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.03 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.03 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.03 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.03 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.03 s |
| `/contato` | Document | 5 KiB | 0.02 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.03 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### contato — mobile
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 1.05 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.05 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.05 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
| `/contato` | Document | 5 KiB | 0.05 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.33 s |
| Load Time | 0.03 s |
| Render Delay | 0.19 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.11 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.11 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.11 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.15 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.88 s |
| Load Time | 0.17 s |
| Render Delay | 0.88 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.12 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.12 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.90 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.43 s |
| Load Time | 0.03 s |
| Render Delay | 0.21 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg` | Image | 287 KiB | 0.08 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.63 s |
| Load Time | 0.06 s |
| Render Delay | 0.87 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.11 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.11 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.90 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.28 s |
| Load Time | 0.05 s |
| Render Delay | 0.19 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.10 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.20 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.64 s |
| Load Time | 0.03 s |
| Render Delay | 0.46 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.09 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.09 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.18 s |
| Load Time | 0.01 s |
| Render Delay | 0.06 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.14 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.14 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.13 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.13 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.13 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.13 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.13 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.50 s |
| Load Time | 0.11 s |
| Render Delay | 0.56 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.09 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.09 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.60 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.13 s |
| Load Delay | 0.15 s |
| Load Time | 0.00 s |
| Render Delay | 0.09 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.11 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.11 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.11 s |
| `/sobre` | Document | 4 KiB | 0.10 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.11 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.57 s |
| Load Time | 0.10 s |
| Render Delay | 0.46 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.07 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
| `/sobre` | Document | 4 KiB | 0.05 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |

View File

@@ -0,0 +1,136 @@
# 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`. Depois: as duas correções descritas abaixo.
| página | preset | LCP antes | LCP depois | Δ | perf antes | perf depois | FCP antes | FCP depois | bytes antes | bytes depois |
|---|---|---|---|---|---|---|---|---|---|---|
| contato | desktop | 0.53 s | 0.36 s | -0.16 s | 100 | 100 | 0.37 s | 0.25 s | 262 KiB | 106 KiB |
| contato | mobile | 2.55 s | 1.51 s | -1.05 s | 97 | 100 | 1.50 s | 0.92 s | 262 KiB | 106 KiB |
| home | desktop | 0.87 s | 0.67 s | -0.20 s | 99 | 100 | 0.37 s | 0.25 s | 798 KiB | 609 KiB |
| home | mobile | 4.58 s | 3.39 s | -1.20 s | 83 | 92 | 1.51 s | 0.92 s | 798 KiB | 609 KiB |
| portfolio | desktop | 0.83 s | 0.65 s | -0.18 s | 99 | 100 | 0.37 s | 0.25 s | 609 KiB | 452 KiB |
| portfolio | mobile | 3.98 s | 1.58 s | -2.40 s | 87 | 100 | 1.51 s | 0.91 s | 609 KiB | 452 KiB |
| portfolio-detalhe | desktop | 0.97 s | 0.79 s | -0.18 s | 99 | 100 | 0.37 s | 0.25 s | 930 KiB | 773 KiB |
| portfolio-detalhe | mobile | 3.98 s | 3.01 s | -0.98 s | 87 | 95 | 1.51 s | 0.91 s | 785 KiB | 629 KiB |
| servicos | desktop | 0.77 s | 0.36 s | -0.40 s | 100 | 100 | 0.37 s | 0.25 s | 552 KiB | 395 KiB |
| servicos | mobile | 3.68 s | 2.63 s | -1.05 s | 89 | 97 | 1.51 s | 0.90 s | 552 KiB | 395 KiB |
| sobre | desktop | 0.61 s | 0.37 s | -0.23 s | 100 | 100 | 0.37 s | 0.25 s | 322 KiB | 165 KiB |
| sobre | mobile | 3.01 s | 1.58 s | -1.43 s | 94 | 100 | 1.51 s | 0.91 s | 322 KiB | 165 KiB |
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:** desktop passa com folga em todas as
páginas (máximo 0,79 s). No mobile, `contato`, `portfolio` e `sobre` entraram na
meta; `servicos` (2,63 s), `portfolio-detalhe` (3,01 s) e `home` (3,39 s)
continuam fora.
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 16 baselines visuais foram regenerados no runner Linux
(`scripts/test/visual-update-ci.sh`), e o diff é imperceptível a 2× de zoom —
mesma forma, mesma cor, só menos bytes.
## O que ainda separa a home da meta
`Improve image delivery` estima 0,90 s de ganho de LCP restante e aponta para as
imagens de conteúdo, não para mais nada do layout. Depois das duas correções, a
decomposição do LCP da home no mobile é:
| fase | tempo |
|---|---|
| TTFB | 0,45 s |
| Load Delay | 1,88 s |
| Load Time | 0,17 s |
| Render Delay | 0,88 s |
O elemento de LCP é a imagem do hero (`og-default-960.jpg`, **143 KiB** em JPEG
q82). O Load Delay de 1,88 s é contenção de banda: as três capas do portfólio
somam 348 KiB e, embora sejam `loading="lazy"` e prioridade `Low`, o total da
página ainda é 609 KiB.
O próximo lever, portanto, é o formato das imagens de conteúdo — variantes WebP
em `App\Support\ResponsiveImage` com `<picture>` em `x-media.image`. Não entrou
nesta entrega por escopo: mexe no pipeline de mídia que o CMS usa para upload.
## 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()` (3 `exists()`) 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 ~4 round
trips remotos por imagem no lado servidor, ~16 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.
- 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -31,9 +31,14 @@
// dimensions, so it gets no attributes rather than wrong ones. The CSS // dimensions, so it gets no attributes rather than wrong ones. The CSS
// classes still govern the rendered size in both cases — width/height only // classes still govern the rendered size in both cases — width/height only
// give the browser the aspect ratio to reserve. // give the browser the aspect ratio to reserve.
//
// The webp assets are encoded at 3x the largest rendered size (lockup at
// h-12 = 48 px, mark at h-8 = 32 px), which is why these are 149x144 and
// 191x96 rather than the 512-wide originals kept as png fallbacks. See
// tests/Feature/PublicSite/BrandAssetBudgetTest.php.
$intrinsic = $usesUploadedLogo $intrinsic = $usesUploadedLogo
? [] ? []
: ($mark ? ['width' => 512, 'height' => 257] : ['width' => 512, 'height' => 495]); : ($mark ? ['width' => 191, 'height' => 96] : ['width' => 149, 'height' => 144]);
@endphp @endphp
<img <img

View File

@@ -8,6 +8,18 @@
# Usage: # Usage:
# scripts/perf/lighthouse.sh [output-dir] # scripts/perf/lighthouse.sh [output-dir]
# #
# Environment:
# BASE_URL origin to audit (default http://127.0.0.1:8000)
# TARGET label for the output subtree (default local)
# RUNS runs per page and preset (default 3)
# PRESETS space-separated preset list (default "mobile desktop")
#
# Lighthouse LCP moves by a few tenths of a second between runs on the same
# build, so a single run cannot support a before/after comparison. Every page
# is audited RUNS times per preset and the run holding the median LCP is the
# one reported — averaging across runs would describe a page that never
# existed.
#
# Expects a site already answering on $BASE_URL. To raise one from scratch: # Expects a site already answering on $BASE_URL. To raise one from scratch:
# #
# docker build -t amare-app:ci . # docker build -t amare-app:ci .
@@ -20,7 +32,11 @@
# -v "$(pwd)/storage/app/public:/app/storage/app/public" \ # -v "$(pwd)/storage/app/public:/app/storage/app/public" \
# amare-app:ci # amare-app:ci
# #
# Seed content and generate the responsive variants first. Skipping # Seed content and generate the responsive variants first, and run both from
# the host rather than inside the container. ContentSeeder guards itself with
# an allow-list of local/staging/testing (database/seeders/ContentSeeder.php),
# so under the container's APP_ENV=production it is a deliberate no-op and
# Lighthouse would end up measuring empty pages. Skipping
# `media:generate-variants` inflates LCP by roughly 2.5 s on the home page, # `media:generate-variants` inflates LCP by roughly 2.5 s on the home page,
# because the originals are served at full size: # because the originals are served at full size:
# #
@@ -30,7 +46,11 @@
set -euo pipefail set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}" BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
OUT_DIR="${1:-storage/app/lighthouse}" BASE_URL="${BASE_URL%/}"
TARGET="${TARGET:-local}"
RUNS="${RUNS:-3}"
PRESETS="${PRESETS:-mobile desktop}"
OUT_DIR="${1:-storage/app/lighthouse}/${TARGET}"
# Lighthouse needs a Chrome binary. Playwright's is already on disk after # Lighthouse needs a Chrome binary. Playwright's is already on disk after
# `npx playwright install chromium`; fall back to a system Chrome. # `npx playwright install chromium`; fall back to a system Chrome.
@@ -45,23 +65,73 @@ fi
mkdir -p "${OUT_DIR}" mkdir -p "${OUT_DIR}"
PAGES=("/:home" "/servicos:servicos" "/portfolio:portfolio" "/sobre:sobre" "/contato:contato") PAGES=(
"/:home"
"/servicos:servicos"
"/portfolio:portfolio"
"/portfolio/casamento-ana-lucas:portfolio-detalhe"
"/sobre:sobre"
"/contato:contato"
)
# A route that answers 404 still produces a Lighthouse report, and the error
# page is light enough to score well — the heaviest route on the site would be
# reported as excellent and nobody would notice. Refuse to measure anything
# that is not a 200 before spending minutes on the audit.
echo "preflight against ${BASE_URL}"
for entry in "${PAGES[@]}"; do for entry in "${PAGES[@]}"; do
path="${entry%%:*}" path="${entry%%:*}"
name="${entry##*:}" code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "${BASE_URL}${path}" 2>/dev/null || true)"
echo "auditing ${name} (${BASE_URL}${path})" if [[ "${code}" != "200" ]]; then
echo "preflight failed: ${BASE_URL}${path} answered HTTP ${code:-<none>}, expected 200" >&2
exit 1
fi
# Default preset: simulated mobile throttling, 150 ms RTT, ~1.6 Mbps, echo " ok ${path}"
# 4x CPU slowdown. Add --preset=desktop for the desktop numbers. done
npx --yes lighthouse@12 "${BASE_URL}${path}" \
--quiet \ for preset in ${PRESETS}; do
--output=json --output=html \ # The mobile preset is Lighthouse's default and rejects an explicit
--output-path="${OUT_DIR}/${name}-mobile" \ # --preset flag, so only desktop is passed through.
--chrome-flags="--headless=new --no-sandbox" preset_flags=()
if [[ "${preset}" != "mobile" ]]; then
preset_flags+=("--preset=${preset}")
fi
for entry in "${PAGES[@]}"; do
path="${entry%%:*}"
name="${entry##*:}"
for run in $(seq 1 "${RUNS}"); do
echo "auditing ${name} ${preset} run ${run}/${RUNS} (${BASE_URL}${path})"
# Default preset: simulated mobile throttling, 150 ms RTT,
# ~1.6 Mbps, 4x CPU slowdown.
# ${array[@]+...} keeps `set -u` from treating an empty array as
# unbound, which bash 3.2 (the macOS system bash) still does.
npx --yes lighthouse@12 "${BASE_URL}${path}" \
--quiet \
${preset_flags[@]+"${preset_flags[@]}"} \
--output=json --output=html \
--output-path="${OUT_DIR}/${name}-${preset}-run${run}" \
--chrome-flags="--headless=new --no-sandbox"
done
done
done done
echo echo
echo "reports written to ${OUT_DIR}" echo "reports written to ${OUT_DIR}"
# The seeder is recorded because it decides the byte weight of every hero
# image: a before/after comparison across different fixtures measures nothing.
node scripts/perf/summarize-lighthouse.mjs "${OUT_DIR}" \
--target="${TARGET}" \
--base-url="${BASE_URL}" \
--commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
--seeder="${SEEDER:-ContentSeeder}" \
> "${OUT_DIR}/summary.md"
echo "summary written to ${OUT_DIR}/summary.md"
echo "SPEC.md §6.6 targets: LCP <= 2.5s, CLS <= 0.1, INP <= 200ms, 0 console errors" echo "SPEC.md §6.6 targets: LCP <= 2.5s, CLS <= 0.1, INP <= 200ms, 0 console errors"

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env node
//
// Turns a directory of Lighthouse JSON reports into one reviewable markdown
// summary. Written because the numbers behind MAN-109 previously survived only
// as a hand-typed table in a Linear comment: `storage/app/lighthouse` is
// gitignored, so there was nothing to diff a later run against.
//
// For each page and preset the run holding the median LCP is the one reported.
// Metrics are never averaged across runs — every figure in the table comes
// from the same single navigation, so the LCP phase breakdown adds up.
//
// Usage:
// node scripts/perf/summarize-lighthouse.mjs <report-dir> [--target=local]
// [--base-url=...] [--commit=...] [--seeder=...]
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const [dir, ...rest] = process.argv.slice(2);
if (!dir) {
console.error('usage: summarize-lighthouse.mjs <report-dir> [--target=...]');
process.exit(1);
}
const flags = new Map(
rest
.filter((argument) => argument.startsWith('--'))
.map((argument) => {
const [key, ...value] = argument.replace(/^--/, '').split('=');
return [key, value.join('=')];
}),
);
const ms = (value) => (typeof value === 'number' ? `${(value / 1000).toFixed(2)} s` : '—');
const kib = (value) => (typeof value === 'number' ? `${Math.round(value / 1024)} KiB` : '—');
const score = (value) => (typeof value === 'number' ? Math.round(value * 100) : '—');
const numeric = (audit) => (typeof audit?.numericValue === 'number' ? audit.numericValue : null);
/** Every nested details table in an audit, flattened. */
function nestedItems(audit) {
const items = audit?.details?.items ?? [];
return items.flatMap((item) => (Array.isArray(item?.items) ? item.items : [item]));
}
function lcpElement(lhr) {
const item = nestedItems(lhr.audits?.['largest-contentful-paint-element']).find((entry) => entry?.node);
const snippet = item?.node?.snippet ?? item?.node?.selector ?? null;
return snippet ? snippet.replace(/\s+/g, ' ').slice(0, 160) : null;
}
function lcpPhases(lhr) {
return nestedItems(lhr.audits?.['largest-contentful-paint-element'])
.filter((entry) => typeof entry?.phase === 'string')
.map((entry) => ({ phase: entry.phase, timing: entry.timing ?? null }));
}
/** Requests that finished before LCP, heaviest first — the contention evidence. */
function requestsBeforeLcp(lhr, lcp) {
const requests = lhr.audits?.['network-requests']?.details?.items ?? [];
return requests
.filter((request) => typeof request.networkEndTime === 'number' && (lcp === null || request.networkEndTime <= lcp + 50))
.filter((request) => (request.transferSize ?? 0) > 1024)
.sort((a, b) => (b.transferSize ?? 0) - (a.transferSize ?? 0))
.slice(0, 8)
.map((request) => ({
url: String(request.url ?? '').replace(/^https?:\/\/[^/]+/, ''),
type: request.resourceType ?? '—',
transferSize: request.transferSize ?? null,
endTime: request.networkEndTime ?? null,
}));
}
/** Failing LH12 insights, with whatever savings estimate they carry. */
function insights(lhr) {
return Object.entries(lhr.audits ?? {})
.filter(([id, audit]) => id.endsWith('-insight') && typeof audit.score === 'number' && audit.score < 1)
.map(([id, audit]) => ({
id,
title: audit.title ?? id,
lcpSavings: audit.metricSavings?.LCP ?? null,
byteSavings: audit.details?.overallSavingsBytes ?? null,
}))
.sort((a, b) => (b.lcpSavings ?? 0) - (a.lcpSavings ?? 0));
}
const reports = new Map();
for (const file of readdirSync(dir).filter((name) => name.endsWith('.report.json')).sort()) {
const match = /^(.+)-(mobile|desktop)-run(\d+)\.report\.json$/.exec(file);
if (!match) {
continue;
}
const [, page, preset] = match;
const key = `${page}::${preset}`;
const lhr = JSON.parse(readFileSync(join(dir, file), 'utf8'));
if (!reports.has(key)) {
reports.set(key, []);
}
reports.get(key).push({ file, lhr });
}
if (reports.size === 0) {
console.error(`no Lighthouse JSON reports found in ${dir}`);
process.exit(1);
}
/** The run whose LCP is the median — reported whole, never blended. */
function medianRun(runs) {
const sorted = [...runs].sort(
(a, b) => (numeric(a.lhr.audits?.['largest-contentful-paint']) ?? Infinity)
- (numeric(b.lhr.audits?.['largest-contentful-paint']) ?? Infinity),
);
return sorted[Math.floor((sorted.length - 1) / 2)];
}
const rows = [];
const details = [];
for (const [key, runs] of [...reports.entries()].sort()) {
const [page, preset] = key.split('::');
const chosen = medianRun(runs);
const { lhr } = chosen;
const lcp = numeric(lhr.audits?.['largest-contentful-paint']);
const spread = runs
.map((run) => numeric(run.lhr.audits?.['largest-contentful-paint']))
.filter((value) => value !== null)
.sort((a, b) => a - b);
rows.push({
page,
preset,
performance: score(lhr.categories?.performance?.score),
accessibility: score(lhr.categories?.accessibility?.score),
bestPractices: score(lhr.categories?.['best-practices']?.score),
seo: score(lhr.categories?.seo?.score),
lcp,
cls: numeric(lhr.audits?.['cumulative-layout-shift']),
tbt: numeric(lhr.audits?.['total-blocking-time']),
fcp: numeric(lhr.audits?.['first-contentful-paint']),
ttfb: numeric(lhr.audits?.['server-response-time']),
runs: runs.length,
spread: spread.length > 1 ? [spread[0], spread[spread.length - 1]] : null,
file: chosen.file,
});
details.push({
page,
preset,
element: lcpElement(lhr),
phases: lcpPhases(lhr),
requests: requestsBeforeLcp(lhr, lcp),
insights: insights(lhr),
});
}
const first = reports.values().next().value[0].lhr;
const out = [];
out.push(`# Lighthouse — ${flags.get('target') ?? 'local'}`);
out.push('');
out.push(`- Origem: \`${flags.get('base-url') ?? '—'}\``);
out.push(`- Lighthouse: ${first.lighthouseVersion ?? '—'}`);
// Which Chrome ran the audit is part of the measurement: the stable system
// Chrome and Playwright's Chromium do not produce interchangeable numbers.
out.push(`- Navegador: \`${first.environment?.hostUserAgent ?? '—'}\``);
out.push(`- Coletado em: ${first.fetchTime ?? '—'}`);
out.push(`- Execuções por página/preset: ${rows[0]?.runs ?? '—'} (reportada a de LCP mediano)`);
if (flags.get('commit')) {
out.push(`- Commit: \`${flags.get('commit')}\``);
}
if (flags.get('seeder')) {
out.push(`- Seeder: \`${flags.get('seeder')}\``);
}
out.push('');
out.push('Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.');
out.push('');
out.push('| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |');
out.push('|---|---|---|---|---|---|---|---|---|---|---|---|');
for (const row of rows) {
const spread = row.spread ? `${ms(row.spread[0])} ${ms(row.spread[1])}` : '—';
out.push(
`| ${row.page} | ${row.preset} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} `
+ `| ${row.seo} | ${ms(row.lcp)} | ${ms(row.fcp)} | ${typeof row.cls === 'number' ? row.cls.toFixed(3) : '—'} `
+ `| ${typeof row.tbt === 'number' ? `${Math.round(row.tbt)} ms` : '—'} `
+ `| ${typeof row.ttfb === 'number' ? `${Math.round(row.ttfb)} ms` : '—'} | ${spread} |`,
);
}
out.push('');
out.push('## Decomposição do LCP');
for (const detail of details) {
out.push('');
out.push(`### ${detail.page}${detail.preset}`);
out.push('');
out.push(`Elemento de LCP: ${detail.element ? `\`${detail.element}\`` : '—'}`);
if (detail.phases.length > 0) {
out.push('');
out.push('| fase | tempo |');
out.push('|---|---|');
for (const phase of detail.phases) {
out.push(`| ${phase.phase} | ${ms(phase.timing)} |`);
}
}
if (detail.requests.length > 0) {
out.push('');
out.push('Requests concluídas até o LCP, mais pesadas primeiro:');
out.push('');
out.push('| recurso | tipo | transferido | fim |');
out.push('|---|---|---|---|');
for (const request of detail.requests) {
out.push(`| \`${request.url}\` | ${request.type} | ${kib(request.transferSize)} | ${ms(request.endTime)} |`);
}
}
if (detail.insights.length > 0) {
out.push('');
out.push('| insight com falha | ganho estimado de LCP | bytes |');
out.push('|---|---|---|');
for (const insight of detail.insights) {
out.push(`| ${insight.title} | ${ms(insight.lcpSavings)} | ${kib(insight.byteSavings)} |`);
}
}
}
out.push('');
console.log(out.join('\n'));

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# Regenerates the visual regression baselines inside a Linux container that
# matches CI's rendering environment.
#
# `composer visual:update` run on macOS writes baselines CI will reject: the
# snapshots are pixels, and the Chromium build plus the font stack differ
# between the two systems. Pest Browser serves the application from an
# in-process Amp server, so no FrankenPHP container is involved — the only
# thing that has to match is the machine running the browser.
#
# SPEC.md §1.1 and openspec/specs/visual-regression/spec.md require the diff to
# be reviewed by a human before merge. This script updates baselines; it does
# not approve them.
#
# Usage:
# scripts/test/visual-update-ci.sh [--assert] [-- <extra pest args>]
#
# --assert run the suite in assertion mode instead of updating, to
# confirm the freshly written baselines actually pass
#
# Environment:
# DB_HOST host reachable from inside the container (default host.docker.internal)
# DB_PORT PostgreSQL port on that host (default 5433)
# DB_DATABASE database for the run (default amare_test)
# IMAGE runner image tag (default amare-ci-runner:local)
set -euo pipefail
MODE="update"
if [[ "${1:-}" == "--assert" ]]; then
MODE="assert"
shift
fi
if [[ "${1:-}" == "--" ]]; then
shift
fi
IMAGE="${IMAGE:-amare-ci-runner:local}"
DB_HOST="${DB_HOST:-host.docker.internal}"
DB_PORT="${DB_PORT:-5433}"
DB_DATABASE="${DB_DATABASE:-amare_test}"
DB_USERNAME="${DB_USERNAME:-amare}"
DB_PASSWORD="${DB_PASSWORD:-secret}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "building ${IMAGE}"
docker build -f "${REPO_ROOT}/docker/ci-runner.Dockerfile" -t "${IMAGE}" "${REPO_ROOT}"
fi
PEST_FLAGS="--testsuite=Browser"
if [[ "${MODE}" == "update" ]]; then
PEST_FLAGS="${PEST_FLAGS} --update-snapshots"
fi
# node_modules and the Playwright browser cache live in named volumes rather
# than in the bind mount: the host copy is built for macOS and its native
# binaries (rollup, esbuild, playwright) would not run here. Both volumes are
# reused across runs so only the first one pays the install.
docker run --rm \
-v "${REPO_ROOT}:/app" \
-v amare-ci-node-modules:/app/node_modules \
-v amare-ci-playwright:/opt/playwright-browsers \
--add-host=host.docker.internal:host-gateway \
-e DB_CONNECTION=pgsql \
-e DB_HOST="${DB_HOST}" \
-e DB_PORT="${DB_PORT}" \
-e DB_DATABASE="${DB_DATABASE}" \
-e DB_USERNAME="${DB_USERNAME}" \
-e DB_PASSWORD="${DB_PASSWORD}" \
-e PEST_FLAGS="${PEST_FLAGS}" \
-e PEST_EXTRA="$*" \
"${IMAGE}" \
bash -euo pipefail -c '
# composer install is skipped when vendor/ came in through the bind
# mount: the dependencies are pure PHP, so the host copy is valid here.
if [ ! -f vendor/autoload.php ]; then
composer install --no-interaction --prefer-dist
fi
if [ ! -x node_modules/.bin/playwright ]; then
npm ci
fi
npx playwright install chromium
npm run build
php artisan migrate --force
php artisan db:seed --class=VisualContentSeeder --force
php artisan storage:link --force
php artisan test ${PEST_FLAGS} ${PEST_EXTRA}
'

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Tests\TestCase;
/**
* The brand logo is eager-loaded into the header and footer of every public
* page, so its weight lands on the critical path of the whole site. It used to
* ship at 512 px wide 82 KiB of lockup plus 41 KiB of mark to render at 48
* and 32 px which the MAN-109 Lighthouse audit measured as the largest
* avoidable block ahead of the LCP element.
*
* A byte budget rather than a CI performance gate: SPEC §14.1 pins the five
* blocking jobs and §22 governs when new capability is added, but nothing stops
* a future asset swap from silently undoing this. The budget is deliberately
* loose roughly double what the current encoding costs so it catches a
* regression in kind (a full-size original dropped back in) and not normal
* re-encoding noise.
*/
class BrandAssetBudgetTest extends TestCase
{
/**
* Max transfer size per shipped webp, in bytes.
*/
private const WEBP_BUDGET = 30 * 1024;
/**
* Intrinsic size the assets are encoded at: 3x the largest rendered box
* (lockup at h-12 = 48 px, mark at h-8 = 32 px). The brand.logo component
* declares these as width/height to reserve the layout box, so a re-encode
* at a different size has to update both or the reserved box lies.
*
* @var array<string, array{width: int, height: int}>
*/
private const INTRINSIC = [
'lockup-on-light' => ['width' => 149, 'height' => 144],
'lockup-on-dark' => ['width' => 149, 'height' => 144],
'mark-on-light' => ['width' => 191, 'height' => 96],
'mark-on-dark' => ['width' => 191, 'height' => 96],
];
public function test_brand_webp_assets_stay_within_their_byte_budget(): void
{
foreach (array_keys(self::INTRINSIC) as $name) {
$path = public_path("brand/{$name}.webp");
$this->assertFileExists($path);
$this->assertLessThanOrEqual(
self::WEBP_BUDGET,
filesize($path),
"public/brand/{$name}.webp is over the critical-path budget of "
.(self::WEBP_BUDGET / 1024).' KiB. It is eager-loaded on every public page; '
.'re-encode it at the rendered size instead of raising the budget.'
);
}
}
public function test_brand_webp_assets_match_the_dimensions_the_component_declares(): void
{
$component = (string) file_get_contents(
resource_path('views/components/brand/logo.blade.php')
);
foreach (self::INTRINSIC as $name => $expected) {
$size = getimagesize(public_path("brand/{$name}.webp"));
$this->assertIsArray($size, "public/brand/{$name}.webp is not a readable image.");
$this->assertSame($expected['width'], $size[0], "public/brand/{$name}.webp width changed.");
$this->assertSame($expected['height'], $size[1], "public/brand/{$name}.webp height changed.");
}
foreach ([['width' => 191, 'height' => 96], ['width' => 149, 'height' => 144]] as $pair) {
$this->assertStringContainsString(
"'width' => {$pair['width']}, 'height' => {$pair['height']}",
$component,
'brand/logo.blade.php must reserve the box using the real intrinsic size of the shipped asset.'
);
}
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Tests\TestCase;
/**
* Bunny serves every EB Garamond weight as both woff2 and woff, and the fonts
* plugin emitted an `@font-face` rule for each woff2 first, woff second. Two
* rules with the same family, weight, style and unicode-range mean the later
* one wins, so browsers rendered from the woff files and discarded the
* preloaded woff2.
*
* The MAN-109 audit measured the cost on the home page: 88 KiB of woff at
* VeryHigh priority, ahead of the LCP image, plus 74 KiB of preloaded woff2
* that was fetched and never used. The `amare:fonts-woff2-only` plugin in
* vite.config.js strips the woff rules; these assertions fail if a plugin
* upgrade brings them back.
*
* Reads the build output because that is where the defect lived the source
* config looked correct the whole time. The `feature` job runs `npm run build`
* before the suite (.github/workflows/ci.yml).
*/
class FontDeliveryTest extends TestCase
{
public function test_build_ships_woff2_only(): void
{
$manifestPath = public_path('build/fonts-manifest.json');
$this->assertFileExists(
$manifestPath,
'Run `npm run build` before this suite — the font manifest is a build artifact.'
);
$manifest = json_decode((string) file_get_contents($manifestPath), true);
$this->assertIsArray($manifest);
$this->assertNotEmpty($manifest['preloads'] ?? [], 'The critical font weights must still be preloaded.');
foreach ($manifest['preloads'] as $preload) {
$this->assertStringEndsWith('.woff2', (string) ($preload['file'] ?? ''));
}
foreach ($manifest['families'] ?? [] as $alias => $family) {
foreach ($family['variants'] ?? [] as $key => $variant) {
$this->assertNotEmpty($variant['files'] ?? [], "{$alias} {$key} lost every font file.");
foreach ($variant['files'] as $file) {
$this->assertSame(
'woff2',
$file['format'] ?? null,
"{$alias} {$key} declares a {$file['format']} file. Only woff2 is served — a second "
.'format with the same family, weight and unicode-range overrides the woff2 rule and '
.'doubles the font traffic on the critical path.'
);
}
}
}
$stylesheet = $manifest['style']['file'] ?? null;
$this->assertIsString($stylesheet);
$css = (string) file_get_contents(public_path('build/'.$stylesheet));
$this->assertStringContainsString('format("woff2")', $css);
$this->assertStringNotContainsString('format("woff")', $css);
}
public function test_no_woff_files_are_emitted_into_the_build(): void
{
$emitted = glob(public_path('build/assets/*.woff')) ?: [];
$this->assertSame(
[],
array_map('basename', $emitted),
'The build emitted legacy woff files. They are never requested by a browser that supports woff2, '
.'which is every browser this site targets.'
);
}
}

View File

@@ -3,6 +3,69 @@ import laravel from 'laravel-vite-plugin';
import { bunny } from 'laravel-vite-plugin/fonts'; import { bunny } from 'laravel-vite-plugin/fonts';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
/**
* Bunny serves every EB Garamond weight as both woff2 and woff, and the fonts
* plugin emits an `@font-face` rule for each — woff2 first, woff second. Two
* rules with the same family, weight, style and unicode-range mean the *later*
* one wins, so the browser rendered from the woff files and never touched the
* preloaded woff2.
*
* Measured on the home page (MAN-109): 88 KiB of woff fetched at VeryHigh
* priority, ahead of the LCP image, on top of 74 KiB of preloaded woff2 that
* was downloaded and discarded. 162 KiB of font traffic for 74 KiB of fonts.
*
* woff2 has been supported by every browser this site targets since 2016, so
* the woff rules are dead weight rather than a fallback. Strip them from the
* emitted stylesheet and manifest, and drop the files from the bundle.
* `tests/Feature/PublicSite/FontDeliveryTest.php` fails if they come back.
*/
function fontsWoff2Only() {
const withoutWoffRules = (css) => css.replace(
/@font-face\s*\{[^}]*\}\s*/g,
(block) => (/format\(\s*["']woff["']\s*\)/.test(block) ? '' : block),
);
return {
name: 'amare:fonts-woff2-only',
enforce: 'post',
generateBundle(_options, bundle) {
for (const [fileName, asset] of Object.entries(bundle)) {
if (asset.type !== 'asset') {
continue;
}
if (fileName.endsWith('.woff')) {
delete bundle[fileName];
continue;
}
if (/fonts-[^/]*\.css$/.test(fileName)) {
asset.source = withoutWoffRules(String(asset.source));
continue;
}
if (fileName.endsWith('fonts-manifest.json')) {
const manifest = JSON.parse(String(asset.source));
for (const family of Object.values(manifest.families ?? {})) {
for (const variant of Object.values(family.variants ?? {})) {
variant.files = (variant.files ?? []).filter((file) => file.format === 'woff2');
}
}
for (const [alias, css] of Object.entries(manifest.style?.familyStyles ?? {})) {
manifest.style.familyStyles[alias] = withoutWoffRules(css);
}
asset.source = JSON.stringify(manifest, null, 2);
}
}
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
laravel({ laravel({
@@ -19,6 +82,7 @@ export default defineConfig({
], ],
}), }),
tailwindcss(), tailwindcss(),
fontsWoff2Only(),
], ],
server: { server: {
watch: { watch: {