Compare commits

...

5 Commits

Author SHA1 Message Date
7902b35393 feat(home): alinhar cadência editorial (#47)
* feat(home): alinhar cadência editorial

* test(home): ajustar contrato do hero dividido
2026-08-11 09:52:08 -03:00
28e25b4464 feat: recompor cadência editorial pública (#46) 2026-08-11 09:07:58 -03:00
8afb925e17 feat: adicionar heros fotográficos imersivos (#45) 2026-08-11 08:28:09 -03:00
338e3c9adb chore: remover regressão visual (#44) 2026-08-10 22:57:20 -03:00
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
100 changed files with 3516 additions and 1079 deletions

View File

@@ -205,15 +205,12 @@ jobs:
- run: npm run build
- run: npx playwright install chromium --with-deps
- run: php artisan migrate --force
- run: php artisan db:seed --class=VisualContentSeeder --force
- run: php artisan storage:link
- name: Build application image
run: docker build -t amare-app:ci .
- name: Run browser tests against FrankenPHP container
env:
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
run: |
docker run -d --name amare-web \
-e APP_ENV=testing \
@@ -222,7 +219,6 @@ jobs:
-e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Sao_Paulo \
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
-e DB_CONNECTION=pgsql \
-e DB_HOST=host.docker.internal \
-e DB_PORT=5432 \
@@ -253,8 +249,6 @@ jobs:
mkdir -p artifacts/browser
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
cp -R tests/Browser/Screenshots artifacts/browser/screenshots 2>/dev/null || true
cp -R tests/.pest artifacts/browser/pest 2>/dev/null || true
- name: Upload browser failure artifacts
if: failure()

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/tests/Browser/Screenshots
/storage/*.key
/storage/pail
/vendor

View File

@@ -53,18 +53,9 @@ Site-wide content is a singleton row reached via `SiteSetting::instance()`. Publ
`App\Application` is **not** covered by that rule. `app/Domain/` currently holds a single placeholder (`DomainModule.php`); business reads live in `app/Application/Queries`. Extend the arch test when you add a boundary.
## Visual regression — read before touching baselines
## Deterministic test support
- Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`.
- `tests/Browser/Screenshots/` is gitignored — it only holds diff output.
- Regenerate with `composer visual:update`.
- **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.
Determinism relies on three cooperating pieces:
- `APP_FROZEN_NOW``CarbonImmutable::setTestNow()` in `AppServiceProvider::freezeClockWhenConfigured()` (no-op in production).
- `Database\Seeders\VisualContentSeeder::FROZEN_NOW` — the value the browser tests and the CI job both pin to.
- `Tests\Support\StableScreenshot` — forces Arial, disables transitions/animations, scrolls the page to settle lazy images, and avoids the flaky `networkidle` wait.
`APP_FROZEN_NOW` configures `CarbonImmutable::setTestNow()` through `AppServiceProvider::freezeClockWhenConfigured()` outside production. `VisualContentSeeder` provides deterministic image/content fixtures for tests that explicitly need them. Neither setting is a browser-CI global default.
## Other things that bite

View File

@@ -58,7 +58,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
## Evidence on Hand
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP.
- Código atual contém site público, CMS, estrutura de acessibilidade, testes visuais e conteúdo demonstrativo.
- Código atual contém site público, CMS, estrutura de acessibilidade, testes browser e conteúdo demonstrativo.
- Textos, fotografias, portfólio, depoimentos, contatos e perfis sociais presentes nos seeders são dados fictícios de desenvolvimento e não constituem prova comercial.
- Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los.
@@ -68,7 +68,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
2. **Uma informação, um fluxo:** conduzir o dado do briefing ao evento sem redigitação ou planilhas paralelas.
3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo.
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
5. **Qualidade verificável:** tratar acessibilidade, desempenho, testes visuais e jornadas críticas como critérios de entrega.
5. **Qualidade verificável:** tratar acessibilidade, desempenho, motion e jornadas críticas como critérios de entrega.
## Accessibility & Inclusion

63
SPEC.md
View File

@@ -23,7 +23,7 @@
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) |
| Servidor de aplicação | FrankenPHP em modo regular |
| Banco de dados | PostgreSQL |
| Testes | Pest, Pest Browser/Playwright e testes visuais |
| Testes | Pest e Pest Browser/Playwright |
### 0.1 Vocabulário normativo
@@ -80,7 +80,6 @@ O agente:
- NÃO DEVE colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário.
- NÃO DEVE usar `float` para dinheiro.
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte.
- NÃO DEVE atualizar snapshots visuais apenas para fazer o CI passar sem revisar o diff.
### 1.2 Entrega incremental
@@ -135,7 +134,6 @@ Uma assessora adotará o sistema quando ele:
| Operação | Tarefas vencidas sem responsável | `0` |
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo |
| Qualidade | Jornadas E2E críticas passando | 100% antes de deploy |
| Visual | Snapshots aprovados | 100% |
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
---
@@ -207,7 +205,7 @@ No recorte do lançamento (Fases 01):
- formulário de briefing;
- usuários internos e papéis simples;
- SEO básico;
- acessibilidade e testes visuais;
- acessibilidade automatizada;
- CI/CD e deploy em contêiner com FrankenPHP.
Adiados para depois do lançamento (Fases 25, ver ADR-016):
@@ -1901,7 +1899,7 @@ A pirâmide deve possuir:
1. muitos testes unitários rápidos;
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
3. poucos testes E2E cobrindo jornadas críticas;
4. testes visuais determinísticos nas telas mais importantes.
4. testes E2E, de acessibilidade, motion e smoke nas jornadas públicas mais importantes.
A distribuição é por intenção, não por percentual rígido.
@@ -1983,40 +1981,7 @@ Em falha, CI deve publicar:
- logs do browser;
- HTML report quando disponível.
### 13.5 Regressão visual
Usar `assertScreenshotMatches()` ou API equivalente do Pest Browser.
Snapshots obrigatórios:
| Tela | Desktop | Mobile |
|---|---:|---:|
| Home | 1440×1000 | 390×844 |
| Serviços | 1440×1000 | 390×844 |
| Portfólio | 1440×1000 | 390×844 |
| Detalhe do portfólio | 1440×1000 | 390×844 |
| Briefing vazio | 1280×900 | 390×844 |
| Briefing com erros | 1280×900 | 390×844 |
| Briefing sucesso | 1280×900 | 390×844 |
| Login | 1280×900 | Opcional |
| Dashboard | 1440×1000 | Não obrigatório |
| Detalhe do evento | 1440×1000 | Não obrigatório |
Determinismo obrigatório:
- Chromium e imagem Linux fixos;
- viewport fixo;
- timezone `America/Sao_Paulo`;
- locale `pt-BR`;
- fontes instaladas na imagem;
- relógio congelado;
- seed determinístico;
- animações e transições desabilitadas;
- dados dinâmicos mascarados quando necessário.
Atualização de baseline deve usar comando explícito e revisão humana do diff.
### 13.6 Testes de arquitetura
### 13.5 Testes de arquitetura
Criar regras Pest Architecture:
@@ -2033,14 +1998,14 @@ arch()
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
### 13.7 Cobertura
### 13.6 Cobertura
- meta de 80% para `Domain` e `Application`;
- não medir views, migrations, código gerado e framework;
- cobertura não substitui critérios de aceite;
- queda de cobertura em módulo crítico deve bloquear merge.
### 13.8 Acessibilidade automatizada
### 13.7 Acessibilidade automatizada
Smoke/browser tests devem verificar:
@@ -2058,7 +2023,7 @@ Revisão manual mínima antes do lançamento:
- zoom a 200%;
- leitor de tela nos fluxos de briefing e login.
### 13.9 Comandos padronizados
### 13.8 Comandos padronizados
O projeto deve expor scripts equivalentes:
@@ -2068,7 +2033,6 @@ composer test:feature
composer test:browser
composer test
composer quality
composer visual:update
```
Composição esperada:
@@ -2076,7 +2040,7 @@ Composição esperada:
```text
test:unit → Unit + Architecture
test:feature → Feature + Livewire + Filament
test:browser → E2E + visual + acessibilidade + smoke
test:browser → E2E + acessibilidade + motion + smoke
quality → Pint check + PHPStan/Larastan + audits + testes
```
@@ -2091,7 +2055,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
| `unit` | Unitários, arquitetura e cobertura | Sim |
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim |
| `container` | Build da imagem final e healthcheck | Sim |
### 14.2 Regras do pipeline
@@ -2125,7 +2089,6 @@ Uma história só está concluída quando:
- PHPStan/Larastan está verde;
- unit e feature tests cobrem regras relevantes;
- E2E foi criado ou atualizado para fluxo crítico;
- snapshot foi criado ou revisado para UI coberta;
- acessibilidade automatizada não possui issues críticas/sérias;
- migration foi testada;
- autorização foi verificada;
@@ -2383,10 +2346,9 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] privacidade;
- [x] SEO;
- [x] mídia otimizada;
- [x] snapshots desktop/mobile;
- [x] testes de acessibilidade.
**Critério de saída:** conteúdo gerenciável no Filament e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
**Critério de saída:** conteúdo gerenciável no Filament, testes browser funcionais, de acessibilidade e motion aprovados, e revisão humana do site público no PR.
### Fase 2 — Leads — ADIADA (ADR-016)
@@ -2497,8 +2459,7 @@ Toda operação financeira deve:
| Risco | Mitigação obrigatória |
|---|---|
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
| Site parecer genérico | design tokens, conteúdo real, fotografia e visual tests |
| Snapshots instáveis | contêiner fixo, relógio/dados/fontes determinísticos |
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana |
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real |
| Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
| Worker mode vazar estado | manter modo regular até benchmark e ADR |
@@ -2517,7 +2478,7 @@ Toda operação financeira deve:
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita |
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
| ADR-004 | Pagamentos somente manuais | Aceita |
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
| ADR-005 | Pest unifica unit, feature e browser | Aceita |
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
| ADR-008 | Database queue; Redis adiado | Aceita |

View File

@@ -29,6 +29,7 @@ final class MediaGenerateVariantsCommand extends Command
foreach ($paths as $path) {
if (! Storage::disk($disk)->exists($path)) {
ResponsiveImage::forgetMetadata($path, $disk);
$this->warn("Missing file: {$path}");
$skipped++;
@@ -56,9 +57,21 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($settings->default_og_image_path)) {
$paths[] = (string) $settings->default_og_image_path;
}
if ($settings && filled($settings->hero_image_path)) {
$paths[] = (string) $settings->hero_image_path;
}
if ($settings && filled($settings->about_image_path)) {
$paths[] = (string) $settings->about_image_path;
}
if ($settings && filled($settings->hero_image_path)) {
$paths[] = (string) $settings->hero_image_path;
}
if ($settings && filled($settings->services_hero_image_path)) {
$paths[] = (string) $settings->services_hero_image_path;
}
if ($settings && filled($settings->portfolio_hero_image_path)) {
$paths[] = (string) $settings->portfolio_hero_image_path;
}
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
$paths[] = (string) $path;

View File

@@ -160,11 +160,24 @@ class ManageSiteSettings extends Page
Textarea::make('hero_note')
->label('Nota do hero')
->rows(2),
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
Textarea::make('about_summary')
->label('Resumo institucional')
->rows(3),
])
->columns(2),
Section::make('Imagens das aberturas')
->description('Fotos reais e autorizadas para as primeiras dobras. Sem imagem, cada rota mantém a abertura tonal.')
->schema([
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem da home', 'content/heroes'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
PublicImageUploadRules::fileUpload('services_hero_image_path', 'Imagem de Serviços', 'content/heroes'),
PublicImageUploadRules::altTextField('services_hero_image_alt', 'services_hero_image_path'),
PublicImageUploadRules::fileUpload('portfolio_hero_image_path', 'Imagem de Portfólio', 'content/heroes'),
PublicImageUploadRules::altTextField('portfolio_hero_image_alt', 'portfolio_hero_image_path'),
])
->columns(2),
Section::make('Manifesto editorial')
->schema([
TextInput::make('manifesto_title')

View File

@@ -18,6 +18,12 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $default_og_image_alt
* @property string|null $about_image_path
* @property string|null $about_image_alt
* @property string|null $hero_image_path
* @property string|null $hero_image_alt
* @property string|null $services_hero_image_path
* @property string|null $services_hero_image_alt
* @property string|null $portfolio_hero_image_path
* @property string|null $portfolio_hero_image_alt
* @property string|null $logo_path
* @property string|null $logo_alt
*/
@@ -31,6 +37,12 @@ use Illuminate\Database\Eloquent\Model;
'hero_cta_label',
'hero_secondary_cta_label',
'hero_note',
'hero_image_path',
'hero_image_alt',
'services_hero_image_path',
'services_hero_image_alt',
'portfolio_hero_image_path',
'portfolio_hero_image_alt',
'about_summary',
'about_image_path',
'about_image_alt',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Support;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
@@ -12,13 +13,23 @@ use Throwable;
final class ResponsiveImage
{
/** @var list<int> */
public const WIDTHS = [480, 960, 1440];
/**
* 720 sits between the original 480 and 960 because that is where the common
* mobile viewport lands: 412 CSS px at a 1.75 device pixel ratio asks for
* ~721 px, so a full-width image used to jump straight to the 960 variant and
* pay for a third more pixels than it drew. Measured on the hero (MAN-109):
* 143 KiB at 960 in jpeg against 75 KiB at 720 in webp.
*
* @var list<int>
*/
public const WIDTHS = [480, 720, 960, 1440];
public static function generate(string $path, ?string $disk = null): void
{
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk);
try {
if (! $filesystem->exists($path)) {
return;
}
@@ -47,40 +58,85 @@ final class ResponsiveImage
};
$filesystem->put($variantPath, (string) $encoded);
// A webp sibling for every variant. The MAN-109 audit measured the
// hero as the LCP element on every mobile page, at 143 KiB for a
// 960 px jpeg — webp carries the same picture for roughly a third of
// that. `x-media.image` offers these through a <source> so a browser
// that cannot decode webp still gets the original format.
if ($extension === 'webp') {
continue;
}
$webp = $manager->read($contents);
if ($webp->width() > $width) {
$webp->scale(width: $width);
}
$filesystem->put(
self::webpVariantPath($path, $width),
(string) $webp->toWebp(quality: 80)
);
}
} finally {
self::forgetMetadata($path, $disk);
}
self::metadata($path, $disk);
}
public static function deleteVariants(string $path, ?string $disk = null): void
{
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk);
try {
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
if ($filesystem->exists($variantPath)) {
$filesystem->delete($variantPath);
}
}
}
} finally {
self::forgetMetadata($path, $disk);
}
}
public static function delete(string $path, ?string $disk = null): void
{
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk);
try {
self::deleteVariants($path, $disk);
if ($filesystem->exists($path)) {
$filesystem->delete($path);
}
} finally {
self::forgetMetadata($path, $disk);
}
}
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
{
self::forgetMetadata($previousPath, $disk);
self::forgetMetadata($newPath, $disk);
try {
if ($previousPath !== '' && $previousPath !== $newPath) {
self::delete($previousPath, $disk);
}
self::generate($newPath, $disk);
} finally {
self::forgetMetadata($previousPath, $disk);
self::forgetMetadata($newPath, $disk);
}
self::metadata($newPath, $disk);
}
public static function variantPath(string $path, int $width): string
@@ -93,26 +149,35 @@ final class ResponsiveImage
return $directory === '' ? $variantName : $directory.'/'.$variantName;
}
/**
* The webp sibling of a variant, named by appending rather than replacing the
* extension. Uploads are stored under a UUID so a collision is already
* unlikely, but `photo-480.jpg.webp` cannot collide with the webp variant of
* a `photo.png` the way `photo-480.webp` would.
*/
public static function webpVariantPath(string $path, int $width): string
{
return self::variantPath($path, $width).'.webp';
}
/**
* @return list<array{path: string, width: int}>
*/
public static function availableVariants(string $path, ?string $disk = null): array
{
$filesystem = self::filesystem($disk);
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = [
'path' => $variantPath,
'width' => $width,
];
}
return self::metadata($path, $disk)['variants'] ?? [];
}
return $variants;
/**
* The webp variants that exist for a path. Empty when the media predates
* `media:generate-variants` running with webp support, which is why
* `x-media.image` treats the <source> as optional rather than assuming it.
*
* @return list<array{path: string, width: int}>
*/
public static function availableWebpVariants(string $path, ?string $disk = null): array
{
return self::metadata($path, $disk)['webp_variants'] ?? [];
}
/**
@@ -120,13 +185,38 @@ final class ResponsiveImage
*/
public static function dimensions(string $path, ?string $disk = null): ?array
{
$metadata = self::metadata($path, $disk);
return $metadata === null ? null : [
'width' => $metadata['width'],
'height' => $metadata['height'],
];
}
/**
* @return array{
* width: int,
* height: int,
* variants: list<array{path: string, width: int}>,
* webp_variants: list<array{path: string, width: int}>
* }|null
*/
public static function metadata(string $path, ?string $disk = null): ?array
{
$key = self::metadataCacheKey($path, $disk);
$cached = self::cachedMetadata($key);
if (is_array($cached)) {
return $cached;
}
try {
$filesystem = self::filesystem($disk);
if (! $filesystem->exists($path)) {
return null;
}
try {
$contents = $filesystem->get($path);
if ($contents === null) {
@@ -134,18 +224,88 @@ final class ResponsiveImage
}
$image = (new ImageManager(new Driver))->read($contents);
return [
$metadata = [
'width' => $image->width(),
'height' => $image->height(),
'variants' => self::existingVariants($filesystem, $path, false),
'webp_variants' => self::existingVariants($filesystem, $path, true),
];
self::storeMetadata($key, $metadata);
return $metadata;
} catch (Throwable) {
return null;
}
}
public static function forgetMetadata(string $path, ?string $disk = null): void
{
try {
Cache::forget(self::metadataCacheKey($path, $disk));
} catch (Throwable) {
// Cache availability must not block public media rendering or cleanup.
}
}
private static function filesystem(?string $disk): Filesystem
{
return Storage::disk($disk ?? PublicImageUploadRules::disk());
return Storage::disk(self::diskName($disk));
}
/**
* @return list<array{path: string, width: int}>
*/
private static function existingVariants(Filesystem $filesystem, string $path, bool $webp): array
{
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = $webp
? self::webpVariantPath($path, $width)
: self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = ['path' => $variantPath, 'width' => $width];
}
}
return $variants;
}
private static function metadataCacheKey(string $path, ?string $disk): string
{
return 'responsive-image:metadata:'.sha1(self::diskName($disk).'|'.$path);
}
/**
* @return array<string, mixed>|null
*/
private static function cachedMetadata(string $key): ?array
{
try {
$cached = Cache::get($key);
return is_array($cached) ? $cached : null;
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed> $metadata
*/
private static function storeMetadata(string $key, array $metadata): void
{
try {
Cache::forever($key, $metadata);
} catch (Throwable) {
// The uncached result remains safe to use for this request.
}
}
private static function diskName(?string $disk): string
{
return $disk ?? PublicImageUploadRules::disk();
}
}

View File

@@ -87,9 +87,6 @@
"npm audit --omit=dev --audit-level=high",
"@test"
],
"visual:update": [
"@php artisan test --testsuite=Browser --update-snapshots"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",

View File

@@ -73,7 +73,7 @@ return [
|--------------------------------------------------------------------------
|
| When set outside production, the application clock is frozen for
| deterministic rendering (visual regression / seeded content).
| deterministic time-dependent tests or seeded content.
|
*/

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->string('hero_image_path')->nullable()->after('hero_note');
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
$table->string('services_hero_image_path')->nullable()->after('hero_image_alt');
$table->string('services_hero_image_alt')->nullable()->after('services_hero_image_path');
$table->string('portfolio_hero_image_path')->nullable()->after('services_hero_image_alt');
$table->string('portfolio_hero_image_alt')->nullable()->after('portfolio_hero_image_path');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn([
'hero_image_path',
'hero_image_alt',
'services_hero_image_path',
'services_hero_image_alt',
'portfolio_hero_image_path',
'portfolio_hero_image_alt',
]);
});
}
};

View File

@@ -61,6 +61,12 @@ class ContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'hero_image_path' => $this->copyFixture('case-casamento-ana-lucas.jpg', 'content/heroes/home.jpg'),
'hero_image_alt' => 'Celebração ao ar livre em São Paulo',
'services_hero_image_path' => $this->copyFixture('service-eventos-corporativos.jpg', 'content/heroes/services.jpg'),
'services_hero_image_alt' => 'Mesa preparada para um evento corporativo',
'portfolio_hero_image_path' => $this->copyFixture('case-lancamento-verano.jpg', 'content/heroes/portfolio.jpg'),
'portfolio_hero_image_alt' => 'Ambientação de um evento de lançamento',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',

View File

@@ -14,7 +14,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
/**
* Deterministic content for visual regression. Keep separate from ContentSeeder demo data.
* Deterministic content and image fixtures. Keep separate from ContentSeeder demo data.
*/
class VisualContentSeeder extends Seeder
{
@@ -42,6 +42,12 @@ class VisualContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'hero_image_path' => $this->writeSolidJpeg('visual/heroes/home.jpg', 1600, 1100, [218, 224, 205]),
'hero_image_alt' => 'Imagem editorial da home',
'services_hero_image_path' => $this->writeSolidJpeg('visual/heroes/services.jpg', 1600, 1100, [196, 200, 184]),
'services_hero_image_alt' => 'Imagem editorial de Serviços',
'portfolio_hero_image_path' => $this->writeSolidJpeg('visual/heroes/portfolio.jpg', 1600, 1100, [228, 226, 221]),
'portfolio_hero_image_alt' => 'Imagem editorial de Portfólio',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre',

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-sizes
- 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-10T17:38:33.741Z
- Execuções por página/preset: 3 (reportada a de LCP mediano)
- Commit: `9ab2beb` (o script gravou `65919d4`, o HEAD no momento da coleta; a árvore medida é a que virou `9ab2beb`)
- 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 | 91 ms | 0.36 s 0.37 s |
| contato | mobile | 100 | 100 | 100 | 100 | 1.50 s | 0.92 s | 0.000 | 0 ms | 66 ms | 1.50 s 1.51 s |
| home | desktop | 100 | 100 | 100 | 100 | 0.53 s | 0.25 s | 0.000 | 0 ms | 110 ms | 0.53 s 0.54 s |
| home | mobile | 98 | 100 | 100 | 100 | 2.49 s | 0.92 s | 0.000 | 0 ms | 77 ms | 2.48 s 2.57 s |
| portfolio-detalhe | desktop | 100 | 100 | 100 | 100 | 0.65 s | 0.25 s | 0.000 | 0 ms | 81 ms | 0.65 s 0.66 s |
| portfolio-detalhe | mobile | 99 | 100 | 100 | 100 | 2.18 s | 0.90 s | 0.000 | 0 ms | 77 ms | 2.18 s 2.18 s |
| portfolio | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 72 ms | 0.36 s 0.49 s |
| portfolio | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 71 ms | 1.58 s 1.58 s |
| servicos | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.24 s | 0.000 | 0 ms | 59 ms | 0.36 s 0.38 s |
| servicos | mobile | 99 | 100 | 100 | 100 | 2.03 s | 0.90 s | 0.000 | 0 ms | 62 ms | 1.58 s 2.03 s |
| sobre | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 51 ms | 0.36 s 0.37 s |
| sobre | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 93 ms | 1.58 s 1.58 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.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-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.10 s |
| `/contato` | Document | 5 KiB | 0.09 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.10 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.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.07 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
| `/contato` | Document | 5 KiB | 0.07 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 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 | — |
| 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-720.jpg.webp" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…"`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.25 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/og/og-default-720.jpg.webp` | Image | 74 KiB | 0.13 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.14 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.14 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.12 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.12 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.12 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.12 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 | — |
### home — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-720.jpg.webp" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…"`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.24 s |
| Load Time | 0.10 s |
| Render Delay | 0.69 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-720.jpg.webp` | Image | 74 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.10 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.10 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 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.09 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.45 s | — |
| Use efficient cache lifetimes | 0.00 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.28 s |
| Load Time | 0.03 s |
| Render Delay | 0.22 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg.webp` | Image | 210 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-720.jpg.webp` | Image | 105 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-720.jpg.webp` | Image | 88 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 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.09 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.09 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.11 s |
| Load Time | 0.04 s |
| Render Delay | 0.57 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-720.jpg.webp` | Image | 105 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-720.jpg.webp` | Image | 88 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 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.09 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.45 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 — 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.14 s |
| Load Time | 0.01 s |
| Render Delay | 0.09 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.09 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 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-8wBXH8kj.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 | — |
### 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.66 s |
| Load Time | 0.04 s |
| Render Delay | 0.42 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.09 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 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-8wBXH8kj.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-720.jpg.webp" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/se`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.14 s |
| Load Time | 0.01 s |
| Render Delay | 0.10 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-720.jpg.webp` | Image | 49 KiB | 0.08 s |
| `/storage/content/services/eventos-corporativos-720.jpg.webp` | Image | 39 KiB | 0.08 s |
| `/storage/content/services/celebracoes-intimistas-720.jpg.webp` | Image | 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 |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.07 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.07 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-720.jpg.webp" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/se`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.08 s |
| Load Time | 0.07 s |
| Render Delay | 0.43 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-720.jpg.webp` | Image | 49 KiB | 0.08 s |
| `/storage/content/services/eventos-corporativos-720.jpg.webp` | Image | 39 KiB | 0.08 s |
| `/storage/content/services/celebracoes-intimistas-720.jpg.webp` | Image | 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.07 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.07 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.07 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.07 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.45 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-720.jpg.webp" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.11 s |
| Load Time | 0.01 s |
| Render Delay | 0.12 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.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/storage/content/about/about-image-720.jpg.webp` | Image | 22 KiB | 0.07 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.06 s |
| `/sobre` | 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 | — |
| 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-720.jpg.webp" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.77 s |
| Load Time | 0.02 s |
| Render Delay | 0.33 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.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 |
| `/storage/content/about/about-image-720.jpg.webp` | Image | 22 KiB | 0.11 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.10 s |
| `/sobre` | Document | 5 KiB | 0.09 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.10 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,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: `65919d4` (o script gravou `2e43fde`, o HEAD no momento da coleta; a árvore medida é a que virou `65919d4`)
- 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,205 @@
# Lighthouse — MAN-109
Medições versionadas porque a rodada anterior (PR #34) sobreviveu apenas como
uma tabela digitada à mão num comentário do Linear: `storage/app/lighthouse` é
gitignored, então não havia contra o quê comparar. Os relatórios brutos pesam
~48 MB por passada e continuam fora do repositório; o que fica versionado são os
resumos, que já carregam a decomposição do LCP e a lista de requests até o LCP.
## Como reproduzir
```bash
# Sobe a imagem de produção. O seed roda no host, não no container:
# ContentSeeder é no-op fora de local/staging/testing (database/seeders/ContentSeeder.php:44),
# então sob APP_ENV=production ele não semeia nada e o Lighthouse mede páginas vazias.
docker build -t amare-app:man109 .
php artisan migrate --force
php artisan db:seed --class=ContentSeeder --force
php artisan media:generate-variants # sem isso o LCP da home infla ~2,5 s
docker run -d --name amare-web -p 8000:8000 -e APP_ENV=production ... amare-app:man109
TARGET=local bash scripts/perf/lighthouse.sh storage/app/lighthouse
```
Cada página é auditada 3 vezes por preset e o relatório de LCP mediano é o
reportado — nunca a média. O LCP se move alguns décimos entre execuções na mesma
build, e uma única execução não sustenta comparação.
## Resultado
Preset mobile padrão do Lighthouse 12.8.2: throttling simulado de 150 ms de RTT,
~1,6 Mbps, CPU 4× mais lenta. Chrome estável do sistema (não o Chromium do
Playwright), imagem de produção local, seeder `ContentSeeder`.
Antes: commit `2e43fde`. As colunas intermediárias mostram cada correção
isoladamente, medida com uma passada completa antes da seguinte entrar.
| página | preset | antes | + fontes e marca | + webp e 720w | + sizes correto | perf antes → depois | bytes antes → depois |
|---|---|---|---|---|---|---|---|
| contato | desktop | 0.53 s | 0.36 s | 0.37 s | **0.36 s** | 100 → 100 | 262 → 106 KiB |
| contato | mobile | 2.55 s | 1.51 s | 1.51 s | **1.50 s** | 97 → 100 | 262 → 106 KiB |
| home | desktop | 0.87 s | 0.67 s | 0.53 s | **0.53 s** | 99 → 100 | 798 → 347 KiB |
| home | mobile | 4.58 s | 3.39 s | 2.87 s | **2.49 s** | 83 → 98 | 798 → 347 KiB |
| portfolio | desktop | 0.83 s | 0.65 s | 0.37 s | **0.36 s** | 99 → 100 | 609 → 259 KiB |
| portfolio | mobile | 3.98 s | 1.58 s | 2.64 s | **1.58 s** | 87 → 100 | 609 → 259 KiB |
| portfolio-detalhe | desktop | 0.97 s | 0.79 s | 0.66 s | **0.65 s** | 99 → 100 | 930 → 508 KiB |
| portfolio-detalhe | mobile | 3.98 s | 3.01 s | 2.71 s | **2.18 s** | 87 → 99 | 785 → 365 KiB |
| servicos | desktop | 0.77 s | 0.36 s | 0.37 s | **0.36 s** | 100 → 100 | 552 → 222 KiB |
| servicos | mobile | 3.68 s | 2.63 s | 1.58 s | **2.03 s** | 89 → 99 | 552 → 222 KiB |
| sobre | desktop | 0.61 s | 0.37 s | 0.37 s | **0.36 s** | 100 → 100 | 322 → 127 KiB |
| sobre | mobile | 3.01 s | 1.58 s | 1.60 s | **1.58 s** | 94 → 100 | 322 → 127 KiB |
FCP cai de 1,51 s para 0,91 s em todas as páginas no mobile. Acessibilidade,
boas práticas e SEO marcam 100 em todas as páginas nos dois presets, antes e
depois. CLS é 0,000 e TBT é 0 ms em todas — as metas de §6.6 para essas três
métricas já passavam e continuam passando.
**Contra a meta de LCP ≤ 2,5 s da §6.6: todas as páginas passam nos dois
presets.** Desktop com folga (máximo 0,65 s). No mobile o pior caso é a home a
2,49 s, ou seja **em cima da linha** — a pior das três execuções dela deu 2,57 s.
Tratar a home como aprovada por margem, não com folga.
Duas colunas intermediárias merecem leitura cuidadosa em vez de conclusão:
- `portfolio` mobile aparece pior na coluna do webp (2,64 s) do que na anterior
(1,58 s). É variância, não regressão: as três execuções daquela passada foram
1,59 / 2,64 / 2,78 s. A página é a mais instável do conjunto e a mediana pulou
de ponta. Na passada final as três deram 1,58 s.
- `servicos` mobile sobe de 1,58 s para 2,03 s da terceira para a quarta coluna,
pelo mesmo motivo (1,58 / 1,58 / 2,03).
É exatamente por isso que o script roda três vezes e reporta a mediana; ainda
assim, diferenças abaixo de meio segundo entre passadas não devem ser lidas como
efeito de uma correção.
Ressalva: a medição é local, então latência de origem e TLS não entram, e o
throttling de rede é simulado. Trate o LCP como piso, não como valor de campo.
## O que cada correção comprou
**Fontes servidas em dobro — 88 KiB fora do caminho crítico.** Bunny entrega
cada peso de EB Garamond em woff2 e woff, e o plugin de fontes emitia uma regra
`@font-face` para cada, woff2 primeiro e woff depois. Duas regras com a mesma
família, peso, estilo e unicode-range fazem a **última** vencer: o navegador
renderizava a partir dos woff e descartava os woff2 pré-carregados.
A prova está no log de rede da home antes da correção: 3 woff em prioridade
`VeryHigh` (88 KiB) — a prioridade mais alta da página, à frente do elemento de
LCP — somados a 3 woff2 em `High` (74 KiB) que só foram baixados porque estavam
em `<link rel="preload">`. 162 KiB de tráfego de fonte para 74 KiB de fonte útil.
woff2 é suportado por todo navegador que este site atende desde 2016, então as
regras woff não eram fallback e sim peso morto. O plugin `amare:fonts-woff2-only`
em `vite.config.js` remove as regras do CSS e do manifest e tira os arquivos do
bundle. É o que derruba o FCP de 1,51 s para 0,91 s em todas as páginas.
**Ativos de marca reencodados — 102 KiB fora do caminho crítico.** O logotipo
era servido a 512 px de largura para renderizar em 48 px (lockup, cabeçalho e
rodapé) e 32 px (mark, home): 84 KiB + 42 KiB com `loading="eager"` em todas as
páginas. Reencodados a 3× do maior render — lockup 149×144 (14 KiB) e mark
191×96 (10 KiB) —, mantendo os mesmos nomes de arquivo para não invalidar cache.
As variantes `on-dark` foram reencodadas junto por consistência; nenhuma view as
usa hoje.
Isto absorve MAN-122: os ativos de marca foram reencodados sem alterar a forma
ou a cor percebida, reduzindo apenas o peso transferido.
Os arquivos versionados aqui são: `2026-08-10-local-antes.md` (commit `2e43fde`),
`2026-08-10-local-etapa-fontes-e-marca.md` (passada intermediária) e
`2026-08-10-local-depois.md` (estado final).
**Variantes WebP nas imagens de conteúdo.** Com fontes e marca resolvidas, o
elemento de LCP de toda página no mobile era a imagem do hero, e o Load Delay de
1,88 s era contenção de banda pura: 143 KiB de JPEG q82 a 960 px, com as três
capas do portfólio somando outros 348 KiB. `ResponsiveImage::generate()` passa a
escrever uma variante `.webp` ao lado de cada variante no formato original, e
`x-media.image` a oferece num `<source type="image/webp">`. O `<img>` continua
apontando para o formato original, então nada quebra em quem não decodifica webp,
e mídia antiga sem irmãos webp renderiza `<img>` puro como antes.
**`sizes` que descreve a realidade.** Nenhuma imagem do site ocupa a viewport
inteira: todas ficam dentro de `container-amare`, que reserva 1,5rem de padding
de cada lado. Declarar `100vw` fazia uma viewport de 412 px em DPR 1,75 pedir
721 px e pular para a variante de 960 para desenhar uma caixa de 637 px — errar
por um pixel custava um terço a mais de bytes. Com `calc(100vw - 3rem)` a home
passa a usar a variante de 720 (**74 KiB**, contra 143 KiB no início).
Foi também por isso que 720 entrou em `ResponsiveImage::WIDTHS`: sem ela o salto
de 480 para 960 é grande demais para a viewport mobile mais comum.
Decomposição final do LCP da home no mobile:
| fase | tempo |
|---|---|
| TTFB | 0,45 s |
| Load Delay | 1,24 s |
| Load Time | 0,10 s |
| Render Delay | 0,69 s |
## O ganho de imagem só aparece depois do deploy regenerar as variantes
Toda a medição acima usou `FILESYSTEM_DISK=local`. Em staging e produção o disco
é `r2`, e lá as variantes `.webp` e a largura 720 **ainda não existem** para a
mídia já publicada. Até `media:generate-variants` rodar, `availableWebpVariants()`
volta vazio, o `<source>` é omitido e o `srcset` do formato original perde a
entrada de 720w — ou seja, nenhum dos dois ganhos de imagem aparece.
O serviço `migrate` do `docker-compose.deploy.yml` roda
`php artisan media:generate-variants` sem condição a cada deploy, depois de
`migrate` e do seed, e o comando só pula um caminho quando o arquivo original não
existe (`MediaGenerateVariantsCommand`) — regenera mesmo quando já há variantes.
Então o primeiro deploy desta branch produz as variantes novas por conta própria.
Enquanto isso não acontece, há um custo sem contrapartida: `x-media.image` faz
agora **8 chamadas `exists()` por imagem** (4 larguras × 2 formatos) contra o
object store, no lugar de 3. Na home são ~32 round trips remotos por request em
vez de ~12. Isso agrava a hipótese não validada abaixo em vez de melhorá-la, e é
mais um motivo para cachear esses metadados.
## Se a home precisar de mais folga
O Load Delay de 1,24 s ainda é contenção: as três capas do portfólio somam
155 KiB e, embora sejam `loading="lazy"` e prioridade `Low`, o navegador as busca
porque entram no limiar de lazy loading da viewport emulada. Os levers restantes,
em ordem de custo:
1. Baixar a qualidade webp de 80 para 75 (medido: 109 → 92 KiB na imagem do
hero a 960 px). Barato, mas mexe na qualidade de imagem de uma marca cujo
posicionamento é acabamento editorial — decisão de produto, não de engenharia.
2. Reduzir o Render Delay de 0,69 s, que agora é a segunda maior fatia e é
trabalho de main thread, não de rede.
3. FrankenPHP worker mode para o TTFB de 0,45 s. Tem gatilho objetivo em
SPEC §22 e não deve ser puxado antes dele.
## Cobertura que este trabalho não tem
Os testes browser não exercitam `srcset` nem `<picture>` com variantes geradas:
nem `ContentSeeder` nem `VisualContentSeeder` geram variantes. Nesses cenários,
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A
cobertura do caminho com variantes fica nos testes de feature
(`MediaImageComponentTest`).
## Staging
Não medido. A origem de staging responde `303` para
`blocked.teams.cloudflare.com` a partir da rede corporativa da Creditas
("O conteúdo deste site viola a Política de Segurança da Informação"), inclusive
em `/up`. O preflight de HTTP 200 do script barra a execução antes de gastar
minutos auditando páginas de bloqueio.
Duas hipóteses seguem **não validadas** porque só existem com
`FILESYSTEM_DISK=r2`, que é configuração de staging e produção:
- `x-media.image` chama `ResponsiveImage::availableVariants()`,
`availableWebpVariants()` (8 `exists()` somados) e `ResponsiveImage::dimensions()`
(que baixa o arquivo inteiro) a cada render, sem cache. No disco `local` são
leituras de filesystem; no `r2` são ~9 round trips remotos por imagem no lado
servidor, ~36 na home. Teste discriminante: comparar o TTFB de `/contato`
(zero imagens) com o de `/portfolio` (N imagens). Se o TTFB escalar com a
contagem de imagens, está confirmado. **Este é o item mais urgente da lista**,
porque as variantes webp multiplicaram o número de chamadas.
- A mídia vem de `R2_URL`, uma origem cross-origin, e não há `preconnect` no
`<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.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,37 @@
## Context
A suíte browser contém uma camada exclusiva de comparação de pixels: um teste com baselines versionados, um trait para estabilizar capturas, exportação de diffs e configuração de CI para seeds e artefatos determinísticos. O restante da cobertura browser — smoke, acessibilidade, motion e fixtures de conteúdo — não depende dessa camada.
## Goals / Non-Goals
**Goals:**
- Remover integralmente a comparação de pixels, os baselines e a infraestrutura exclusiva de suporte.
- Manter o job browser em FrankenPHP e suas verificações funcionais, de acessibilidade e motion.
- Atualizar as fontes normativas e as specs ativas para que não exijam snapshots visuais.
**Non-Goals:**
- Não substituir a comparação de pixels por outro serviço ou ferramenta de regressão visual.
- Não remover `VisualContentSeeder`, fixtures JPEG, `APP_FROZEN_NOW` que tenham usos fora das capturas, screenshots documentais ou testes browser não relacionados.
- Não alterar APIs públicas, rotas, conteúdo nem comportamentos de interface.
## Decisions
1. **Eliminar a capability em vez de reduzir a matriz.** Todos os requisitos de `visual-regression` serão removidos, pois o produto não terá mais comparação de pixels. Alternativa rejeitada: conservar apenas algumas telas, pois ainda manteria baselines e o custo operacional indesejado.
2. **Preservar diagnósticos gerais de browser.** O CI manterá screenshots, logs e relatórios úteis a falhas funcionais; serão removidos apenas seed global, relógio congelado e artefatos específicos de baseline/diff. Alternativa rejeitada: retirar todos os diagnósticos, pois reduziria a capacidade de investigar falhas não visuais.
3. **Separar determinismo reutilizável da captura visual.** `VisualContentSeeder` e `APP_FROZEN_NOW` permanecem quando usados por outros testes ou pela aplicação; somente as referências exclusivas a screenshot são excluídas. Alternativa rejeitada: remover os nomes por associação, pois quebraria fixtures e cenários existentes.
## Risks / Trade-offs
- [Uma mudança puramente visual deixa de bloquear CI] → acessibilidade, smoke, motion e revisão humana continuam no fluxo de qualidade.
- [Remover preparação comum do job browser afeta testes restantes] → inspecionar usos de seed/relógio e executar a suíte browser contra FrankenPHP após a alteração.
- [Uma spec ativa ainda reintroduz snapshots] → remover as menções no delta de `enhance-public-motion` antes da validação OpenSpec.
## Migration Plan
Publicar a remoção junto com a atualização da documentação e do CI; não há migração de dados. O rollback é um revert do commit, restaurando os arquivos versionados e a configuração anterior.
## Open Questions
Nenhuma.

View File

@@ -0,0 +1,25 @@
## Why
As comparações pixel-a-pixel e seus baselines adicionam manutenção e infraestrutura exclusiva sem serem um gate de qualidade desejado. A qualidade do site continuará protegida por jornadas browser, acessibilidade, motion, smoke e screenshots documentais, sem dependência de snapshots versionados.
## What Changes
- Remove a capability `visual-regression` e todos os requisitos de baselines, snapshots e diffs visuais.
- Atualiza os quality gates para manter a cobertura browser funcional, de acessibilidade e motion sem comparações de pixels.
- Remove a infraestrutura de testes, CI e documentação usada exclusivamente para regressão visual.
- **BREAKING** para contribuidores: o comando `composer visual:update` deixa de existir.
## Capabilities
### New Capabilities
Nenhuma.
### Modified Capabilities
- `visual-regression`: Remover os requisitos da capability descontinuada.
- `quality-gates`: Remover a exigência de executar e publicar diagnósticos de snapshots visuais.
## Impact
Afeta os testes browser e seus helpers exclusivos, baselines rastreados, configuração de Composer e CI, `.gitignore`, `SPEC.md` e a documentação ativa de motion. Não altera rotas, APIs, dados de conteúdo, fixtures reutilizadas, nem a cobertura funcional, de acessibilidade ou motion.

View File

@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: Browser tests run against FrankenPHP-served application
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, the automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison.
#### Scenario: Browser job validates served application
- **WHEN** the `browser` CI job runs
- **THEN** tests execute against the built application artifact or equivalent production-like image
#### Scenario: Browser job covers functional, accessibility and motion assertions
- **WHEN** the `browser` CI job runs
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge
#### Scenario: Browser failures publish diagnostics
- **WHEN** a browser test fails in CI
- **THEN** the job MUST publish screenshots, application logs and browser logs as diagnostics

View File

@@ -0,0 +1,13 @@
## REMOVED Requirements
### Requirement: Public screens have desktop and mobile visual baselines
**Reason**: O projeto não manterá mais comparação de pixels nem baselines versionados para telas públicas.
**Migration**: Remover as asserções de screenshot e os arquivos baseline; manter testes browser funcionais, de acessibilidade, motion e smoke.
### Requirement: Visual runs are deterministic
**Reason**: Não haverá execução de captura para comparação visual.
**Migration**: Preservar seeds e relógio congelado apenas onde forem necessários por outros testes.
### Requirement: Baseline updates are explicit and reviewed
**Reason**: O comando e o fluxo de atualização de baselines foram descontinuados junto com a capability.
**Migration**: Remover `composer visual:update` e qualquer configuração de CI dedicada a regenerar, transportar ou publicar baselines e diffs.

View File

@@ -0,0 +1,19 @@
## 1. Contratos normativos
- [x] 1.1 Remover os requisitos e critérios de regressão visual de `SPEC.md`, ADR-005 e do delta ativo `enhance-public-motion`.
- [x] 1.2 Sincronizar os deltas da mudança para remover a capability `visual-regression` e atualizar `quality-gates`.
## 2. Infraestrutura de regressão visual
- [x] 2.1 Remover o teste browser, os 16 baselines, o trait de screenshot, o exportador de diffs e seu teste unitário.
- [x] 2.2 Simplificar o `TestCase` e remover o script Composer e a regra de ignore exclusivos de baselines.
## 3. Browser CI e documentação operacional
- [x] 3.1 Remover seed global, relógio congelado e cópias de snapshots/diffs exclusivos de baseline do job browser, preservando diagnósticos funcionais.
- [x] 3.2 Atualizar a documentação operacional que referencia o fluxo descontinuado sem afetar fixtures ou congelamento usados fora dele.
## 4. Verificação e arquivamento
- [x] 4.1 Confirmar ausência dos símbolos e comandos removidos no código ativo e executar os gates de qualidade aplicáveis.
- [x] 4.2 Validar OpenSpec estritamente, marcar as tarefas e arquivar a mudança sincronizada.

View File

@@ -10,7 +10,7 @@ O site público usa Blade/Tailwind e já contém um runtime pequeno em `resource
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
- Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
- Cobrir marcação, comportamento real, acessibilidade e console.
**Non-Goals:**
@@ -32,15 +32,14 @@ O site público usa Blade/Tailwind e já contém um runtime pequeno em `resource
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe e console incluem sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
## Risks / Trade-offs
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
- [Snapshots ficam instáveis] → continuar capturando com `reducedMotion: reduce` e transições desabilitadas.
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é o baseline seguro; feature tests cobrem os cinco templates.
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é seguro; feature tests cobrem os cinco templates.
## Migration Plan

View File

@@ -9,7 +9,7 @@ O site público já possui uma abertura focal na home, mas o restante da experi
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade, console e regressão visual.
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade e console.
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
## Capabilities
@@ -23,7 +23,6 @@ O site público já possui uma abertura focal na home, mas o restante da experi
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
- `visual-regression`: Ampliar a matriz visual para sobre, contato, privacidade e 404, mantendo capturas determinísticas sem animação.
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
## Impact

View File

@@ -1,26 +0,0 @@
## MODIFIED Requirements
### Requirement: Public screens have desktop and mobile visual baselines
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio, Detalhe do portfólio, Sobre, Contato, Privacidade and branded 404, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
#### Scenario: Unintended visual change fails the suite
- **GIVEN** approved baselines exist
- **WHEN** a code change alters the rendering of a covered screen
- **THEN** the visual assertion MUST fail and report the diff
#### Scenario: Both viewports are covered
- **WHEN** the visual suite runs
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
#### Scenario: Heritage Editorial identity is captured
- **WHEN** approved baselines for the home are reviewed after this change
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
#### Scenario: Motion does not destabilize new baselines
- **WHEN** Sobre, Contato, Privacidade, or 404 is captured
- **THEN** the browser MUST use the final reduced-motion state before asserting the screenshot

View File

@@ -15,7 +15,7 @@
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
- [x] 3.4 Extend deterministic desktop/mobile visual snapshots to about, contact, privacy and branded 404 and review the generated diffs
- [x] 3.4 Extend axe and console route matrices to about, contact, privacy and branded 404
## 4. Verification and delivery

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,44 @@
## Context
O commit de base ja separa as imagens hero de Open Graph, oferece variantes responsivas, fallbacks tonais e preserva o formulario de briefing. As views publicas ainda usam algumas grades simetricas, especialmente nas colecoes de portfolio. A referencia aprovada define composicao e materialidade, nunca novo acervo ou copy.
## Goals / Non-Goals
**Goals:**
- Aplicar uma cadencia de campos tonais, regras e proporcoes alternadas nas paginas narrativas.
- Manter uma ordem DOM linear e completa em telas pequenas.
- Reforcar os contratos existentes de acessibilidade, motion progressivo, performance de imagem e SEO.
**Non-Goals:**
- Alterar dados CMS, contratos de rota, controller, metadata, uploads, variantes ou o formulario.
- Usar imagens externas, criar conteudo comercial, adicionar dependencias, atualizar snapshots ou reintroduzir regressao visual.
## Decisions
### Usar variacoes de `col-span` e `aspect-ratio` nas galerias existentes
As imagens continuam no mesmo componente responsivo, mas a primeira imagem e os itens pares recebem encaixes editoriais no desktop. A alternativa seria uma galeria JavaScript ou masonry; foi descartada por prejudicar a ordem de leitura e adicionar comportamento sem necessidade.
### Aplicar assimetria pela composicao, nao por posicionamento absoluto
Grid responsivo, margens e campos tonais criam o deslocamento sem tirar conteudo do fluxo. Isso preserva foco, leitura mobile e fallback sem JavaScript. Posicionamento absoluto foi descartado por aumentar risco de sobreposicao e overflow.
### Reutilizar motion de transformacao e clip ja existente
As novas regioes conservam `data-reveal` e grupos existentes. Nenhum texto passa a depender de opacidade; `prefers-reduced-motion` continua exibindo o estado final imediatamente.
## Risks / Trade-offs
- [Ritmo visual pode parecer irregular com poucos cases] -> regras e metadados mantem uma leitura coerente mesmo com um unico item.
- [Classes responsivas podem afetar o fluxo em mobile] -> DOM linear e classes de uma coluna continuam sendo a base abaixo de `md`.
- [Mudar CSS pode gerar diferencas de ambiente] -> validar markup, acessibilidade funcional e build; snapshots permanecem fora do escopo.
## Migration Plan
Nao ha migracao de dados. A mudanca e retrocompativel: sem imagens, os fallbacks tonais existentes continuam ativos; com imagens, o mesmo `srcset` responsivo e usado. Reverter o commit restaura somente apresentacao.
## Open Questions
Nenhuma. Fotografia autorizada de producao permanece uma dependencia de conteudo futura.

View File

@@ -0,0 +1,30 @@
## Why
O site público já possui os conteúdos, fluxos e a base Heritage Editorial, mas algumas páginas ainda repetem grades regulares que enfraquecem a cadência da referência aprovada. A recomposição consolida uma leitura editorial contínua sem alterar dados CMS, SEO ou a jornada de briefing.
## What Changes
- Recompõe as superfícies públicas narrativas com campos tonais, regras finas, assimetria desktop e sequência vertical legível em mobile.
- Organiza a home nos capítulos editoriais existentes, com portfólio em campo oliva e recortes de imagens em proporções alternadas.
- Reestiliza listagens e detalhe de portfólio para que imagens e blocos de leitura não dependam de uma grade de cartões repetida.
- Mantém contato, privacidade e erros em tratamento sóbrio; preserva menu móvel, foco, formulário, SEO, motion progressivo e conteúdo do CMS.
- Documenta que heros fotográficos e sua administração já estão presentes na base atual, sem acoplar mídia editorial à imagem Open Graph.
## Capabilities
### New Capabilities
- Nenhuma.
### Modified Capabilities
- `public-site-pages`: composição e cadência visual das rotas públicas passam a exigir ritmos editoriais assimétricos.
- `content-media`: a apresentação de mídia de portfólio passa a suportar proporções editoriais alternadas sem mudar armazenamento ou variantes.
## Impact
Afeta Blade e estilos do site público, testes de estrutura/renderização e a documentação OpenSpec. Não altera rotas, controladores, dados de conteúdo, dependências ou contratos de SEO.
## Non-goals
Não introduz itens excluídos por SPEC.md §4.2, novos canais, prova comercial, conteúdo de produção, imagens externas, dependências de animação, snapshots visuais ou mudanças no Filament.

View File

@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Editorial image treatment remains self-hosted and deterministic
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Editorial layouts MUST use CSS-only tonal treatment and alternating aspect ratios for portfolio media while retaining document order and the image component's `srcset`, `sizes`, dimensions and loading behavior. The system MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
#### Scenario: Public pages do not depend on external stock hosts
- **WHEN** the visual or browser suite loads covered public routes
- **THEN** content images MUST resolve from the application media disk or static fixtures
- **AND** MUST NOT require network access to third-party stock hosts
#### Scenario: Editorial portfolio media retains responsive delivery
- **WHEN** a portfolio listing or gallery applies an editorial image proportion
- **THEN** the rendered image MUST still expose the responsive media component markup
- **AND** image order and lazy-loading behavior MUST remain intact

View File

@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: Home renders the editorial structure from CMS content
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition: asymmetric spreads and tonal fields on desktop, with portfolio images in alternating editorial proportions, and a linear complete sequence on mobile rather than rounded card grids.
#### Scenario: Published content is displayed in configured order
- **GIVEN** published services, cases and testimonials exist
- **WHEN** a visitor loads the home
- **THEN** the published content MUST be displayed following the `sort_order` and featured flags
- **AND** the hero MUST show the values stored in `site_settings`
- **AND** the manifesto, method and positioning sections MUST be present
- **AND** the portfolio section MUST use an olive tonal field with editorial image proportions
#### Scenario: CTA leads to the contact placeholder page
- **WHEN** a visitor activates the primary or final CTA on the home
- **THEN** the visitor MUST be taken to the `contact` route
- **AND** no lead record MUST be created
#### Scenario: Empty catalog sections are omitted
- **GIVEN** no published services, cases or testimonials
- **WHEN** a visitor loads the home
- **THEN** the response MUST be 200
- **AND** the services, portfolio and testimonials sections MUST be omitted instead of rendering empty containers
- **AND** hero, manifesto, method, positioning and final CTA MUST still render
#### Scenario: Home has no console errors
- **WHEN** the home is loaded in a real browser at desktop and mobile viewports
- **THEN** the browser console MUST contain no JavaScript errors
### Requirement: Listing and detail pages exist for catalog content
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
#### Scenario: Services listing shows published services
- **WHEN** a visitor loads `/servicos`
- **THEN** every published service MUST be listed with title and summary in `sort_order`
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
#### Scenario: Gallery respects stored order
- **GIVEN** a published case with multiple gallery images
- **WHEN** a visitor loads the case detail
- **THEN** the images MUST be rendered ordered by `sort_order`
- **AND** desktop presentation MUST alternate editorial image proportions without changing that order
#### Scenario: Listings paginate open-ended growth
- **WHEN** the number of published cases exceeds the page size
- **THEN** `/portfolio` MUST paginate instead of rendering all records
### Requirement: Institutional and error pages have brand identity
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. Contact, privacy and error surfaces MUST remain sober tonal layouts without decorative photography. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
#### Scenario: Unknown URL renders branded 404
- **WHEN** a visitor requests a non-existent public URL
- **THEN** the response status MUST be 404
- **AND** the page MUST use the public layout and offer navigation back to the home
#### Scenario: Server error hides internals in production
- **GIVEN** `APP_DEBUG` is false
- **WHEN** an unhandled exception occurs on a public route
- **THEN** the response MUST be a generic branded error page
- **AND** MUST NOT contain a stack trace, file path, or environment variable

View File

@@ -0,0 +1,10 @@
## 1. Editorial composition
- [x] 1.1 Add regression coverage for alternating public portfolio composition and sober functional surfaces.
- [x] 1.2 Recompose home portfolio, portfolio listing and case gallery with responsive editorial proportions while preserving order and responsive media delivery.
- [x] 1.3 Refine public content-page rhythm with tonal fields and desktop offsets without changing routes, CMS content or briefing behavior.
## 2. Verification
- [x] 2.1 Run focused feature and browser checks for public routes, motion, accessibility and navigation.
- [x] 2.2 Run formatting, static analysis, frontend build and strict OpenSpec validation without updating snapshots.

View File

@@ -127,7 +127,7 @@ The system SHALL provide optimized Amare brand logo assets derived from the offi
### Requirement: Editorial image treatment remains self-hosted and deterministic
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that break deterministic visual tests.
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
#### Scenario: Public pages do not depend on external stock hosts

View File

@@ -1,7 +1,7 @@
# quality-gates Specification
## Purpose
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser visual and accessibility coverage.
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser functional, accessibility and motion coverage.
## Requirements
### Requirement: Standardized Composer test scripts exist
@@ -53,20 +53,20 @@ The system SHALL configure `phpunit.xml` so that the Feature test suite uses Pos
### Requirement: Browser tests run against FrankenPHP-served application
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, the visual regression assertions and the automated accessibility checks for public routes, and MUST run in assertion mode without regenerating baselines (SPEC §13.4, §13.5, §13.8, §14.1).
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison.
#### Scenario: Browser job validates served application
- **WHEN** the `browser` CI job runs
- **THEN** tests execute against the built application artifact or equivalent production-like image
#### Scenario: Browser job covers visual and accessibility assertions
#### Scenario: Browser job covers functional, accessibility and motion assertions
- **WHEN** the `browser` CI job runs
- **THEN** it MUST execute the visual regression suite and the accessibility suite
- **AND** a failing snapshot or a critical/serious accessibility issue MUST block merge
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge
#### Scenario: Browser failures publish diagnostics
- **WHEN** a browser test fails in CI
- **THEN** the job MUST publish screenshots, snapshot diffs, application logs and browser logs as artifacts
- **THEN** the job MUST publish application logs and browser logs as diagnostics

View File

@@ -1,60 +0,0 @@
# visual-regression Specification
## Purpose
Define deterministic visual baselines for public screens and the explicit baseline update workflow.
## Requirements
### Requirement: Public screens have desktop and mobile visual baselines
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio and Detalhe do portfólio, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
#### Scenario: Unintended visual change fails the suite
- **GIVEN** approved baselines exist
- **WHEN** a code change alters the rendering of a covered screen
- **THEN** the visual assertion MUST fail and report the diff
#### Scenario: Both viewports are covered
- **WHEN** the visual suite runs
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
#### Scenario: Heritage Editorial identity is captured
- **WHEN** approved baselines for the home are reviewed after this change
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
### Requirement: Visual runs are deterministic
Visual runs SHALL be deterministic per SPEC §13.5: fixed Chromium and Linux image, fixed viewport, timezone `America/Sao_Paulo`, locale `pt-BR`, self-hosted fonts installed/bundled for the suite, frozen clock, deterministic seed (including real testimonial subset and São Paulo settings), animations and transitions disabled, and no dependency on external network.
#### Scenario: Repeated run without code change produces no diff
- **WHEN** the visual suite runs twice against the same commit and seed
- **THEN** both runs MUST pass with no pixel diff
#### Scenario: Time-dependent content does not cause drift
- **GIVEN** the clock is frozen and the seed is deterministic
- **WHEN** the suite runs on a different calendar day
- **THEN** rendered dates MUST remain identical to the baseline
#### Scenario: Motion is disabled during capture
- **WHEN** a screenshot is captured
- **THEN** CSS animations and transitions MUST be disabled
### Requirement: Baseline updates are explicit and reviewed
Baselines SHALL only be updated through the explicit `composer visual:update` command, and the resulting diff MUST be reviewed by a human before merge. Baselines MUST NOT be regenerated automatically to make CI pass.
#### Scenario: CI does not regenerate baselines
- **WHEN** the `browser` CI job runs
- **THEN** it MUST run in assertion mode
- **AND** MUST NOT write new baselines
#### Scenario: Developer updates baselines intentionally
- **WHEN** a developer runs `composer visual:update`
- **THEN** the updated baseline files MUST be written to the versioned baseline directory for review

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

@@ -130,43 +130,6 @@
display: flex;
}
.home-chapters {
counter-reset: home-chapter -1;
}
.home-chapter {
counter-increment: home-chapter;
}
.home-folio {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--amare-color-accent);
font-size: var(--amare-text-xs);
font-weight: 600;
letter-spacing: 0.14em;
line-height: 1;
text-transform: uppercase;
}
.home-folio--inverse {
color: color-mix(in srgb, var(--amare-color-accent-text) 82%, transparent);
}
.home-folio__separator {
color: var(--amare-color-sage);
}
.home-folio--inverse .home-folio__separator {
color: color-mix(in srgb, var(--amare-color-accent-text) 55%, transparent);
}
.home-folio__number::before {
content: counter(home-chapter, decimal-leading-zero);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: no-preference) {
.main-nav {
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
@@ -181,85 +144,6 @@
overflow-x: clip;
}
/* Dossiê vivo — content visible by default; enhance only when opted in */
[data-chapter-index] {
display: none;
}
[data-chapter-progress] {
display: none;
pointer-events: none;
}
[data-chapter-index] a[aria-current="true"] {
color: var(--amare-color-accent-deep);
}
[data-chapter-index] a[aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0.35em;
bottom: 0.35em;
width: 1px;
background: var(--amare-color-accent);
}
[data-chapter-progress] > span {
display: block;
height: 100%;
width: var(--chapter-progress, 0%);
background: var(--amare-color-accent);
transform-origin: left center;
}
@media (min-width: 1280px) {
[data-chapter-index] {
display: flex;
position: fixed;
top: 50%;
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
z-index: 30;
max-width: 6.5rem;
translate: 0 -50%;
flex-direction: column;
gap: 0.75rem;
}
[data-chapter-index] a {
position: relative;
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding-left: 0.75rem;
font-size: var(--amare-text-xs);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--amare-color-muted);
text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
}
[data-chapter-index] a:hover {
color: var(--amare-color-accent);
}
}
@media (max-width: 1279px) {
[data-chapter-progress] {
display: block;
position: fixed;
top: 0;
left: 0;
z-index: 45;
width: 100%;
height: 2px;
background: transparent;
}
}
@media (prefers-reduced-motion: no-preference) {
:where(a, button, input, textarea, select) {
transition-duration: var(--amare-motion-feedback);

View File

@@ -84,86 +84,11 @@ function observeReveals(enhance) {
nodes.forEach((node) => observer.observe(node));
}
function setupChapterIndex() {
const index = document.querySelector('[data-chapter-index]');
const progress = document.querySelector('[data-chapter-progress] span');
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
if (!index || chapters.length === 0) {
return;
}
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
let framePending = false;
const setActive = (id) => {
links.forEach((link) => {
const isCurrent = link.getAttribute('href') === `#${id}`;
if (isCurrent) {
link.setAttribute('aria-current', 'true');
} else {
link.removeAttribute('aria-current');
}
});
};
const updateProgress = (ratio) => {
if (!progress) {
return;
}
const clamped = Math.min(1, Math.max(0, ratio));
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
};
const sync = () => {
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
let current = chapters[0];
chapters.forEach((chapter) => {
if (chapter.offsetTop <= marker) {
current = chapter;
}
});
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
const headingId = heading?.id
|| current.getAttribute('aria-labelledby')
|| `${current.dataset.chapter}-heading`;
setActive(headingId);
const doc = document.documentElement;
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
updateProgress(window.scrollY / max);
};
const scheduleSync = () => {
if (framePending) {
return;
}
framePending = true;
requestAnimationFrame(() => {
framePending = false;
sync();
});
};
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
sync();
window.addEventListener('scroll', scheduleSync, { passive: true });
window.addEventListener('resize', scheduleSync);
}
function bootMotion() {
initializeMotionDelays();
const enhance = enableEnhancement();
activatePageOpen(enhance);
observeReveals(enhance);
setupChapterIndex();
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
if (event.matches) {
document.documentElement.removeAttribute('data-motion');

View File

@@ -31,9 +31,14 @@
// dimensions, so it gets no attributes rather than wrong ones. The CSS
// classes still govern the rendered size in both cases — width/height only
// 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
? []
: ($mark ? ['width' => 512, 'height' => 257] : ['width' => 512, 'height' => 495]);
: ($mark ? ['width' => 191, 'height' => 96] : ['width' => 149, 'height' => 144]);
@endphp
<img

View File

@@ -1,15 +0,0 @@
@props([
'chapters' => [],
])
@if (count($chapters) > 0)
<nav data-chapter-index aria-label="Índice de capítulos">
@foreach ($chapters as $chapter)
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
@endforeach
</nav>
<div data-chapter-progress aria-hidden="true">
<span></span>
</div>
@endif

View File

@@ -7,14 +7,12 @@
'border-t border-amare-border bg-amare-bg-deep py-20',
'home-chapter' => $editorial,
]) data-chapter="final-cta" data-reveal-group>
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
@if ($editorial)
<x-home.folio label="Próximo passo" class="justify-center" />
@else
<div class="container-amare max-w-4xl space-y-6 py-4" data-reveal data-reveal-from="up">
@unless ($editorial)
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
@endif
@endunless
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted">
<p class="max-w-2xl text-amare-muted">
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
</p>
<div>

View File

@@ -1,20 +0,0 @@
@props([
'label',
'tone' => 'default',
])
@php
$tone = $tone === 'inverse' ? 'inverse' : 'default';
@endphp
<p
{{ $attributes->class([
'home-folio',
'home-folio--inverse' => $tone === 'inverse',
]) }}
data-home-folio
>
<span data-home-folio-label>{{ $label }}</span>
<span class="home-folio__separator" aria-hidden="true">·</span>
<span class="home-folio__number" data-home-folio-number aria-hidden="true"></span>
</p>

View File

@@ -4,16 +4,16 @@
<section
aria-labelledby="hero-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
class="home-chapter min-h-[100dvh] border-b border-amare-border bg-amare-bg"
data-chapter="hero"
data-motion="page-open"
>
<div class="container-amare pt-8 md:pt-10">
<x-home.folio label="Capa" data-motion-beat="folio" />
</div>
<div class="container-amare grid gap-12 pb-20 pt-10 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:pb-28 md:pt-14" data-reveal-group>
<div class="space-y-8">
<div @class([
'container-amare grid min-h-[100dvh] gap-12 py-20 md:py-10',
'md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-stretch' => filled($settings->hero_image_path),
'md:max-w-4xl md:items-center' => blank($settings->hero_image_path),
]) data-reveal-group @if (blank($settings->hero_image_path)) data-tonal-hero @endif>
<div class="flex flex-col justify-center space-y-8 md:py-12">
<div data-motion-beat="seal" class="flex items-center gap-4">
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@if (filled($settings->hero_eyebrow))
@@ -30,19 +30,12 @@
@endif
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a
href="{{ route('contact') }}"
data-testid="home-primary-cta"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
>
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover">
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a>
@if (filled($settings->hero_secondary_cta_label))
<a
href="{{ route('portfolio.index') }}"
class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
>
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif
@@ -53,16 +46,9 @@
@endif
</div>
@if (filled($settings->default_og_image_path))
<div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
<x-media.image
:path="$settings->default_og_image_path"
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
loading="eager"
fetchpriority="high"
sizes="(max-width: 768px) 100vw, 40vw"
class="img-editorial h-full w-full object-cover"
/>
@if (filled($settings->hero_image_path))
<div data-split-hero data-motion-beat="media" class="min-h-80 overflow-hidden bg-amare-bg-deep md:min-h-full" data-reveal-media>
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 768px) 100vw, 55vw" class="img-editorial h-full w-full object-cover" />
</div>
@endif
</div>

View File

@@ -8,14 +8,12 @@
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
@endphp
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
<div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up">
<x-home.folio label="Manifesto" />
<span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span>
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" data-chapter="manifesto">
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group>
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left">
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
</div>
<div class="space-y-6" data-reveal data-reveal-from="up">
<div class="space-y-6 md:col-span-7 md:col-start-6" data-reveal data-reveal-from="up">
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>

View File

@@ -7,10 +7,9 @@
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
@endphp
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-24" data-chapter="method">
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start" data-reveal-group>
<div class="space-y-4" data-reveal data-reveal-from="up">
<x-home.folio label="Método" />
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
<p class="text-amare-text-muted">{{ $intro }}</p>
<x-brand.logo

View File

@@ -3,25 +3,43 @@
])
@if ($cases->isNotEmpty())
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio">
<div class="container-amare space-y-10" data-reveal-group>
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
<x-home.folio label="Portfólio" tone="inverse" />
<div class="space-y-3">
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up">
<div class="md:col-span-7 md:col-start-5 space-y-3">
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
</div>
</div>
<div class="grid gap-10 md:grid-cols-2">
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio>
@foreach ($cases as $case)
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal data-reveal-from="up">
@php
$isFeature = $loop->first;
$isOffset = ! $isFeature && $loop->even;
@endphp
<article
@class([
'space-y-4 border-t border-amare-accent-text/30 pt-4',
'md:col-span-7' => $isFeature,
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
'md:col-span-5' => ! $isFeature && ! $isOffset,
])
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
data-reveal
data-reveal-from="up"
>
@if (filled($case->cover_image_path))
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
@class([
'img-editorial w-full object-cover',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/>
@endif
<div class="space-y-2">

View File

@@ -7,18 +7,27 @@
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
@endphp
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="positioning">
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
<div class="space-y-4" data-reveal data-reveal-from="up">
<x-home.folio label="A Amare" />
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="positioning">
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group>
<div class="space-y-4 md:col-span-5" data-reveal data-reveal-from="left">
<div class="flex items-center gap-4">
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span>
<p class="text-sm uppercase tracking-[0.12em] text-amare-text-muted">Assessoria boutique · São Paulo</p>
</div>
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
</div>
<div class="space-y-8" data-reveal data-reveal-from="up">
<div class="space-y-8 md:col-span-6 md:col-start-7" data-reveal data-reveal-from="up">
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
@if (filled($settings->about_image_path))
<div class="overflow-hidden bg-amare-bg-deep" data-reveal-media>
<x-media.image
:path="$settings->about_image_path"
:alt="$settings->about_image_alt ?: $settings->brand_name"
sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover"
/>
</div>
@endif
<ul class="grid gap-3 border-t border-amare-border">
@foreach ($principles as $principle)
<li class="border-b border-amare-border py-3 text-amare-text-muted">{{ $principle }}</li>

View File

@@ -3,11 +3,10 @@
])
@if ($services->isNotEmpty())
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="services">
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services">
<div class="container-amare space-y-10" data-reveal-group>
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
<div class="space-y-3 md:col-span-4">
<x-home.folio label="Serviços" />
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
</div>
<div class="space-y-4 md:col-span-6 md:col-start-7">

View File

@@ -9,21 +9,23 @@
@endphp
@if ($testimonials->isNotEmpty())
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
<div class="container-amare space-y-8" data-reveal-group>
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
<x-home.folio label="Depoimentos" />
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials">
<div class="container-amare space-y-10" data-reveal-group>
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up">
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
</div>
<div class="grid gap-6 md:grid-cols-2">
<div class="grid gap-6 md:grid-cols-12">
@foreach ($testimonials as $testimonial)
@php
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
@endphp
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
<blockquote @class([
'space-y-4 border-t border-amare-border pt-4 md:col-span-5' => $loop->odd,
'space-y-4 border-t border-amare-border pt-4 md:col-span-5 md:col-start-7 md:mt-16' => $loop->even,
]) data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
<span class="text-3xl leading-none text-amare-sage" aria-hidden="true"></span>
<div class="space-y-3 text-lg text-amare-text">

View File

@@ -1,7 +1,11 @@
@props([
'path',
'alt',
'sizes' => '(max-width: 768px) 100vw, 960px',
// No image on the site spans the full viewport: every one of them sits inside
// `container-amare`, which reserves 1.5rem of padding on each side. Claiming
// 100vw made a 412 px viewport at DPR 1.75 ask for 721 px and jump to the
// 960 variant for a box it draws at 637 px.
'sizes' => '(max-width: 768px) calc(100vw - 3rem), 960px',
'loading' => 'lazy',
'fetchpriority' => null,
'width' => null,
@@ -18,17 +22,26 @@
$diskName = $disk ?? PublicImageUploadRules::disk();
$filesystem = Storage::disk($diskName);
$src = $filesystem->url($path);
$variants = ResponsiveImage::availableVariants($path, $diskName);
$srcset = collect($variants)
$toSrcset = fn (array $variants): string => collect($variants)
->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w')
->implode(', ');
if ($srcset === '' && $filesystem->exists($path)) {
$srcset = null;
}
// All public rendering reads one durable metadata record. On a cache hit this
// avoids both existence probes and downloading the original from R2.
$metadata = ResponsiveImage::metadata($path, $diskName);
$variants = $metadata['variants'] ?? [];
$srcset = $toSrcset($variants);
// Offered ahead of the original format because webp carries the same picture
// for roughly a third of the bytes, and the MAN-109 audit found the hero
// image to be the LCP element on every page at mobile widths. Media uploaded
// before `media:generate-variants` learned to emit webp has no siblings, so
// the <source> is skipped rather than pointed at nothing.
$webpSrcset = $toSrcset($metadata['webp_variants'] ?? []);
$dimensions = ($width === null || $height === null)
? ResponsiveImage::dimensions($path, $diskName)
? $metadata
: null;
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
@@ -36,9 +49,17 @@
$loadingValue = $loading;
@endphp
{{-- `display: contents` keeps <picture> out of the layout: the callers style the
<img> with classes like `h-full w-full object-cover` that resolve against the
grid or flex parent, and an inline wrapper would break that. --}}
@if ($webpSrcset !== '')
<picture class="contents">
<source type="image/webp" srcset="{{ $webpSrcset }}" sizes="{{ $sizes }}">
@endif
<img
src="{{ $src }}"
@if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
@if ($srcset) srcset="{{ $srcset }}" @endif
sizes="{{ $sizes }}"
alt="{{ $alt }}"
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
@@ -47,3 +68,6 @@
@if ($class) class="{{ $class }}" @endif
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
>
@if ($webpSrcset !== '')
</picture>
@endif

View File

@@ -0,0 +1,76 @@
@props([
'imagePath' => null,
'imageAlt' => null,
'eyebrow' => null,
'title',
'summary' => null,
'metadata' => null,
'headingId' => 'hero-heading',
'brandMark' => false,
])
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
@if (filled($imagePath))
<div class="relative flex min-h-[calc(100dvh-5.5rem)] items-end overflow-hidden" data-photo-hero>
<x-media.image
:path="$imagePath"
:alt="$imageAlt ?: $title"
loading="eager"
fetchpriority="high"
sizes="100vw"
class="img-editorial absolute inset-0 h-full w-full object-cover"
data-motion-beat="media"
/>
<div class="container-amare relative z-10 w-full py-8 md:py-12">
<div class="max-w-3xl border border-amare-border bg-amare-bg p-6 text-amare-text md:p-10" data-motion-beat="heading">
@if ($brandMark || filled($eyebrow))
<div class="mb-5 flex items-center gap-4" data-motion-beat="seal">
@if ($brandMark)
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@endif
@if (filled($eyebrow))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
@endif
</div>
@endif
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">{{ $title }}</h1>
@if (filled($summary))
<p class="mt-5 max-w-2xl text-lg text-amare-text-muted">{{ $summary }}</p>
@endif
@if (filled($metadata))
<p class="mt-4 text-sm break-words text-amare-text-muted">{{ $metadata }}</p>
@endif
@if (trim((string) $slot) !== '')
<div class="mt-7" data-motion-beat="cta">{{ $slot }}</div>
@endif
</div>
</div>
</div>
@else
<div class="container-amare py-16 md:py-24" data-tonal-hero>
<div class="max-w-3xl space-y-4" data-motion-beat="heading">
@if ($brandMark || filled($eyebrow))
<div class="flex items-center gap-4" data-motion-beat="seal">
@if ($brandMark)
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@endif
@if (filled($eyebrow))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
@endif
</div>
@endif
<h1 id="{{ $headingId }}" data-motion-beat="title" class="text-headline font-medium tracking-tight text-amare-text">{{ $title }}</h1>
@if (filled($summary))
<p class="text-lg text-amare-muted">{{ $summary }}</p>
@endif
@if (filled($metadata))
<p class="text-sm break-words text-amare-muted">{{ $metadata }}</p>
@endif
@if (trim((string) $slot) !== '')
<div data-motion-beat="cta">{{ $slot }}</div>
@endif
</div>
</div>
@endif
</section>

View File

@@ -10,6 +10,24 @@
<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'])

View File

@@ -8,30 +8,12 @@
$city = $siteSettings->city ?: 'São Paulo - SP';
@endphp
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
<div class="space-y-6" data-motion-beat="heading">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
<p class="text-amare-muted">
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
presença no dia do evento e uma condução serena do início ao fim.
</p>
@if (filled($siteSettings->about_image_path))
<div class="overflow-hidden bg-amare-bg-deep">
<x-media.image
:path="$siteSettings->about_image_path"
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
loading="lazy"
sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover"
/>
</div>
@endif
</div>
<x-public.photo-hero :image-path="$siteSettings->about_image_path" :image-alt="$siteSettings->about_image_alt" eyebrow="A Amare" title="Humana no cuidado. Precisa na entrega." :summary="$siteSettings->about_summary">
<p class="max-w-xl text-amare-text-muted">A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo, presença no dia do evento e uma condução serena do início ao fim.</p>
</x-public.photo-hero>
<div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare py-16 md:py-24">
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare" data-reveal-group>
@foreach ($principles as $index => $principle)
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">

View File

@@ -1,36 +1,6 @@
@extends('layouts.public')
@section('content')
@php
$testimonials = $content->testimonials
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
->values();
$chapters = [
['id' => 'hero-heading', 'label' => 'Capa'],
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
];
if ($content->featuredServices->isNotEmpty()) {
$chapters[] = ['id' => 'services-heading', 'label' => 'Serviços'];
}
if ($content->featuredCases->isNotEmpty()) {
$chapters[] = ['id' => 'portfolio-heading', 'label' => 'Portfólio'];
}
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
if ($testimonials->isNotEmpty()) {
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
}
$chapters[] = ['id' => 'positioning-heading', 'label' => 'A Amare'];
$chapters[] = ['id' => 'final-cta-heading', 'label' => 'Próximo passo'];
@endphp
<x-home.chapter-index :chapters="$chapters" />
<div class="home-chapters">
<x-home.hero :settings="$content->settings" />
<x-home.manifesto :settings="$content->settings" />

View File

@@ -1,29 +1,56 @@
@extends('layouts.public')
@section('content')
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
<x-public.photo-hero :image-path="$siteSettings->portfolio_hero_image_path" :image-alt="$siteSettings->portfolio_hero_image_alt" eyebrow="Portfólio" title="Atmosferas que contam histórias." summary="Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência." />
<div class="border-b border-amare-border bg-amare-bg-deep">
<div class="container-amare space-y-10 py-16 md:py-24">
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
</div>
@if ($cases->isEmpty())
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
@else
<div class="grid gap-10 md:grid-cols-2" data-reveal-group>
@php
$hasPrioritizedImage = false;
@endphp
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio-grid data-reveal-group>
@foreach ($cases as $case)
<article class="space-y-4" data-reveal data-reveal-from="up">
@php
$isFeature = $loop->first;
$isOffset = ! $isFeature && $loop->even;
@endphp
<article
@class([
'space-y-4',
'md:col-span-7' => $isFeature,
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
'md:col-span-5' => ! $isFeature && ! $isOffset,
])
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
data-reveal
data-reveal-from="up"
>
@if (filled($case->cover_image_path))
@php
$prioritizeImage = ! $hasPrioritizedImage;
@endphp
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]"
:loading="$prioritizeImage ? 'eager' : 'lazy'"
:fetchpriority="$prioritizeImage ? 'high' : null"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
@class([
'img-editorial w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/>
</a>
@php
$hasPrioritizedImage = true;
@endphp
@endif
<div class="space-y-2 border-t border-amare-border pt-4">
<h2 class="text-2xl font-medium text-amare-text">

View File

@@ -1,34 +1,8 @@
@extends('layouts.public')
@section('content')
<article data-motion="page-open">
<div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-8 py-16 md:py-24">
<header class="max-w-3xl space-y-4" data-motion-beat="heading">
@if (filled($case->event_type))
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
@endif
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
<p class="text-sm break-words text-amare-muted">
@if ($case->city){{ $case->city }}@endif
@if ($case->venue) · {{ $case->venue }}@endif
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
</p>
</header>
@if (filled($case->cover_image_path))
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
loading="eager"
sizes="(max-width: 1024px) 100vw, 1120px"
class="img-editorial aspect-[16/9] w-full object-cover"
data-motion-beat="media"
/>
@endif
</div>
</div>
<article>
<x-public.photo-hero :image-path="$case->cover_image_path" :image-alt="$case->cover_image_alt" :eyebrow="$case->event_type" :title="$case->title" :summary="$case->summary" :metadata="collect([$case->city, $case->venue, $case->event_date?->format('d/m/Y')])->filter()->implode(' · ')" />
<div class="border-b border-amare-border bg-amare-bg-deep">
<div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
@@ -53,14 +27,34 @@
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
<div class="container-amare space-y-8 py-16">
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
<div class="grid gap-6 md:grid-cols-2" data-reveal-group>
<div class="grid gap-6 md:grid-cols-12 md:gap-x-8 md:gap-y-12" data-editorial-gallery data-reveal-group>
@foreach ($case->images as $image)
<figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
@php
$isFeature = $loop->first;
$isOffset = ! $isFeature && $loop->even;
@endphp
<figure
@class([
'space-y-2',
'md:col-span-7' => $isFeature,
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
'md:col-span-5' => ! $isFeature && ! $isOffset,
])
data-editorial-gallery-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
data-reveal
data-reveal-from="up"
data-reveal-media
>
<x-media.image
:path="$image->path"
:alt="$image->alt_text"
sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
@class([
'img-editorial w-full object-cover',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/>
@if (filled($image->caption))
<figcaption class="text-sm text-amare-muted">{{ $image->caption }}</figcaption>

View File

@@ -1,20 +1,21 @@
@extends('layouts.public')
@section('content')
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
<x-public.photo-hero :image-path="$siteSettings->services_hero_image_path" :image-alt="$siteSettings->services_hero_image_alt" eyebrow="Serviços" title="Uma mesma excelência, diferentes ocasiões." summary="O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento." />
<div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-10 py-16 md:py-24">
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
</div>
@if ($services->isEmpty())
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
@else
<div class="divide-y divide-amare-border border-y border-amare-border" data-reveal-group>
@php($hasPrioritizedImage = false)
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
@foreach ($services as $index => $service)
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start" data-reveal data-reveal-from="up">
<article @class([
'grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start',
'md:translate-x-8' => $loop->even,
]) data-reveal data-reveal-from="up">
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<div class="space-y-3">
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
@@ -22,12 +23,16 @@
</div>
<div class="space-y-4">
@if (filled($service->cover_image_path))
@php($prioritizeImage = ! $hasPrioritizedImage)
<x-media.image
:path="$service->cover_image_path"
:alt="$service->cover_image_alt ?: $service->title"
sizes="(max-width: 768px) 100vw, 40vw"
:loading="$prioritizeImage ? 'eager' : 'lazy'"
:fetchpriority="$prioritizeImage ? 'high' : null"
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
class="img-editorial aspect-[16/10] w-full object-cover"
/>
@php($hasPrioritizedImage = true)
@endif
@if (filled($service->description))
<div class="text-amare-muted">

View File

@@ -8,6 +8,18 @@
# Usage:
# 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:
#
# docker build -t amare-app:ci .
@@ -20,7 +32,11 @@
# -v "$(pwd)/storage/app/public:/app/storage/app/public" \
# 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,
# because the originals are served at full size:
#
@@ -30,7 +46,11 @@
set -euo pipefail
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
# `npx playwright install chromium`; fall back to a system Chrome.
@@ -45,23 +65,73 @@ fi
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
path="${entry%%:*}"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "${BASE_URL}${path}" 2>/dev/null || true)"
if [[ "${code}" != "200" ]]; then
echo "preflight failed: ${BASE_URL}${path} answered HTTP ${code:-<none>}, expected 200" >&2
exit 1
fi
echo " ok ${path}"
done
for preset in ${PRESETS}; do
# The mobile preset is Lighthouse's default and rejects an explicit
# --preset flag, so only desktop is passed through.
preset_flags=()
if [[ "${preset}" != "mobile" ]]; then
preset_flags+=("--preset=${preset}")
fi
for entry in "${PAGES[@]}"; do
path="${entry%%:*}"
name="${entry##*:}"
echo "auditing ${name} (${BASE_URL}${path})"
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. Add --preset=desktop for the desktop numbers.
# 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}-mobile" \
--output-path="${OUT_DIR}/${name}-${preset}-run${run}" \
--chrome-flags="--headless=new --no-sandbox"
done
done
done
echo
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)$(git diff --quiet HEAD 2>/dev/null || echo '+alterações não commitadas')" \
--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"

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

@@ -1,19 +0,0 @@
# Fix visual regression baselines (CI-parity)
- [x] 1. Worktree `fix/visual-baselines-ci` a partir de `origin/main` (.worktrees/visual-baselines-ci @ 0aee15e)
- [x] 2. Postgres `amare_test` up (amare-postgres reutilizado, DB criado)
- [x] 3. Build `amare-app:ci` + container amare-web com env idêntica ao job browser (APP_FROZEN_NOW etc.) — /up ok
- [x] 4. Build imagem amare-ci-runner (ubuntu:24.04 + PHP 8.4 + node 22 + playwright deps)
- [x] 5. Regenerar 16 snapshots via `php artisan test --testsuite=Browser --update-snapshots` dentro do ci-runner (network host)
- [x] 6. Verificar: re-run Browser suite verde (35/35)
- [x] 7. Commit .snap, push, PR #24, CI verde; bump commonmark 2.9.0 p/ auditoria; merged
# PR #23 — cadência editorial à home (sync + merge)
- [x] 1. Reset worktree home-editorial-cadence p/ origin/feat/home-editorial-cadence (8ae5824, incl. reconcile)
- [x] 2. Merge origin/main (PR #24): resolver conflitos home snaps (theirs); 14 snaps não-home via auto-merge
- [x] 3. Rebuild amare-app:ci com código PR #23; container amare-web recriado
- [x] 4. Regenerar home desktop/mobile snaps no ci-runner (--update-snapshots); commit d9751b1
- [x] 5. Verificação: Browser suite 37 passed (156 assertions) — inclui HomeEditorialCadenceTest/MotionTest
- [x] 6. Push; CI 5/5 verde (run 31230461189)
- [x] 7. Merge squash PR #23 (commit 42b282c1); worktree/branch/remota limpos

View File

@@ -23,7 +23,6 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
$layout = $page->script(<<<'JS'
() => {
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
const clippedValues = new Set(['clip', 'hidden']);
const textNodes = Array.from(document.querySelectorAll(
'.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span'
@@ -36,10 +35,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
const current = chapter.getBoundingClientRect();
return current.top < previous.bottom - 1;
}),
overflowingFolios: folios.filter((folio) => {
const rect = folio.getBoundingClientRect();
return rect.left < -1 || rect.right > window.innerWidth + 1;
}).length,
ornamentalFolios: document.querySelectorAll('[data-home-folio]').length,
clippedText: textNodes.filter((node) => {
const style = getComputedStyle(node);
const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
@@ -53,7 +49,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
expect($layout)->toBe([
'horizontalOverflow' => false,
'overlappingChapters' => false,
'overflowingFolios' => 0,
'ornamentalFolios' => 0,
'clippedText' => 0,
]);
}

View File

@@ -51,8 +51,8 @@ it('keeps every home motion target in final visible state when reduced motion is
expect($state['heroActive'])->toBeTrue();
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
expect($state['hasIndex'])->toBeTrue();
expect($state['hasActiveChapter'])->toBeTrue();
expect($state['hasIndex'])->toBeFalse();
expect($state['hasActiveChapter'])->toBeFalse();
expect($state['finalReveals'])->toBeTrue();
expect($state['enhancementEnabled'])->toBeFalse();
});
@@ -259,51 +259,7 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
}
});
it('updates the active chapter while scrolling the home dossier', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->resize(1440, 1000);
$before = $page->script(<<<'JS'
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
JS);
$page->script(<<<'JS'
() => {
const target = document.querySelector('#method-heading');
target?.scrollIntoView({ block: 'start' });
window.dispatchEvent(new Event('scroll'));
return Boolean(target);
}
JS);
$page->script(<<<'JS'
() => new Promise((resolve) => {
let frames = 0;
const check = () => {
const href = document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href');
if (href === '#method-heading' || frames > 60) {
resolve(href);
return;
}
frames += 1;
requestAnimationFrame(check);
};
check();
})
JS);
$after = $page->script(<<<'JS'
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
JS);
expect($before)->not->toBeNull();
expect($after)->toBe('#method-heading');
});
it('applies CSS numbering to only rendered home folios in chapter order', function (): void {
it('keeps rendered home chapters free of ornamental folios and counters', function (): void {
PortfolioCase::factory()->published()->create([
'is_featured' => true,
]);
@@ -315,7 +271,7 @@ it('applies CSS numbering to only rendered home folios in chapter order', functi
'reducedMotion' => 'reduce',
]);
$numbering = $page->script(<<<'JS'
$editorial = $page->script(<<<'JS'
() => {
const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
@@ -324,26 +280,12 @@ it('applies CSS numbering to only rendered home folios in chapter order', functi
return {
reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
labels: folios.map((folio) => folio.querySelector('[data-home-folio-label]')?.textContent?.trim() ?? ''),
contents: folios.map((folio) => {
const number = folio.querySelector('[data-home-folio-number]');
return number ? getComputedStyle(number, '::before').content.replace(/["']/g, '') : '';
}),
folioCount: folios.length,
};
}
JS);
expect($numbering['reset'])->toContain('home-chapter -1');
expect($numbering['increments'])->each->toContain('home-chapter 1');
expect($numbering['labels'])->toBe([
'Capa',
'Manifesto',
'Serviços',
'Portfólio',
'Método',
'Depoimentos',
'A Amare',
'Próximo passo',
]);
expect($numbering['contents'])->each->toBe('counter(home-chapter, decimal-leading-zero)');
expect($editorial['reset'])->toBe('none');
expect($editorial['increments'])->each->toBe('none');
expect($editorial['folioCount'])->toBe(0);
});

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\SiteSetting;
use Database\Seeders\VisualContentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
beforeEach(function (): void {
putenv('APP_FROZEN_NOW='.VisualContentSeeder::FROZEN_NOW);
$_ENV['APP_FROZEN_NOW'] = VisualContentSeeder::FROZEN_NOW;
$_SERVER['APP_FROZEN_NOW'] = VisualContentSeeder::FROZEN_NOW;
$this->refreshApplication();
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
SiteSetting::instance();
});
afterEach(function (): void {
putenv('APP_FROZEN_NOW');
unset($_ENV['APP_FROZEN_NOW'], $_SERVER['APP_FROZEN_NOW']);
});
$screens = [
'home' => '/',
'services' => '/servicos',
'portfolio' => '/portfolio',
'portfolio-detail' => '/portfolio/casamento-ana-lucas',
'about' => '/sobre',
'contact' => '/contato',
'privacy' => '/privacidade',
'not-found' => '/__missing-visual-page',
];
$viewports = [
'desktop' => [1440, 1000],
'mobile' => [390, 844],
];
foreach ($screens as $screen => $path) {
foreach ($viewports as $viewport => [$width, $height]) {
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
$this->assertStableScreenshotMatches(
$this->visit($path, [
'reducedMotion' => 'reduce',
])
->withLocale('pt-BR')
->withTimezone('America/Sao_Paulo')
->resize($width, $height),
resetScroll: $path === '/',
);
});
}
}

View File

@@ -111,6 +111,48 @@ class SiteSettingsTest extends TestCase
->assertHasNoFormErrors();
}
public function test_admin_can_upload_a_home_hero_image_with_alt_text(): void
{
Storage::fake('public');
$admin = User::factory()->admin()->create();
SiteSetting::instance();
$this->actingAs($admin);
Livewire::test(ManageSiteSettings::class)
->set('data.hero_image_path', [
UploadedFile::fake()->image('hero.jpg', 1600, 1000),
])
->set('data.hero_image_alt', 'Convidados em uma celebração ao ar livre')
->call('save')
->assertHasNoFormErrors();
$settings = SiteSetting::instance()->refresh();
$this->assertSame('Convidados em uma celebração ao ar livre', $settings->hero_image_alt);
$this->assertNotNull($settings->hero_image_path);
Storage::disk('public')->assertExists($settings->hero_image_path);
}
public function test_home_hero_image_requires_alt_text(): void
{
Storage::fake('public');
$admin = User::factory()->admin()->create();
SiteSetting::instance();
$this->actingAs($admin);
Livewire::test(ManageSiteSettings::class)
->set('data.hero_image_path', [
UploadedFile::fake()->image('hero.jpg', 1600, 1000),
])
->set('data.hero_image_alt', null)
->call('save')
->assertHasFormErrors(['hero_image_alt' => 'required']);
}
public function test_content_seeder_includes_default_og_image_alt_text(): void
{
Storage::fake('public');

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Service;
use App\Models\SiteSetting;
use App\Support\ResponsiveImage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
@@ -37,4 +39,48 @@ class MediaGenerateVariantsCommandTest extends TestCase
Storage::disk('public')->assertExists(ResponsiveImage::variantPath($path, $width));
}
}
public function test_command_invalidates_metadata_for_a_missing_original(): void
{
config(['cache.default' => 'array']);
Cache::store('array')->flush();
Storage::fake('public');
$manager = new ImageManager(new Driver);
$path = 'content/services/missing.jpg';
Storage::disk('public')->put($path, (string) $manager->create(1400, 900)->toJpeg());
ResponsiveImage::generate($path, 'public');
ResponsiveImage::metadata($path, 'public');
Storage::disk('public')->delete($path);
Service::factory()->published()->create([
'cover_image_path' => $path,
]);
$exitCode = Artisan::call('media:generate-variants');
$this->assertSame(0, $exitCode);
$this->assertNull(ResponsiveImage::metadata($path, 'public'));
}
public function test_command_backfills_variants_for_the_home_hero_image(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
$path = 'content/home/hero.jpg';
Storage::disk('public')->put($path, (string) $manager->create(1600, 1000)->toJpeg());
SiteSetting::instance()->update([
'hero_image_path' => $path,
'hero_image_alt' => 'Celebração ao ar livre',
]);
$exitCode = Artisan::call('media:generate-variants');
$this->assertSame(0, $exitCode);
foreach (ResponsiveImage::WIDTHS as $width) {
Storage::disk('public')->assertExists(ResponsiveImage::variantPath($path, $width));
}
}
}

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

@@ -129,6 +129,41 @@ class HomePageContentTest extends TestCase
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
}
public function test_home_hero_uses_its_own_cms_image_instead_of_the_social_og_image(): void
{
SiteSetting::instance()->update([
'hero_image_path' => 'content/home/hero.jpg',
'hero_image_alt' => 'Celebração ao ar livre',
'default_og_image_path' => 'content/og/social.jpg',
'default_og_image_alt' => 'Imagem social',
]);
$response = $this->get(route('home'))
->assertOk()
->assertSee('content/home/hero.jpg', false)
->assertSee('alt="Celebração ao ar livre"', false);
$this->assertMatchesRegularExpression(
'/data-motion-beat="media"[^>]*>\s*<img[^>]*content\/home\/hero\.jpg/s',
$response->getContent(),
);
}
public function test_home_hero_keeps_an_intentional_typographic_fallback_without_media(): void
{
SiteSetting::instance()->update([
'hero_image_path' => null,
'hero_image_alt' => null,
]);
$this->get(route('home'))
->assertOk()
->assertSee('min-h-[100dvh]', false)
->assertSee('id="hero-heading"', false)
->assertSee('data-testid="home-primary-cta"', false)
->assertDontSee('data-motion-beat="media"', false);
}
public function test_blank_quote_testimonials_are_skipped(): void
{
Testimonial::factory()->published()->create([
@@ -180,8 +215,8 @@ class HomePageContentTest extends TestCase
'Segunda autora',
], false);
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="left"'));
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="right"'));
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="left"/i', $response->getContent()));
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="right"/i', $response->getContent()));
}
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
@@ -198,7 +233,7 @@ class HomePageContentTest extends TestCase
->assertDontSee('Relato vazio');
}
public function test_home_folios_follow_rendered_chapter_order_and_hide_decorative_numbers(): void
public function test_home_removes_ornamental_folios_and_counter_css(): void
{
SiteSetting::instance();
@@ -208,54 +243,15 @@ class HomePageContentTest extends TestCase
$html = $this->get(route('home'))->assertOk()->getContent();
$this->assertSame([
'Capa',
'Manifesto',
'Serviços',
'Portfólio',
'Método',
'Depoimentos',
'A Amare',
'Próximo passo',
], $this->folioLabels((string) $html));
$this->assertSame(8, preg_match_all('/data-home-folio-number[^>]*aria-hidden="true"/i', (string) $html));
$this->assertStringNotContainsString('data-home-folio', (string) $html);
$this->assertStringNotContainsString('home-folio', (string) $html);
$this->assertSame(1, preg_match_all('/<h1\b/i', (string) $html));
$css = (string) file_get_contents(resource_path('css/app.css'));
$this->assertStringContainsString('counter-reset: home-chapter -1', $css);
$this->assertStringContainsString('counter-increment: home-chapter', $css);
$this->assertStringContainsString('counter(home-chapter, decimal-leading-zero)', $css);
}
public function test_home_folios_are_omitted_with_conditional_chapters_without_leaving_markup_gaps(): void
{
SiteSetting::instance();
$html = $this->get(route('home'))->assertOk()->getContent();
$this->assertSame([
'Capa',
'Manifesto',
'Método',
'A Amare',
'Próximo passo',
], $this->folioLabels((string) $html));
$this->assertSame(5, preg_match_all('/data-home-folio-number[^>]*aria-hidden="true"/i', (string) $html));
}
/** @return list<string> */
private function folioLabels(string $html): array
{
preg_match_all(
'/<span\b[^>]*data-home-folio-label[^>]*>\s*([^<]+?)\s*<\/span>/is',
$html,
$matches,
);
return array_values(array_map(
static fn (string $label): string => trim(html_entity_decode($label)),
$matches[1],
));
$this->assertStringNotContainsString('counter-reset: home-chapter', $css);
$this->assertStringNotContainsString('counter-increment: home-chapter', $css);
$this->assertStringNotContainsString('counter(home-chapter', $css);
$this->assertFileDoesNotExist(resource_path('views/components/home/folio.blade.php'));
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Filament\Pages\ManageSiteSettings;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class ImmersivePhotoHeroTest extends TestCase
{
use RefreshDatabase;
public function test_home_hero_uses_its_own_image_without_replacing_open_graph_metadata(): void
{
Storage::fake('public');
Storage::disk('public')->put('content/heroes/home.jpg', 'home image');
$settings = SiteSetting::instance();
$settings->update([
'hero_image_path' => 'content/heroes/home.jpg',
'hero_image_alt' => 'Mesa preparada para uma celebração',
'default_og_image_path' => 'content/og/social.jpg',
'default_og_image_alt' => 'Imagem exclusiva das redes sociais',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('data-split-hero', false)
->assertSee('content/heroes/home.jpg', false)
->assertSee('loading="eager"', false)
->assertSee('fetchpriority="high"', false)
->assertSee('sizes="(max-width: 768px) 100vw, 55vw"', false)
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
}
public function test_public_openings_fall_back_to_tonal_layout_when_no_image_is_configured(): void
{
$settings = SiteSetting::instance();
$settings->update([
'hero_image_path' => null,
'hero_image_alt' => null,
'services_hero_image_path' => null,
'services_hero_image_alt' => null,
'portfolio_hero_image_path' => null,
'portfolio_hero_image_alt' => null,
'about_image_path' => null,
'about_image_alt' => null,
]);
$case = PortfolioCase::factory()->published()->create(['cover_image_path' => '', 'cover_image_alt' => '']);
foreach ([route('home'), route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug), route('about')] as $route) {
$this->get($route)
->assertOk()
->assertSee('data-tonal-hero', false);
}
}
public function test_configured_route_and_case_images_render_as_immersive_heroes(): void
{
$settings = SiteSetting::instance();
$settings->update([
'services_hero_image_path' => 'content/heroes/services.jpg',
'services_hero_image_alt' => 'Detalhe de uma mesa corporativa',
'portfolio_hero_image_path' => 'content/heroes/portfolio.jpg',
'portfolio_hero_image_alt' => 'Ambiente de celebração ao entardecer',
'about_image_path' => 'content/heroes/about.jpg',
'about_image_alt' => 'Caderno e flores no estúdio da Amare',
]);
$case = PortfolioCase::factory()->published()->create([
'cover_image_path' => 'content/cases/capa.jpg',
'cover_image_alt' => 'Cerimônia ao ar livre',
]);
foreach ([route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug), route('about')] as $route) {
$this->get($route)
->assertOk()
->assertSee('data-photo-hero', false)
->assertSee('loading="eager"', false)
->assertSee('fetchpriority="high"', false)
->assertSee('sizes="100vw"', false);
}
}
public function test_hero_upload_requires_alt_text_only_when_an_image_is_uploaded(): void
{
Storage::fake('public');
$this->actingAs(User::factory()->admin()->create());
Livewire::test(ManageSiteSettings::class)
->set('data.hero_image_path', [UploadedFile::fake()->create('hero.jpg', 100, 'image/jpeg')])
->set('data.hero_image_alt', null)
->call('save')
->assertHasFormErrors(['hero_image_alt' => 'required']);
Livewire::test(ManageSiteSettings::class)
->set('data.hero_image_path', null)
->set('data.hero_image_alt', null)
->call('save')
->assertHasNoFormErrors();
}
}

View File

@@ -6,9 +6,11 @@ namespace Tests\Feature\PublicSite;
use App\Support\ResponsiveImage;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use Mockery;
use Tests\TestCase;
class MediaImageComponentTest extends TestCase
@@ -60,5 +62,77 @@ class MediaImageComponentTest extends TestCase
$this->assertStringContainsString('alt="Legado"', $html);
$this->assertStringContainsString('loading="lazy"', $html);
$this->assertStringNotContainsString('srcset=', $html);
$this->assertStringNotContainsString('<picture', $html);
}
public function test_webp_variants_are_offered_ahead_of_the_original_format(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
Storage::disk('public')->put('content/hero.jpg', (string) $manager->create(1600, 900)->toJpeg());
ResponsiveImage::generate('content/hero.jpg', 'public');
$html = Blade::render(
'<x-media.image path="content/hero.jpg" alt="Hero" loading="eager" sizes="100vw" class="h-full w-full" />'
);
$this->assertStringContainsString('<source type="image/webp"', $html);
$this->assertStringContainsString('content/hero-720.jpg.webp 720w', $html);
// `display: contents` keeps the wrapper out of the layout — callers style
// the img against the grid or flex parent, and an inline <picture> would
// break `h-full`.
$this->assertStringContainsString('<picture class="contents">', $html);
// The source is a preference, not a replacement: the img keeps the
// original format for anything that cannot decode webp.
$this->assertStringContainsString('src="/storage/content/hero.jpg"', $html);
$this->assertStringContainsString('content/hero-720.jpg 720w', $html);
$this->assertStringContainsString('sizes="100vw"', $html);
}
public function test_media_without_webp_siblings_renders_a_bare_image(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
Storage::disk('public')->put('content/partial.jpg', (string) $manager->create(1600, 900)->toJpeg());
// Variants from before webp support: the original format only.
foreach (ResponsiveImage::WIDTHS as $width) {
Storage::disk('public')->put(
ResponsiveImage::variantPath('content/partial.jpg', $width),
(string) $manager->create(1600, 900)->toJpeg()
);
}
$html = Blade::render('<x-media.image path="content/partial.jpg" alt="Antigo" />');
$this->assertStringContainsString('srcset=', $html);
$this->assertStringNotContainsString('<picture', $html);
$this->assertStringNotContainsString('image/webp', $html);
}
public function test_render_consults_responsive_metadata_once(): void
{
Storage::fake('public');
Cache::shouldReceive('get')
->once()
->with(Mockery::type('string'))
->andReturn([
'width' => 1600,
'height' => 900,
'variants' => [['path' => 'content/cached-component-720.jpg', 'width' => 720]],
'webp_variants' => [['path' => 'content/cached-component-720.jpg.webp', 'width' => 720]],
]);
$html = Blade::render('<x-media.image path="content/cached-component.jpg" alt="Cache" />');
$this->assertStringContainsString('content/cached-component-720.jpg 720w', $html);
$this->assertStringContainsString('content/cached-component-720.jpg.webp 720w', $html);
$this->assertStringContainsString('width="1600"', $html);
$this->assertStringContainsString('height="900"', $html);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Models\PortfolioCase;
use App\Models\Service;
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MediaPerformanceTest extends TestCase
{
use RefreshDatabase;
public function test_only_the_first_visible_content_image_is_prioritized(): void
{
SiteSetting::instance()->update(['about_image_path' => 'content/about.jpg']);
Service::factory()->published()->create([
'cover_image_path' => 'content/service-first.jpg',
'sort_order' => 1,
]);
Service::factory()->published()->create([
'cover_image_path' => 'content/service-later.jpg',
'sort_order' => 2,
]);
$firstCase = PortfolioCase::factory()->published()->create([
'cover_image_path' => 'content/case-first.jpg',
'slug' => 'case-first',
'sort_order' => 1,
]);
PortfolioCase::factory()->published()->create([
'cover_image_path' => 'content/case-later.jpg',
'sort_order' => 2,
]);
$services = $this->get(route('services.index'))->assertOk()->getContent();
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
$about = $this->get(route('about'))->assertOk()->getContent();
$detail = $this->get(route('portfolio.show', $firstCase->slug))->assertOk()->getContent();
$this->assertMatchesRegularExpression('/service-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
$this->assertMatchesRegularExpression('/service-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $services);
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
$this->assertMatchesRegularExpression('/case-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $portfolio);
$this->assertMatchesRegularExpression('/about\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $about);
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $detail);
}
public function test_public_layout_preconnects_only_to_an_external_r2_media_origin(): void
{
config([
'app.url' => 'https://amare.example.test',
'filesystems.default' => 'r2',
'filesystems.disks.r2.url' => 'https://media.example.test/public',
]);
$external = $this->get(route('home'));
$external
->assertOk()
->assertSee('<link rel="preconnect" href="https://media.example.test" crossorigin>', false);
config([
'filesystems.default' => 'public',
'filesystems.disks.public.url' => '/storage',
]);
$local = $this->get(route('home'));
$local
->assertOk()
->assertDontSee('rel="preconnect"', false);
}
public function test_first_visible_index_images_are_prioritized_when_earlier_records_have_no_cover(): void
{
SiteSetting::instance();
Service::factory()->published()->create(['cover_image_path' => '', 'sort_order' => 1]);
Service::factory()->published()->create(['cover_image_path' => 'content/visible-service.jpg', 'sort_order' => 2]);
PortfolioCase::factory()->published()->create(['cover_image_path' => '', 'sort_order' => 1]);
PortfolioCase::factory()->published()->create(['cover_image_path' => 'content/visible-case.jpg', 'sort_order' => 2]);
$services = $this->get(route('services.index'))->assertOk()->getContent();
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
$this->assertMatchesRegularExpression('/visible-service\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
$this->assertMatchesRegularExpression('/visible-case\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
}
}

View File

@@ -15,13 +15,13 @@ class MotionMarkupTest extends TestCase
{
use RefreshDatabase;
public function test_home_exposes_page_open_beats_reveal_groups_and_chapter_index_links(): void
public function test_home_exposes_page_open_beats_and_reveal_groups_without_chapter_navigation(): void
{
$settings = SiteSetting::instance();
$settings->update([
'hero_title' => 'Celebrações com propósito',
'default_og_image_path' => 'media/hero.jpg',
'default_og_image_alt' => 'Capa editorial',
'hero_image_path' => 'media/hero.jpg',
'hero_image_alt' => 'Capa editorial',
]);
Service::factory()->published()->featured()->create(['title' => 'Casamentos']);
@@ -42,22 +42,15 @@ class MotionMarkupTest extends TestCase
->assertSee('data-motion-beat="cta"', false)
->assertSee('data-reveal-group', false)
->assertSee('data-reveal-from="up"', false)
->assertSee('data-chapter-index', false)
->assertSee('data-chapter-progress', false)
->assertSee('href="#hero-heading"', false)
->assertSee('href="#manifesto-heading"', false)
->assertSee('href="#services-heading"', false)
->assertSee('href="#portfolio-heading"', false)
->assertSee('href="#method-heading"', false)
->assertSee('href="#testimonials-heading"', false)
->assertSee('href="#positioning-heading"', false)
->assertSee('href="#final-cta-heading"', false)
->assertDontSee('data-chapter-index', false)
->assertDontSee('data-chapter-progress', false)
->assertSee('data-chapter="hero"', false)
->assertSee('data-chapter="manifesto"', false)
->assertSee('data-reveal', false);
->assertSee('data-reveal', false)
->assertDontSee('data-home-folio', false);
}
public function test_chapter_index_omits_empty_cms_sections(): void
public function test_home_has_no_chapter_navigation_when_cms_sections_are_empty(): void
{
SiteSetting::instance();
@@ -65,15 +58,8 @@ class MotionMarkupTest extends TestCase
$response
->assertOk()
->assertSee('data-chapter-index', false)
->assertSee('href="#hero-heading"', false)
->assertSee('href="#manifesto-heading"', false)
->assertSee('href="#method-heading"', false)
->assertSee('href="#positioning-heading"', false)
->assertSee('href="#final-cta-heading"', false)
->assertDontSee('href="#services-heading"', false)
->assertDontSee('href="#portfolio-heading"', false)
->assertDontSee('href="#testimonials-heading"', false)
->assertDontSee('data-chapter-index', false)
->assertDontSee('data-chapter-progress', false)
->assertDontSee('id="services-heading"', false)
->assertDontSee('id="portfolio-heading"', false)
->assertDontSee('id="testimonials-heading"', false);
@@ -101,7 +87,7 @@ class MotionMarkupTest extends TestCase
$this->assertStringContainsString('[data-reveal-from="left"]', $appCss);
$this->assertStringContainsString('[data-reveal-from="right"]', $appCss);
$this->assertStringContainsString('[data-reveal]', $appCss);
$this->assertStringContainsString('[data-chapter-index]', $appCss);
$this->assertStringNotContainsString('[data-chapter-index]', $appCss);
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
@@ -121,13 +107,13 @@ class MotionMarkupTest extends TestCase
$this->assertFileExists(resource_path('js/motion.js'));
$this->assertStringContainsString("import './motion.js'", $appJs);
$motionJs = (string) file_get_contents(resource_path('js/motion.js'));
$this->assertStringNotContainsString('setupChapterIndex', $motionJs);
$this->assertStringContainsString('prefers-reduced-motion', $motionJs);
$this->assertStringContainsString("querySelectorAll('[data-reveal-group]')", $motionJs);
$this->assertStringContainsString('Math.min(index, 3)', $motionJs);
$this->assertStringContainsString('observer.unobserve(entry.target)', $motionJs);
$this->assertStringContainsString("typeof IntersectionObserver !== 'function'", $motionJs);
$this->assertStringContainsString('requestAnimationFrame', $motionJs);
$this->assertStringContainsString('framePending', $motionJs);
}
public function test_all_visual_public_pages_expose_shared_opening_and_reveal_hooks(): void

View File

@@ -9,6 +9,7 @@ use App\Models\PortfolioImage;
use App\Models\Service;
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
@@ -103,6 +104,56 @@ class PublicPagesTest extends TestCase
$this->get(route('portfolio.show', 'rascunho'))->assertNotFound();
}
public function test_portfolio_surfaces_expose_editorial_composition_without_changing_content_order(): void
{
SiteSetting::instance();
$firstCase = PortfolioCase::factory()->published()->create([
'title' => 'Primeiro caso',
'slug' => 'primeiro-caso-editorial',
'cover_image_path' => 'content/cases/primeiro.jpg',
'is_featured' => true,
'sort_order' => 10,
]);
PortfolioCase::factory()->published()->create([
'title' => 'Segundo caso',
'slug' => 'segundo-caso-editorial',
'cover_image_path' => 'content/cases/segundo.jpg',
'is_featured' => true,
'sort_order' => 20,
]);
PortfolioImage::query()->create([
'portfolio_case_id' => $firstCase->id,
'path' => 'content/gallery-primeira.jpg',
'alt_text' => 'Primeira imagem',
'sort_order' => 10,
]);
PortfolioImage::query()->create([
'portfolio_case_id' => $firstCase->id,
'path' => 'content/gallery-segunda.jpg',
'alt_text' => 'Segunda imagem',
'sort_order' => 20,
]);
$home = $this->get(route('home'));
$portfolio = $this->get(route('portfolio.index'));
$detail = $this->get(route('portfolio.show', $firstCase->slug));
$home
->assertOk()
->assertSee('data-editorial-portfolio', false)
->assertSee('data-editorial-portfolio-item="feature"', false);
$portfolio
->assertOk()
->assertSee('data-editorial-portfolio-grid', false)
->assertSeeInOrder(['Primeiro caso', 'Segundo caso']);
$detail
->assertOk()
->assertSee('data-editorial-gallery', false)
->assertSeeInOrder(['Primeira imagem', 'Segunda imagem']);
}
public function test_about_privacy_and_contact_use_site_settings_without_creating_leads(): void
{
$settings = SiteSetting::instance();
@@ -119,7 +170,7 @@ class PublicPagesTest extends TestCase
->assertSee('Sobre a Amare boutique')
->assertSee('São Paulo - SP')
->assertDontSee('Fortaleza')
->assertSee('min-h-[calc(100dvh-14rem)]', false);
->assertSee('data-tonal-hero', false);
$this->get(route('privacy'))
->assertOk()
@@ -175,6 +226,8 @@ class PublicPagesTest extends TestCase
public function test_portfolio_show_avoids_n_plus_one_on_gallery(): void
{
config(['cache.default' => 'array']);
Cache::store('array')->flush();
SiteSetting::instance();
$case = PortfolioCase::factory()->published()->create(['slug' => 'caso-n1']);

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class MismatchScreenshotExporter
{
/**
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
* HTML file and writes them as standalone PNG files so they can be inspected
* directly in CI artifacts.
*
* @return list<string> paths of the written PNG files
*/
public static function export(string $htmlPath, string $targetDir): array
{
$html = (string) file_get_contents($htmlPath);
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
return [];
}
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
$labels = ['diff', 'expected', 'actual'];
$written = [];
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
continue;
}
$written[] = $path;
}
return $written;
}
}

View File

@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use Pest\Browser\Api\AwaitableWebpage;
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
use Pest\Browser\Support\Screenshot;
use Pest\TestSuite;
use PHPUnit\Framework\ExpectationFailedException;
trait StableScreenshot
{
/**
* Asserts the page matches its visual baseline without relying on the
* networkidle load state, which is inherently flaky (long-lived connections
* like fonts, polling, or keep-alive can keep it from ever firing and cause
* spurious timeouts at the 5s client default).
*
* On mismatch the vendor only writes an HTML diff view with base64-embedded
* images, so this helper additionally exports standalone diff/expected/actual
* PNG files that are directly viewable in CI artifacts.
*/
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable, bool $resetScroll = false): void
{
$page = $awaitable->page();
$page->addStyleTag('* {
transition: none !important;
animation: none !important;
font-family: Arial, sans-serif !important;
body {
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
}
}');
$page->waitForFunction(
'document.readyState === "complete"'
.' && document.fonts.status === "loaded"'
);
$page->evaluate(<<<'JS'
async () => {
const viewport = Math.max(window.innerHeight, 1);
for (let top = 0; top < document.documentElement.scrollHeight; top += viewport) {
window.scrollTo(0, top);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
await Promise.all([...document.images].map(async (image) => {
if (! image.complete) {
await new Promise((resolve) => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
});
}
await image.decode().catch(() => {});
}));
window.scrollTo(0, 0);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
JS);
if ($resetScroll) {
$page->evaluate('window.scrollTo(0, 0)');
$page->waitForFunction('window.scrollY === 0');
}
usleep(300_000);
try {
$page->expectScreenshot(true, false);
} catch (ExpectationFailedException $exception) {
$this->exportMismatchScreenshots();
throw BrowserExpectationFailedException::from($page, $exception);
}
}
private function exportMismatchScreenshots(): void
{
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
if (! is_file($htmlPath)) {
return;
}
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
}
}

View File

@@ -1,11 +1,9 @@
<?php
declare(strict_types=1);
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Tests\Support\StableScreenshot;
abstract class TestCase extends BaseTestCase
{
use StableScreenshot;
}
abstract class TestCase extends BaseTestCase {}

View File

@@ -5,13 +5,23 @@ declare(strict_types=1);
namespace Tests\Unit;
use App\Support\ResponsiveImage;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use RuntimeException;
use Tests\TestCase;
class ResponsiveImageTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
Cache::store('array')->flush();
}
public function test_generates_named_variants_and_removes_them_with_original(): void
{
Storage::fake('public');
@@ -58,4 +68,123 @@ class ResponsiveImageTest extends TestCase
Storage::disk('public')->assertExists('content/new-960.jpg');
Storage::disk('public')->assertExists('content/new-1440.jpg');
}
public function test_generates_a_webp_sibling_for_every_variant(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
$path = 'content/photo.jpg';
Storage::disk('public')->put($path, (string) $manager->create(2000, 1200)->toJpeg(quality: 90));
ResponsiveImage::generate($path, 'public');
foreach (ResponsiveImage::WIDTHS as $width) {
Storage::disk('public')->assertExists("content/photo-{$width}.jpg.webp");
}
$this->assertSame(
ResponsiveImage::WIDTHS,
array_column(ResponsiveImage::availableWebpVariants($path, 'public'), 'width'),
);
// The original format stays available so a browser that cannot decode
// webp still has something to fall back to through the <img>.
foreach (ResponsiveImage::WIDTHS as $width) {
Storage::disk('public')->assertExists("content/photo-{$width}.jpg");
}
ResponsiveImage::delete($path, 'public');
foreach (ResponsiveImage::WIDTHS as $width) {
Storage::disk('public')->assertMissing("content/photo-{$width}.jpg.webp");
}
}
public function test_webp_originals_do_not_get_a_duplicate_webp_sibling(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
$path = 'content/already.webp';
Storage::disk('public')->put($path, (string) $manager->create(1600, 900)->toWebp());
ResponsiveImage::generate($path, 'public');
Storage::disk('public')->assertExists('content/already-480.webp');
Storage::disk('public')->assertMissing('content/already-480.webp.webp');
$this->assertSame([], ResponsiveImage::availableWebpVariants($path, 'public'));
}
public function test_metadata_is_cached_after_the_first_storage_read(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
$path = 'content/cached.jpg';
Storage::disk('public')->put($path, (string) $manager->create(1600, 900)->toJpeg());
ResponsiveImage::generate($path, 'public');
$metadata = ResponsiveImage::metadata($path, 'public');
$this->assertSame(['width' => 1600, 'height' => 900], [
'width' => $metadata['width'],
'height' => $metadata['height'],
]);
$this->assertSame(ResponsiveImage::WIDTHS, array_column($metadata['variants'], 'width'));
$this->assertSame(ResponsiveImage::WIDTHS, array_column($metadata['webp_variants'], 'width'));
Storage::disk('public')->delete(array_merge(
[$path],
array_map(fn (int $width): string => ResponsiveImage::variantPath($path, $width), ResponsiveImage::WIDTHS),
array_map(fn (int $width): string => ResponsiveImage::webpVariantPath($path, $width), ResponsiveImage::WIDTHS),
));
$this->assertSame($metadata, ResponsiveImage::metadata($path, 'public'));
}
public function test_generation_populates_metadata_and_deletion_invalidates_it(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
$path = 'content/generated.jpg';
Storage::disk('public')->put($path, (string) $manager->create(1400, 800)->toJpeg());
ResponsiveImage::generate($path, 'public');
$this->assertSame(['width' => 1400, 'height' => 800], ResponsiveImage::dimensions($path, 'public'));
$this->assertSame(ResponsiveImage::WIDTHS, array_column(ResponsiveImage::availableVariants($path, 'public'), 'width'));
ResponsiveImage::delete($path, 'public');
$this->assertNull(ResponsiveImage::metadata($path, 'public'));
}
public function test_replacement_invalidates_old_metadata_and_populates_new_metadata(): void
{
Storage::fake('public');
$manager = new ImageManager(new Driver);
Storage::disk('public')->put('content/old-metadata.jpg', (string) $manager->create(1600, 900)->toJpeg());
ResponsiveImage::generate('content/old-metadata.jpg', 'public');
ResponsiveImage::metadata('content/old-metadata.jpg', 'public');
Storage::disk('public')->put('content/new-metadata.jpg', (string) $manager->create(1200, 675)->toJpeg());
ResponsiveImage::replace('content/old-metadata.jpg', 'content/new-metadata.jpg', 'public');
$this->assertNull(ResponsiveImage::metadata('content/old-metadata.jpg', 'public'));
$this->assertSame(['width' => 1200, 'height' => 675], ResponsiveImage::dimensions('content/new-metadata.jpg', 'public'));
}
public function test_metadata_falls_back_safely_when_the_cache_is_unavailable(): void
{
Cache::shouldReceive('get')
->once()
->andThrow(new RuntimeException('Cache unavailable'));
$this->assertNull(ResponsiveImage::metadata('content/unavailable.jpg', 'public'));
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
use Tests\Support\MismatchScreenshotExporter;
it('exports diff, expected, and actual PNGs from an ImageDiffView html', function (): void {
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
mkdir($targetDir, 0755, true);
$expected = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$actual = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$diff = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$html = <<<HTML
<!DOCTYPE html>
<html><body>
<img alt="Diff" src="data:image/png;base64,{$diff}"/>
<img slot="image-1" alt="Expected" src="data:image/png;base64,{$expected}"/>
<img slot="image-2" alt="Actual" src="data:image/png;base64,{$actual}"/>
</body></html>
HTML;
$htmlPath = $targetDir.'/it_matches_home_desktop.html';
file_put_contents($htmlPath, $html);
$written = MismatchScreenshotExporter::export($htmlPath, $targetDir);
expect($written)->toHaveCount(3);
expect($written)->toContain($targetDir.'/it_matches_home_desktop-diff.png');
expect($written)->toContain($targetDir.'/it_matches_home_desktop-expected.png');
expect($written)->toContain($targetDir.'/it_matches_home_desktop-actual.png');
expect(base64_decode($diff, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-diff.png'));
expect(base64_decode($expected, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-expected.png'));
expect(base64_decode($actual, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-actual.png'));
});
it('returns an empty list when the html has no embedded images', function (): void {
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
mkdir($targetDir, 0755, true);
$htmlPath = $targetDir.'/plain.html';
file_put_contents($htmlPath, '<html><body>no images</body></html>');
expect(MismatchScreenshotExporter::export($htmlPath, $targetDir))->toBe([]);
});

View File

@@ -3,6 +3,69 @@ import laravel from 'laravel-vite-plugin';
import { bunny } from 'laravel-vite-plugin/fonts';
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({
plugins: [
laravel({
@@ -19,6 +82,7 @@ export default defineConfig({
],
}),
tailwindcss(),
fontsWoff2Only(),
],
server: {
watch: {