Files
amare/resources/views/layouts/public.blade.php
Manoel Freitas 0ecae4c7d6 fix: reduzir I/O remoto de imagens R2 (MAN-109) (#43)
* 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>
2026-08-10 20:08:15 -03:00

161 lines
8.8 KiB
PHP

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#FBF9F4">
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<title>{{ $pageMeta->title }}</title>
<x-seo.meta :page-meta="$pageMeta" />
@php
$origin = static function (string $url): ?string {
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
return null;
}
return $parts['scheme'].'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
};
$r2Origin = $origin((string) config('filesystems.disks.r2.url'));
$siteOrigin = $origin((string) config('app.url'));
@endphp
@if (\App\Support\PublicImageUploadRules::disk() === 'r2' && $r2Origin !== null && $r2Origin !== $siteOrigin)
<link rel="preconnect" href="{{ $r2Origin }}" crossorigin>
@endif
<x-fonts />
@vite(['resources/css/app.css', 'resources/js/app.js'])
<noscript>
<style>
.main-nav {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.main-nav {
flex-direction: row;
}
}
.menu-button {
display: none;
}
</style>
</noscript>
</head>
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
Ir para o conteúdo
</a>
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
<a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
</nav>
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
<x-brand.logo variant="on-light" class="h-10 w-auto md:h-12" />
</a>
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
<a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
Solicitar proposta
</a>
<button
type="button"
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
aria-label="Abrir menu"
aria-controls="main-nav"
aria-expanded="false"
data-menu-button
>
<span class="sr-only">Menu</span>
<span aria-hidden="true" class="flex w-4 flex-col gap-1">
<span class="block h-px w-full bg-current"></span>
<span class="block h-px w-full bg-current"></span>
<span class="block h-px w-full bg-current"></span>
</span>
</button>
</div>
</div>
</header>
<main id="conteudo">
@yield('content')
</main>
<footer class="border-t border-amare-border bg-amare-bg-deep">
<div class="container-amare grid gap-10 py-12 md:grid-cols-[minmax(0,1.4fr)_repeat(2,minmax(0,1fr))]">
<div class="space-y-4">
<x-brand.logo variant="on-light" class="h-12 w-auto" />
<p class="max-w-md text-amare-muted">
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
</p>
</div>
<div class="text-sm">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
<ul class="mt-3 space-y-3">
<li><a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></li>
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
</ul>
</div>
<div class="text-sm">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
<address class="mt-3 space-y-3 not-italic">
@if ($siteSettings->city)
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
@endif
@if ($siteSettings->email)
<p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p>
@endif
@if ($siteSettings->phone)
<p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p>
@endif
@foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url))
<p>
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p>
@endif
@endforeach
</address>
</div>
</div>
<div class="border-t border-amare-border">
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
<p>&copy; {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
<p>
<a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
</p>
</div>
</div>
</footer>
@if ($siteSettings->analytics_enabled && filled($siteSettings->analytics_script))
{!! $siteSettings->analytics_script !!}
@endif
</body>
</html>