* 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 * perf: servir imagens de conteúdo em webp e descrever sizes corretamente (MAN-109) Fecha a meta de LCP ≤ 2,5 s da SPEC §6.6 em todas as páginas, nos dois presets. Continuação direta do commit anterior: com fontes e marca resolvidas, o elemento de LCP de toda página no mobile passou a ser a imagem do hero, e o Load Delay de 1,88 s era contenção de banda pura. ## Variantes webp `ResponsiveImage::generate()` escreve 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: o `<source>` é preferência, não substituição, então nada quebra em quem não decodifica webp, e mídia antiga sem irmãos webp renderiza `<img>` puro como antes. O `<picture>` recebe `display: contents` porque os chamadores estilizam o `<img>` com classes como `h-full w-full object-cover` que resolvem contra o pai grid ou flex — um wrapper inline quebraria isso. O nome do arquivo acrescenta a extensão em vez de trocá-la (`photo-720.jpg.webp`): uploads usam UUID, então colisão já era improvável, mas `photo-720.webp` colidiria com a variante webp de um `photo.png`. ## 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 em toda página. Com `calc(100vw - 3rem)` a home passa a usar a variante de 720: 74 KiB, contra 143 KiB no início da investigação. 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. ## Efeito medido Mobile, mediana de 3 execuções: home 3,39 → 2,49 s; portfolio-detalhe 3,01 → 2,18 s; servicos 2,63 → 2,03 s; portfolio 1,58 s; sobre 1,58 s; contato 1,50 s. Desktop no máximo 0,65 s. Peso total da home 798 → 347 KiB. Contra o início da investigação (`2e43fde`): home 4,58 → 2,49 s com score de performance 83 → 98. A home passa **em cima da linha** — a pior das três execuções deu 2,57 s. Está documentado como aprovada por margem, não com folga, e os levers restantes estão listados em docs/evidence/lighthouse/README.md em ordem de custo. Os 16 baselines visuais não mudaram: nenhum seeder gera variantes e `media:generate-variants` não roda no runner visual, então naquele ambiente `availableVariants()` volta vazio e o componente renderiza `<img>` puro. A cobertura do caminho com variantes fica em MediaImageComponentTest e ResponsiveImageTest, não nos baselines — anotado como lacuna conhecida. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * docs: registrar a dependência do deploy e corrigir os commits nas evidências Três correções de honestidade nos artefatos de medição. O campo `Commit:` era gravado com o HEAD do momento da coleta, que é sempre anterior ao commit que contém as mudanças medidas — as duas passadas pós-correção diziam `2e43fde` e `65919d4` quando as árvores medidas viraram `65919d4` e `9ab2beb`. Corrigido à mão nos dois arquivos, e o script passa a marcar `+alterações não commitadas` quando a árvore está suja, para o artefato não voltar a afirmar o que não é. O ganho das imagens não aparece em staging nem em produção antes de `media:generate-variants` rodar: lá o disco é `r2` e as variantes webp e a largura 720 ainda não existem para a mídia publicada. O serviço `migrate` do docker-compose.deploy.yml roda o comando sem condição a cada deploy e ele regenera mesmo quando já há variantes, então o primeiro deploy resolve — mas estava implícito e agora está escrito. E o custo que as variantes webp introduzem no lado servidor está registrado com o número certo: `x-media.image` faz 8 `exists()` por imagem no lugar de 3, o que em `r2` são ~9 round trips remotos por imagem e ~36 na home. Isso agrava a hipótese não validada de TTFB em staging em vez de melhorá-la, e promove o cache desses metadados a item mais urgente da lista. Co-Authored-By: Claude noreply@anthropic.com AI-Assisted: yes AI-Tool: claude-code * fix: cache responsive image metadata for R2 * test: atualizar baselines visuais Linux --------- Co-authored-by: manoel.neto <manoel.neto@creditas.com>
250 lines
8.9 KiB
JavaScript
Executable File
250 lines
8.9 KiB
JavaScript
Executable File
#!/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 min–max |');
|
||
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'));
|