Compare commits

..

3 Commits

Author SHA1 Message Date
manoel.neto
ab76efa050 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
2026-08-10 14:45:47 -03:00
manoel.neto
9ab2beb3ea 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
2026-08-10 14:43:00 -03:00
manoel.neto
65919d4a6c 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
2026-08-10 13:55:34 -03:00
91 changed files with 1284 additions and 1640 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@@ -53,9 +53,18 @@ 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. `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.
## Deterministic test support ## Visual regression — read before touching baselines
`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. - Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`.
- `tests/Browser/Screenshots/` is gitignored — it only holds diff output.
- `composer visual:update` is the sanctioned command, but on macOS it writes baselines CI rejects. Use `scripts/test/visual-update-ci.sh`, which runs it inside the Linux runner built from `docker/ci-runner.Dockerfile`.
- **Baselines are Linux-parity artifacts.** Pest Browser does not use FrankenPHP or `artisan serve` — it serves the Laravel kernel from an in-process Amp server (`vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php`), so the FrankenPHP container the `browser` job starts is only a health check. What makes a baseline reproducible is the machine that renders it: Ubuntu 24.04, Playwright's Chromium, and Playwright's font packages (`StableScreenshot` forces `Arial`, which fontconfig resolves to Liberation Sans on Linux). Commit `4578457` exists because macOS renders text differently.
Determinism relies on three cooperating pieces:
- `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.
## Other things that bite ## 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 ## Evidence on Hand
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP. - `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 browser e conteúdo demonstrativo. - Código atual contém site público, CMS, estrutura de acessibilidade, testes visuais 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. - 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. - 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. 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. 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. 4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
5. **Qualidade verificável:** tratar acessibilidade, desempenho, motion e jornadas críticas como critérios de entrega. 5. **Qualidade verificável:** tratar acessibilidade, desempenho, testes visuais e jornadas críticas como critérios de entrega.
## Accessibility & Inclusion ## 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) | | Á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 | | Servidor de aplicação | FrankenPHP em modo regular |
| Banco de dados | PostgreSQL | | Banco de dados | PostgreSQL |
| Testes | Pest e Pest Browser/Playwright | | Testes | Pest, Pest Browser/Playwright e testes visuais |
### 0.1 Vocabulário normativo ### 0.1 Vocabulário normativo
@@ -80,6 +80,7 @@ O agente:
- NÃO DEVE colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário. - 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 usar `float` para dinheiro.
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte. - 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 ### 1.2 Entrega incremental
@@ -134,6 +135,7 @@ Uma assessora adotará o sistema quando ele:
| Operação | Tarefas vencidas sem responsável | `0` | | Operação | Tarefas vencidas sem responsável | `0` |
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo | | 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 | | 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 | | Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
--- ---
@@ -205,7 +207,7 @@ No recorte do lançamento (Fases 01):
- formulário de briefing; - formulário de briefing;
- usuários internos e papéis simples; - usuários internos e papéis simples;
- SEO básico; - SEO básico;
- acessibilidade automatizada; - acessibilidade e testes visuais;
- CI/CD e deploy em contêiner com FrankenPHP. - CI/CD e deploy em contêiner com FrankenPHP.
Adiados para depois do lançamento (Fases 25, ver ADR-016): Adiados para depois do lançamento (Fases 25, ver ADR-016):
@@ -1899,7 +1901,7 @@ A pirâmide deve possuir:
1. muitos testes unitários rápidos; 1. muitos testes unitários rápidos;
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament; 2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
3. poucos testes E2E cobrindo jornadas críticas; 3. poucos testes E2E cobrindo jornadas críticas;
4. testes E2E, de acessibilidade, motion e smoke nas jornadas públicas mais importantes. 4. testes visuais determinísticos nas telas mais importantes.
A distribuição é por intenção, não por percentual rígido. A distribuição é por intenção, não por percentual rígido.
@@ -1981,7 +1983,40 @@ Em falha, CI deve publicar:
- logs do browser; - logs do browser;
- HTML report quando disponível. - HTML report quando disponível.
### 13.5 Testes de arquitetura ### 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
Criar regras Pest Architecture: Criar regras Pest Architecture:
@@ -1998,14 +2033,14 @@ arch()
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil. Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
### 13.6 Cobertura ### 13.7 Cobertura
- meta de 80% para `Domain` e `Application`; - meta de 80% para `Domain` e `Application`;
- não medir views, migrations, código gerado e framework; - não medir views, migrations, código gerado e framework;
- cobertura não substitui critérios de aceite; - cobertura não substitui critérios de aceite;
- queda de cobertura em módulo crítico deve bloquear merge. - queda de cobertura em módulo crítico deve bloquear merge.
### 13.7 Acessibilidade automatizada ### 13.8 Acessibilidade automatizada
Smoke/browser tests devem verificar: Smoke/browser tests devem verificar:
@@ -2023,7 +2058,7 @@ Revisão manual mínima antes do lançamento:
- zoom a 200%; - zoom a 200%;
- leitor de tela nos fluxos de briefing e login. - leitor de tela nos fluxos de briefing e login.
### 13.8 Comandos padronizados ### 13.9 Comandos padronizados
O projeto deve expor scripts equivalentes: O projeto deve expor scripts equivalentes:
@@ -2033,6 +2068,7 @@ composer test:feature
composer test:browser composer test:browser
composer test composer test
composer quality composer quality
composer visual:update
``` ```
Composição esperada: Composição esperada:
@@ -2040,7 +2076,7 @@ Composição esperada:
```text ```text
test:unit → Unit + Architecture test:unit → Unit + Architecture
test:feature → Feature + Livewire + Filament test:feature → Feature + Livewire + Filament
test:browser → E2E + acessibilidade + motion + smoke test:browser → E2E + visual + acessibilidade + smoke
quality → Pint check + PHPStan/Larastan + audits + testes quality → Pint check + PHPStan/Larastan + audits + testes
``` ```
@@ -2055,7 +2091,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim | | `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
| `unit` | Unitários, arquitetura e cobertura | Sim | | `unit` | Unitários, arquitetura e cobertura | Sim |
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim | | `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim | | `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
| `container` | Build da imagem final e healthcheck | Sim | | `container` | Build da imagem final e healthcheck | Sim |
### 14.2 Regras do pipeline ### 14.2 Regras do pipeline
@@ -2089,6 +2125,7 @@ Uma história só está concluída quando:
- PHPStan/Larastan está verde; - PHPStan/Larastan está verde;
- unit e feature tests cobrem regras relevantes; - unit e feature tests cobrem regras relevantes;
- E2E foi criado ou atualizado para fluxo crítico; - 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; - acessibilidade automatizada não possui issues críticas/sérias;
- migration foi testada; - migration foi testada;
- autorização foi verificada; - autorização foi verificada;
@@ -2346,9 +2383,10 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] privacidade; - [x] privacidade;
- [x] SEO; - [x] SEO;
- [x] mídia otimizada; - [x] mídia otimizada;
- [x] snapshots desktop/mobile;
- [x] testes de acessibilidade. - [x] testes de acessibilidade.
**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. **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).
### Fase 2 — Leads — ADIADA (ADR-016) ### Fase 2 — Leads — ADIADA (ADR-016)
@@ -2459,7 +2497,8 @@ Toda operação financeira deve:
| Risco | Mitigação obrigatória | | Risco | Mitigação obrigatória |
|---|---| |---|---|
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis | | Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana | | 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 |
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real | | 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 | | Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
| Worker mode vazar estado | manter modo regular até benchmark e ADR | | Worker mode vazar estado | manter modo regular até benchmark e ADR |
@@ -2478,7 +2517,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-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-003 | PostgreSQL como único banco transacional | Aceita |
| ADR-004 | Pagamentos somente manuais | Aceita | | ADR-004 | Pagamentos somente manuais | Aceita |
| ADR-005 | Pest unifica unit, feature e browser | Aceita | | ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita | | ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita | | ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
| ADR-008 | Database queue; Redis adiado | Aceita | | ADR-008 | Database queue; Redis adiado | Aceita |

View File

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

View File

@@ -160,24 +160,11 @@ class ManageSiteSettings extends Page
Textarea::make('hero_note') Textarea::make('hero_note')
->label('Nota do hero') ->label('Nota do hero')
->rows(2), ->rows(2),
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
Textarea::make('about_summary') Textarea::make('about_summary')
->label('Resumo institucional') ->label('Resumo institucional')
->rows(3), ->rows(3),
]) ])
->columns(2), ->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') Section::make('Manifesto editorial')
->schema([ ->schema([
TextInput::make('manifesto_title') TextInput::make('manifesto_title')

View File

@@ -18,12 +18,6 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $default_og_image_alt * @property string|null $default_og_image_alt
* @property string|null $about_image_path * @property string|null $about_image_path
* @property string|null $about_image_alt * @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_path
* @property string|null $logo_alt * @property string|null $logo_alt
*/ */
@@ -37,12 +31,6 @@ use Illuminate\Database\Eloquent\Model;
'hero_cta_label', 'hero_cta_label',
'hero_secondary_cta_label', 'hero_secondary_cta_label',
'hero_note', '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_summary',
'about_image_path', 'about_image_path',
'about_image_alt', 'about_image_alt',

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Support; namespace App\Support;
use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
@@ -26,10 +25,8 @@ final class ResponsiveImage
public static function generate(string $path, ?string $disk = null): void public static function generate(string $path, ?string $disk = null): void
{ {
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
try {
if (! $filesystem->exists($path)) { if (! $filesystem->exists($path)) {
return; return;
} }
@@ -79,19 +76,12 @@ final class ResponsiveImage
(string) $webp->toWebp(quality: 80) (string) $webp->toWebp(quality: 80)
); );
} }
} finally {
self::forgetMetadata($path, $disk);
}
self::metadata($path, $disk);
} }
public static function deleteVariants(string $path, ?string $disk = null): void public static function deleteVariants(string $path, ?string $disk = null): void
{ {
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
try {
foreach (self::WIDTHS as $width) { foreach (self::WIDTHS as $width) {
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) { foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
if ($filesystem->exists($variantPath)) { if ($filesystem->exists($variantPath)) {
@@ -99,44 +89,26 @@ final class ResponsiveImage
} }
} }
} }
} finally {
self::forgetMetadata($path, $disk);
}
} }
public static function delete(string $path, ?string $disk = null): void public static function delete(string $path, ?string $disk = null): void
{ {
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
try {
self::deleteVariants($path, $disk); self::deleteVariants($path, $disk);
if ($filesystem->exists($path)) { if ($filesystem->exists($path)) {
$filesystem->delete($path); $filesystem->delete($path);
} }
} finally {
self::forgetMetadata($path, $disk);
}
} }
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void 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) { if ($previousPath !== '' && $previousPath !== $newPath) {
self::delete($previousPath, $disk); self::delete($previousPath, $disk);
} }
self::generate($newPath, $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 public static function variantPath(string $path, int $width): string
@@ -165,7 +137,21 @@ final class ResponsiveImage
*/ */
public static function availableVariants(string $path, ?string $disk = null): array public static function availableVariants(string $path, ?string $disk = null): array
{ {
return self::metadata($path, $disk)['variants'] ?? []; $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 $variants;
} }
/** /**
@@ -177,7 +163,21 @@ final class ResponsiveImage
*/ */
public static function availableWebpVariants(string $path, ?string $disk = null): array public static function availableWebpVariants(string $path, ?string $disk = null): array
{ {
return self::metadata($path, $disk)['webp_variants'] ?? []; $filesystem = self::filesystem($disk);
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = self::webpVariantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = [
'path' => $variantPath,
'width' => $width,
];
}
}
return $variants;
} }
/** /**
@@ -185,38 +185,13 @@ final class ResponsiveImage
*/ */
public static function dimensions(string $path, ?string $disk = null): ?array 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); $filesystem = self::filesystem($disk);
if (! $filesystem->exists($path)) { if (! $filesystem->exists($path)) {
return null; return null;
} }
try {
$contents = $filesystem->get($path); $contents = $filesystem->get($path);
if ($contents === null) { if ($contents === null) {
@@ -224,88 +199,18 @@ final class ResponsiveImage
} }
$image = (new ImageManager(new Driver))->read($contents); $image = (new ImageManager(new Driver))->read($contents);
$metadata = [
return [
'width' => $image->width(), 'width' => $image->width(),
'height' => $image->height(), 'height' => $image->height(),
'variants' => self::existingVariants($filesystem, $path, false),
'webp_variants' => self::existingVariants($filesystem, $path, true),
]; ];
self::storeMetadata($key, $metadata);
return $metadata;
} catch (Throwable) { } catch (Throwable) {
return null; 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 private static function filesystem(?string $disk): Filesystem
{ {
return Storage::disk(self::diskName($disk)); return Storage::disk($disk ?? PublicImageUploadRules::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,6 +87,9 @@
"npm audit --omit=dev --audit-level=high", "npm audit --omit=dev --audit-level=high",
"@test" "@test"
], ],
"visual:update": [
"@php artisan test --testsuite=Browser --update-snapshots"
],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi", "@php artisan package:discover --ansi",

View File

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

View File

@@ -1,36 +0,0 @@
<?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,12 +61,6 @@ class ContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta', 'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar', '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_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_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_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano', '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; use Illuminate\Support\Facades\Storage;
/** /**
* Deterministic content and image fixtures. Keep separate from ContentSeeder demo data. * Deterministic content for visual regression. Keep separate from ContentSeeder demo data.
*/ */
class VisualContentSeeder extends Seeder class VisualContentSeeder extends Seeder
{ {
@@ -42,12 +42,6 @@ class VisualContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta', 'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar', '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_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_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_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre', 'about_image_alt' => 'Imagem editorial da página Sobre',

View File

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

View File

@@ -101,8 +101,9 @@ páginas. Reencodados a 3× do maior render — lockup 149×144 (14 KiB) e mark
As variantes `on-dark` foram reencodadas junto por consistência; nenhuma view as As variantes `on-dark` foram reencodadas junto por consistência; nenhuma view as
usa hoje. usa hoje.
Isto absorve MAN-122: os ativos de marca foram reencodados sem alterar a forma Isto absorve MAN-122: os 16 baselines visuais foram regenerados no runner Linux
ou a cor percebida, reduzindo apenas o peso transferido. (`scripts/test/visual-update-ci.sh`), e o diff é imperceptível a 2× de zoom —
mesma forma, mesma cor, só menos bytes.
Os arquivos versionados aqui são: `2026-08-10-local-antes.md` (commit `2e43fde`), 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-etapa-fontes-e-marca.md` (passada intermediária) e
@@ -173,11 +174,12 @@ em ordem de custo:
## Cobertura que este trabalho não tem ## Cobertura que este trabalho não tem
Os testes browser não exercitam `srcset` nem `<picture>` com variantes geradas: Os testes de regressão visual nunca exercitam `srcset` nem `<picture>`: nem
nem `ContentSeeder` nem `VisualContentSeeder` geram variantes. Nesses cenários, `ContentSeeder` nem `VisualContentSeeder` geram variantes, e `media:generate-variants`
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A não roda no runner visual. Naquele ambiente `availableVariants()` volta vazio e o
cobertura do caminho com variantes fica nos testes de feature componente renderiza `<img>` puro — foi por isso que os 16 baselines não mudaram
(`MediaImageComponentTest`). com a introdução do `<picture>`. A cobertura do caminho com variantes fica nos
testes de feature (`MediaImageComponentTest`), não nos baselines.
## Staging ## Staging

View File

@@ -1,81 +0,0 @@
# Hero full-bleed da home Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fazer a fotografia configurada do hero da home preencher toda a coluna direita abaixo do header, preservando a leitura editorial e o fallback sem foto.
**Architecture:** `x-home.hero` continuará a renderizar as duas variantes. A variante com imagem usará uma grade full-width com a coluna textual ancorada à régua global e a mídia como segundo trilho sem container; a variante tonal permanece contida. Os testes de feature e browser documentam os contratos de markup, tamanho e responsividade.
**Tech Stack:** Laravel Blade, Tailwind CSS 4 utilities, Pest feature/browser tests, Vite.
---
## Chunk 1: Layout e contratos do hero
### Task 1: Cobrir o spread full-bleed
**Files:**
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
- [ ] **Step 1: Escrever as expectativas de feature para a variante com foto**
Exigir `data-split-hero`, `data-hero-content`, `data-motion="page-open"`, `data-reveal-group`, `aria-labelledby="hero-heading"`, `loading="eager"`, `fetchpriority="high"` e `sizes="(max-width: 767px) 100vw, 55vw"`. Com `hero_image_path` nulo, exigir `data-tonal-hero`, os mesmos atributos de motion/semântica, ausência de `data-split-hero` e ausência de `<img` dentro de `[data-tonal-hero]`.
- [ ] **Step 2: Estender a verificação browser de geometria**
No viewport 1440×1000, obter os retângulos de `.site-header`, `[data-chapter="hero"]`, `[data-hero-content]` e `[data-split-hero]`; esperar, com tolerância de 1px, que `hero.top === header.bottom`, `media.top === hero.top`, `media.bottom === hero.bottom`, `media.right === innerWidth` e `hero.height === innerHeight - header.height`. No viewport 390×844, comparar `data-split-hero` com `[data-hero-content]`; esperar que a mídia inicie depois do conteúdo, que `width / height` fique entre 0,79 e 0,81, e que não exista overflow horizontal. Antes das duas visitas de fallback, zerar `hero_image_path`; numa visita com reduced motion, esperar conteúdo visível. Em outra com `reducedMotion: no-preference` e `IntersectionObserver` desabilitado antes da navegação, esperar ausência de `data-motion` em `document.documentElement`, cada `[data-motion="page-open"]` com `.is-active`, cada `[data-reveal]` com `.is-revealed` e `aria-labelledby="hero-heading"`.
- [ ] **Step 3: Executar os testes focados e confirmar a falha inicial**
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
Expected: falha nas novas expectativas de geometria/markup até a alteração do componente.
### Task 2: Implementar a grade full-bleed
**Files:**
- Modify: `resources/views/components/home/hero.blade.php`
- [ ] **Step 1: Separar a variante com imagem do container compartilhado**
Substituir `min-h-[100dvh]` do `<section>` por `md:h-[calc(100dvh-5rem)]` quando `hero_image_path` estiver preenchido e renderizar um wrapper `grid min-h-[calc(100dvh-5rem)] md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]`. A coluna de texto deve manter `data-reveal-group`, receber `data-hero-content`, ter `padding-left: max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, padding direito fluido e centralização vertical.
- [ ] **Step 2: Tornar a mídia contínua**
Aplicar ao bloco `data-split-hero` `overflow-hidden`, `bg-amare-bg-deep`, `md:min-h-full`; abaixo de `md`, aplicar `aspect-[4/5]`. Manter `data-motion-beat="media"`, `data-reveal-media`, `object-cover`, path/alt CMS e mudar `sizes` para `(max-width: 767px) 100vw, 55vw`.
- [ ] **Step 3: Preservar o fallback**
Manter o wrapper tonal existente para hero sem imagem, com `data-tonal-hero`, `data-motion="page-open"` e `data-reveal-group`; não renderizar o bloco de mídia nessa variante.
- [ ] **Step 4: Executar os testes focados e confirmar a passagem**
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
Expected: PASS.
### Task 3: Verificar e registrar
**Files:**
- Modify: `resources/views/components/home/hero.blade.php`
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
- [ ] **Step 1: Formatar e executar as verificações proporcionais**
Run: `composer pint && composer phpstan && composer test:feature && composer test:browser && npm run build`
Expected: todos os comandos passam.
- [ ] **Step 2: Inspecionar o diff e registrar**
Run: `git diff --check && git status --short`
Expected: apenas os três arquivos de implementação/testes e a documentação deste plano/especificação aparecem como escopo.
- [ ] **Step 3: Commit**
Run: `git add resources/views/components/home/hero.blade.php tests/Feature/PublicSite/ImmersivePhotoHeroTest.php tests/Browser/HomeEditorialCadenceTest.php docs/superpowers && git commit -m "feat(home): tornar hero fotográfico full-bleed"`
Expected: commit focado, sem artefatos de build ou screenshots.

View File

@@ -1,31 +0,0 @@
# Hero da home com fotografia full-bleed
## Objetivo
Dar à fotografia do hero da home a mesma presença contínua da referência aprovada: no desktop, ela preenche integralmente a coluna direita abaixo do header; o conteúdo editorial permanece concentrado na coluna esquerda.
## Escopo
- Alterar somente o componente `resources/views/components/home/hero.blade.php` e seus testes diretos.
- Quando `hero_image_path` existir, usar uma grade externa `0.9fr / 1.1fr` a partir de `md` (768px), com altura exata `calc(100dvh - 5rem)`: o espaço restante depois do header desktop. A mídia deve chegar ao topo e à base da seção, sem padding vertical ou lateral de container.
- Manter o texto, CTAs, imagem CMS, `alt`, carregamento eager, `fetchpriority`, responsividade de imagem e atributos de motion existentes.
- Abaixo de `md`, manter a ordem texto seguido de imagem, sem overflow horizontal, e fixar a mídia em `aspect-ratio: 4 / 5` com `object-cover`.
- Quando não houver imagem configurada, manter a abertura tonal, tipográfica e contida já existente, sem área vazia para mídia.
## Fora de escopo
- Não alterar conteúdo do CMS, imagens, Open Graph, outras rotas públicas, header, animações, tokens globais ou dependências.
- Não criar ou publicar mídia nova.
## Estrutura
O componente continuará sendo a única unidade de layout do hero. A variante com foto terá um wrapper full-width em desktop. A coluna textual terá `data-hero-content`, margem esquerda `max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, a mesma régua esquerda de `container-amare`, e padding direito fluido para não comprimir a leitura. O bloco de mídia ocupará os 55% da grade, receberá `data-split-hero` e `data-reveal-media`, e deixará de ficar limitado pelo container. O atributo `sizes` será `(max-width: 767px) 100vw, 55vw`. A variante sem foto permanece no wrapper atual.
## Critérios de aceitação
- Em desktop, `data-split-hero` inicia sob o header e mede `calc(100dvh - 5rem)`, sem margens externas de container.
- A foto usa `object-cover`, `loading="eager"`, `fetchpriority="high"`, path/alt próprios do hero e `sizes="(max-width: 767px) 100vw, 55vw"`.
- Em mobile, o conteúdo essencial continua acessível antes da foto, sem overflow nem texto recortado.
- O fallback sem foto continua com `data-tonal-hero`, sem `data-split-hero` e sem mídia renderizada.
- `data-motion="page-open"`, `data-reveal-group`, beats existentes, motion reduzido, CTAs e semântica atual continuam funcionais.
- Testes verificam os contratos full-bleed da variante com foto, a ausência de mídia no fallback, e a continuidade de motion/semântica.

View File

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

View File

@@ -1,37 +0,0 @@
## 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

@@ -1,25 +0,0 @@
## 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

@@ -1,21 +0,0 @@
## 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

@@ -1,13 +0,0 @@
## 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

@@ -1,19 +0,0 @@
## 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. - 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. - 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. - Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
- Cobrir marcação, comportamento real, acessibilidade e console. - Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
**Non-Goals:** **Non-Goals:**
@@ -32,14 +32,15 @@ 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. 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 e console incluem 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/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
## Risks / Trade-offs ## 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. - [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. - [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. - [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 é seguro; feature tests cobrem os cinco templates. - [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.
## Migration Plan ## 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. - 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. - 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`. - 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 e console. - 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.
- 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. - 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 ## Capabilities
@@ -23,6 +23,7 @@ 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. - `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. - `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. - `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. - `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
## Impact ## Impact

View File

@@ -0,0 +1,26 @@
## 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.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.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.3 Extend axe and console route matrices to about, contact, privacy and branded 404
- [x] 3.4 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
## 4. Verification and delivery ## 4. Verification and delivery

View File

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

View File

@@ -1,44 +0,0 @@
## 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

@@ -1,30 +0,0 @@
## 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

@@ -1,16 +0,0 @@
## 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

@@ -1,69 +0,0 @@
## 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

@@ -1,10 +0,0 @@
## 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 ### 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 compromise browser-test reliability. 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.
#### Scenario: Public pages do not depend on external stock hosts #### Scenario: Public pages do not depend on external stock hosts

View File

@@ -1,7 +1,7 @@
# quality-gates Specification # quality-gates Specification
## Purpose ## Purpose
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser functional, accessibility and motion coverage. Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser visual and accessibility coverage.
## Requirements ## Requirements
### Requirement: Standardized Composer test scripts exist ### 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 ### 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, automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison. 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).
#### Scenario: Browser job validates served application #### Scenario: Browser job validates served application
- **WHEN** the `browser` CI job runs - **WHEN** the `browser` CI job runs
- **THEN** tests execute against the built application artifact or equivalent production-like image - **THEN** tests execute against the built application artifact or equivalent production-like image
#### Scenario: Browser job covers functional, accessibility and motion assertions #### Scenario: Browser job covers visual and accessibility assertions
- **WHEN** the `browser` CI job runs - **WHEN** the `browser` CI job runs
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites - **THEN** it MUST execute the visual regression suite and the accessibility suite
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge - **AND** a failing snapshot or a critical/serious accessibility issue MUST block merge
#### Scenario: Browser failures publish diagnostics #### Scenario: Browser failures publish diagnostics
- **WHEN** a browser test fails in CI - **WHEN** a browser test fails in CI
- **THEN** the job MUST publish application logs and browser logs as diagnostics - **THEN** the job MUST publish screenshots, snapshot diffs, application logs and browser logs as artifacts

View File

@@ -0,0 +1,60 @@
# 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

View File

@@ -130,6 +130,43 @@
display: flex; 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) { @media (prefers-reduced-motion: no-preference) {
.main-nav { .main-nav {
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard); transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
@@ -144,6 +181,85 @@
overflow-x: clip; 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) { @media (prefers-reduced-motion: no-preference) {
:where(a, button, input, textarea, select) { :where(a, button, input, textarea, select) {
transition-duration: var(--amare-motion-feedback); transition-duration: var(--amare-motion-feedback);

View File

@@ -84,11 +84,86 @@ function observeReveals(enhance) {
nodes.forEach((node) => observer.observe(node)); 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() { function bootMotion() {
initializeMotionDelays(); initializeMotionDelays();
const enhance = enableEnhancement(); const enhance = enableEnhancement();
activatePageOpen(enhance); activatePageOpen(enhance);
observeReveals(enhance); observeReveals(enhance);
setupChapterIndex();
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => { window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
if (event.matches) { if (event.matches) {
document.documentElement.removeAttribute('data-motion'); document.documentElement.removeAttribute('data-motion');

View File

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

View File

@@ -0,0 +1,20 @@
@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,43 +4,45 @@
<section <section
aria-labelledby="hero-heading" aria-labelledby="hero-heading"
@class([ class="home-chapter border-b border-amare-border bg-amare-bg"
'home-chapter border-b border-amare-border bg-amare-bg',
'min-h-[100dvh]' => blank($settings->hero_image_path),
'md:h-[calc(100dvh-5rem)]' => filled($settings->hero_image_path),
])
data-chapter="hero" data-chapter="hero"
data-motion="page-open" data-motion="page-open"
> >
@if (filled($settings->hero_image_path)) <div class="container-amare pt-8 md:pt-10">
<div class="grid md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]"> <x-home.folio label="Capa" data-motion-beat="folio" />
<div </div>
data-hero-content
data-reveal-group <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>
class="flex flex-col justify-center space-y-8 px-6 py-20 md:py-12 md:pr-[clamp(3rem,6vw,7rem)] md:pl-[max(var(--amare-container-padding),calc((100vw-var(--amare-container-max))/2+var(--amare-container-padding)))]" <div class="space-y-8">
<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))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
@endif
</div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1>
@if (filled($settings->hero_subtitle))
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@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"
> >
<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))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
@endif
</div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1>
@if (filled($settings->hero_subtitle))
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@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">
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
@if (filled($settings->hero_secondary_cta_label)) @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> <span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a> </a>
@endif @endif
@@ -51,44 +53,17 @@
@endif @endif
</div> </div>
<div data-split-hero data-motion-beat="media" class="max-md:aspect-[4/5] overflow-hidden bg-amare-bg-deep md:h-full" data-reveal-media> @if (filled($settings->default_og_image_path))
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 767px) 100vw, 55vw" class="img-editorial h-full w-full object-cover" /> <div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
</div> <x-media.image
</div> :path="$settings->default_og_image_path"
@else :alt="$settings->default_og_image_alt ?: $settings->brand_name"
<div class="container-amare grid min-h-[100dvh] gap-12 py-20 md:max-w-4xl md:items-center md:py-10" data-reveal-group data-tonal-hero> loading="eager"
<div class="flex flex-col justify-center space-y-8 md:py-12"> fetchpriority="high"
<div data-motion-beat="seal" class="flex items-center gap-4"> sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" /> class="img-editorial h-full w-full object-cover"
@if (filled($settings->hero_eyebrow)) />
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
@endif
</div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1>
@if (filled($settings->hero_subtitle))
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@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">
{{ $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">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif
</div>
@if (filled($settings->hero_note))
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
@endif
</div>
</div> </div>
@endif @endif
</div>
</section> </section>

View File

@@ -8,12 +8,14 @@
$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.'; $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 @endphp
<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"> <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-10 md:grid-cols-12" data-reveal-group> <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:col-span-4 md:pt-20" data-reveal data-reveal-from="left"> <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>
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p> <p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
</div> </div>
<div class="space-y-6 md:col-span-7 md:col-start-6" data-reveal data-reveal-from="up"> <div class="space-y-6" data-reveal data-reveal-from="up">
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2> <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-xl leading-relaxed text-amare-text">{{ $lead }}</p>
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p> <p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>

View File

@@ -7,9 +7,10 @@
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.'; $intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
@endphp @endphp
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-24" data-chapter="method"> <section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-20" 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="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"> <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> <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> <p class="text-amare-text-muted">{{ $intro }}</p>
<x-brand.logo <x-brand.logo

View File

@@ -3,43 +3,25 @@
]) ])
@if ($cases->isNotEmpty()) @if ($cases->isNotEmpty())
<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"> <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">
<div class="container-amare space-y-10" data-reveal-group> <div class="container-amare space-y-10" data-reveal-group>
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up"> <div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
<div class="md:col-span-7 md:col-start-5 space-y-3"> <x-home.folio label="Portfólio" tone="inverse" />
<div class="space-y-3">
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2> <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> <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> </div>
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio> <div class="grid gap-10 md:grid-cols-2">
@foreach ($cases as $case) @foreach ($cases as $case)
@php <article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal data-reveal-from="up">
$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)) @if (filled($case->cover_image_path))
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title" :alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
@class([ class="img-editorial aspect-[4/3] w-full object-cover"
'img-editorial w-full object-cover',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/> />
@endif @endif
<div class="space-y-2"> <div class="space-y-2">

View File

@@ -7,27 +7,18 @@
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples(); $principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
@endphp @endphp
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="positioning"> <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-12" data-reveal-group> <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 md:col-span-5" data-reveal data-reveal-from="left"> <div class="space-y-4" data-reveal data-reveal-from="up">
<x-home.folio label="A Amare" />
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span> <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> <p class="text-sm uppercase tracking-[0.12em] text-amare-text-muted">Assessoria boutique · São Paulo</p>
</div> </div>
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2> <h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
</div> </div>
<div class="space-y-8 md:col-span-6 md:col-start-7" data-reveal data-reveal-from="up"> <div class="space-y-8" data-reveal data-reveal-from="up">
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p> <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"> <ul class="grid gap-3 border-t border-amare-border">
@foreach ($principles as $principle) @foreach ($principles as $principle)
<li class="border-b border-amare-border py-3 text-amare-text-muted">{{ $principle }}</li> <li class="border-b border-amare-border py-3 text-amare-text-muted">{{ $principle }}</li>

View File

@@ -3,10 +3,11 @@
]) ])
@if ($services->isNotEmpty()) @if ($services->isNotEmpty())
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services"> <section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="services">
<div class="container-amare space-y-10" data-reveal-group> <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="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"> <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> <h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
</div> </div>
<div class="space-y-4 md:col-span-6 md:col-start-7"> <div class="space-y-4 md:col-span-6 md:col-start-7">

View File

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

View File

@@ -27,21 +27,22 @@
->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w') ->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w')
->implode(', '); ->implode(', ');
// All public rendering reads one durable metadata record. On a cache hit this $variants = ResponsiveImage::availableVariants($path, $diskName);
// avoids both existence probes and downloading the original from R2.
$metadata = ResponsiveImage::metadata($path, $diskName);
$variants = $metadata['variants'] ?? [];
$srcset = $toSrcset($variants); $srcset = $toSrcset($variants);
if ($srcset === '' && $filesystem->exists($path)) {
$srcset = null;
}
// Offered ahead of the original format because webp carries the same picture // 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 // 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 // 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 // before `media:generate-variants` learned to emit webp has no siblings, so
// the <source> is skipped rather than pointed at nothing. // the <source> is skipped rather than pointed at nothing.
$webpSrcset = $toSrcset($metadata['webp_variants'] ?? []); $webpSrcset = $toSrcset(ResponsiveImage::availableWebpVariants($path, $diskName));
$dimensions = ($width === null || $height === null) $dimensions = ($width === null || $height === null)
? $metadata ? ResponsiveImage::dimensions($path, $diskName)
: null; : null;
$resolvedWidth = $width ?? $dimensions['width'] ?? null; $resolvedWidth = $width ?? $dimensions['width'] ?? null;
@@ -58,8 +59,7 @@
@endif @endif
<img <img
src="{{ $src }}" src="{{ $src }}"
@if ($srcset) srcset="{{ $srcset }}" @endif @if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
sizes="{{ $sizes }}"
alt="{{ $alt }}" alt="{{ $alt }}"
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif @if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif @if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif

View File

@@ -1,76 +0,0 @@
@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,24 +10,6 @@
<title>{{ $pageMeta->title }}</title> <title>{{ $pageMeta->title }}</title>
<x-seo.meta :page-meta="$pageMeta" /> <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 /> <x-fonts />
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])

View File

@@ -8,12 +8,30 @@
$city = $siteSettings->city ?: 'São Paulo - SP'; $city = $siteSettings->city ?: 'São Paulo - SP';
@endphp @endphp
<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"> <div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
<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> <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">
</x-public.photo-hero> <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) calc(100vw - 3rem), 50vw"
class="img-editorial aspect-[4/3] w-full object-cover"
/>
</div>
@endif
</div>
<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> <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) @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"> <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,6 +1,36 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @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"> <div class="home-chapters">
<x-home.hero :settings="$content->settings" /> <x-home.hero :settings="$content->settings" />
<x-home.manifesto :settings="$content->settings" /> <x-home.manifesto :settings="$content->settings" />

View File

@@ -1,56 +1,29 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<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" data-motion="page-open">
<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="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()) @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> <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 @else
@php <div class="grid gap-10 md:grid-cols-2" data-reveal-group>
$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) @foreach ($cases as $case)
@php <article class="space-y-4" data-reveal data-reveal-from="up">
$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)) @if (filled($case->cover_image_path))
@php
$prioritizeImage = ! $hasPrioritizedImage;
@endphp
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden"> <a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title" :alt="$case->cover_image_alt ?: $case->title"
:loading="$prioritizeImage ? 'eager' : 'lazy'"
:fetchpriority="$prioritizeImage ? 'high' : null"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
@class([ 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]"
'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> </a>
@php
$hasPrioritizedImage = true;
@endphp
@endif @endif
<div class="space-y-2 border-t border-amare-border pt-4"> <div class="space-y-2 border-t border-amare-border pt-4">
<h2 class="text-2xl font-medium text-amare-text"> <h2 class="text-2xl font-medium text-amare-text">

View File

@@ -1,8 +1,34 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<article> <article data-motion="page-open">
<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">
<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) calc(100vw - 3rem), 1120px"
class="img-editorial aspect-[16/9] w-full object-cover"
data-motion-beat="media"
/>
@endif
</div>
</div>
<div class="border-b border-amare-border bg-amare-bg-deep"> <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> <div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
@@ -27,34 +53,14 @@
<section aria-labelledby="gallery-heading" class="bg-amare-bg"> <section aria-labelledby="gallery-heading" class="bg-amare-bg">
<div class="container-amare space-y-8 py-16"> <div class="container-amare space-y-8 py-16">
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2> <h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
<div class="grid gap-6 md:grid-cols-12 md:gap-x-8 md:gap-y-12" data-editorial-gallery data-reveal-group> <div class="grid gap-6 md:grid-cols-2" data-reveal-group>
@foreach ($case->images as $image) @foreach ($case->images as $image)
@php <figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
$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 <x-media.image
:path="$image->path" :path="$image->path"
:alt="$image->alt_text" :alt="$image->alt_text"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
@class([ class="img-editorial aspect-[4/3] w-full object-cover"
'img-editorial w-full object-cover',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/> />
@if (filled($image->caption)) @if (filled($image->caption))
<figcaption class="text-sm text-amare-muted">{{ $image->caption }}</figcaption> <figcaption class="text-sm text-amare-muted">{{ $image->caption }}</figcaption>

View File

@@ -1,21 +1,20 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<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" data-motion="page-open">
<div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-10 py-16 md:py-24"> <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()) @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> <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 @else
@php($hasPrioritizedImage = false) <div class="divide-y divide-amare-border border-y border-amare-border" data-reveal-group>
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
@foreach ($services as $index => $service) @foreach ($services as $index => $service)
<article @class([ <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">
'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> <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"> <div class="space-y-3">
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2> <h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
@@ -23,16 +22,12 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
@if (filled($service->cover_image_path)) @if (filled($service->cover_image_path))
@php($prioritizeImage = ! $hasPrioritizedImage)
<x-media.image <x-media.image
:path="$service->cover_image_path" :path="$service->cover_image_path"
:alt="$service->cover_image_alt ?: $service->title" :alt="$service->cover_image_alt ?: $service->title"
:loading="$prioritizeImage ? 'eager' : 'lazy'"
:fetchpriority="$prioritizeImage ? 'high' : null"
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw" sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
class="img-editorial aspect-[16/10] w-full object-cover" class="img-editorial aspect-[16/10] w-full object-cover"
/> />
@php($hasPrioritizedImage = true)
@endif @endif
@if (filled($service->description)) @if (filled($service->description))
<div class="text-amare-muted"> <div class="text-amare-muted">

View File

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

19
tasks.md Normal file
View File

@@ -0,0 +1,19 @@
# 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,86 +12,6 @@ beforeEach(function (): void {
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]); Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
}); });
it('fills the desktop opening from below the header while keeping the media full bleed', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
])->resize(1440, 1000);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$layout = $page->script(<<<'JS'
() => {
const header = document.querySelector('.site-header');
const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]');
const media = hero?.querySelector('[data-split-hero]');
const image = media?.querySelector('img');
const headerRect = header?.getBoundingClientRect();
const heroRect = hero?.getBoundingClientRect();
const contentRect = content?.getBoundingClientRect();
const mediaRect = media?.getBoundingClientRect();
return {
hasContentColumn: Boolean(content),
startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 1,
fillsRemainingViewport: Math.abs((heroRect?.bottom ?? -1) - window.innerHeight) <= 1,
mediaTouchesHeroTop: Math.abs((mediaRect?.top ?? -1) - (heroRect?.top ?? -2)) <= 1,
mediaTouchesHeroBottom: Math.abs((mediaRect?.bottom ?? -1) - (heroRect?.bottom ?? -2)) <= 1,
mediaTouchesViewportRight: Math.abs((mediaRect?.right ?? -1) - window.innerWidth) <= 1,
splitImageObjectFit: image ? getComputedStyle(image).objectFit : null,
columnRatio: Number(((contentRect?.width ?? 0) / (mediaRect?.width ?? 1)).toFixed(3)),
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
};
}
JS);
expect($layout)->toBe([
'hasContentColumn' => true,
'startsBelowHeader' => true,
'fillsRemainingViewport' => true,
'mediaTouchesHeroTop' => true,
'mediaTouchesHeroBottom' => true,
'mediaTouchesViewportRight' => true,
'splitImageObjectFit' => 'cover',
'columnRatio' => 0.818,
'horizontalOverflow' => false,
]);
});
it('stacks the home hero media after its content as a full-width four-by-five crop on mobile', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
])->resize(390, 844);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$layout = $page->script(<<<'JS'
() => {
const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]');
const media = hero?.querySelector('[data-split-hero]');
const contentRect = content?.getBoundingClientRect();
const mediaRect = media?.getBoundingClientRect();
return {
hasContentColumn: Boolean(content),
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
};
}
JS);
expect($layout)->toBe([
'hasContentColumn' => true,
'mediaFollowsContent' => true,
'mediaFillsViewportWidth' => true,
'mediaAspectRatio' => 0.8,
'horizontalOverflow' => false,
]);
});
it('keeps the full home cadence accessible and contained at both viewports', function (): void { it('keeps the full home cadence accessible and contained at both viewports', function (): void {
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) { foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
$page = $this->visit('/', [ $page = $this->visit('/', [
@@ -103,6 +23,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
$layout = $page->script(<<<'JS' $layout = $page->script(<<<'JS'
() => { () => {
const chapters = Array.from(document.querySelectorAll('.home-chapter')); const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
const clippedValues = new Set(['clip', 'hidden']); const clippedValues = new Set(['clip', 'hidden']);
const textNodes = Array.from(document.querySelectorAll( const textNodes = Array.from(document.querySelectorAll(
'.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span' '.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span'
@@ -115,7 +36,10 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
const current = chapter.getBoundingClientRect(); const current = chapter.getBoundingClientRect();
return current.top < previous.bottom - 1; return current.top < previous.bottom - 1;
}), }),
ornamentalFolios: document.querySelectorAll('[data-home-folio]').length, overflowingFolios: folios.filter((folio) => {
const rect = folio.getBoundingClientRect();
return rect.left < -1 || rect.right > window.innerWidth + 1;
}).length,
clippedText: textNodes.filter((node) => { clippedText: textNodes.filter((node) => {
const style = getComputedStyle(node); const style = getComputedStyle(node);
const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1; const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
@@ -129,7 +53,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
expect($layout)->toBe([ expect($layout)->toBe([
'horizontalOverflow' => false, 'horizontalOverflow' => false,
'overlappingChapters' => false, 'overlappingChapters' => false,
'ornamentalFolios' => 0, 'overflowingFolios' => 0,
'clippedText' => 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['heroActive'])->toBeTrue();
expect($state['titleOpacity'])->toBeGreaterThan(0.9); expect($state['titleOpacity'])->toBeGreaterThan(0.9);
expect($state['hasIndex'])->toBeFalse(); expect($state['hasIndex'])->toBeTrue();
expect($state['hasActiveChapter'])->toBeFalse(); expect($state['hasActiveChapter'])->toBeTrue();
expect($state['finalReveals'])->toBeTrue(); expect($state['finalReveals'])->toBeTrue();
expect($state['enhancementEnabled'])->toBeFalse(); expect($state['enhancementEnabled'])->toBeFalse();
}); });
@@ -259,7 +259,51 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
} }
}); });
it('keeps rendered home chapters free of ornamental folios and counters', function (): void { 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 {
PortfolioCase::factory()->published()->create([ PortfolioCase::factory()->published()->create([
'is_featured' => true, 'is_featured' => true,
]); ]);
@@ -271,7 +315,7 @@ it('keeps rendered home chapters free of ornamental folios and counters', functi
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
$editorial = $page->script(<<<'JS' $numbering = $page->script(<<<'JS'
() => { () => {
const flow = document.querySelector('.home-chapters'); const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter')); const chapters = Array.from(document.querySelectorAll('.home-chapter'));
@@ -280,12 +324,26 @@ it('keeps rendered home chapters free of ornamental folios and counters', functi
return { return {
reset: flow ? getComputedStyle(flow).counterReset : '', reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement), increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
folioCount: folios.length, 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, '') : '';
}),
}; };
} }
JS); JS);
expect($editorial['reset'])->toBe('none'); expect($numbering['reset'])->toContain('home-chapter -1');
expect($editorial['increments'])->each->toBe('none'); expect($numbering['increments'])->each->toContain('home-chapter 1');
expect($editorial['folioCount'])->toBe(0); 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)');
}); });

View File

@@ -0,0 +1,59 @@
<?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,48 +111,6 @@ class SiteSettingsTest extends TestCase
->assertHasNoFormErrors(); ->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 public function test_content_seeder_includes_default_og_image_alt_text(): void
{ {
Storage::fake('public'); Storage::fake('public');

View File

@@ -5,11 +5,9 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting;
use App\Support\ResponsiveImage; use App\Support\ResponsiveImage;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
@@ -39,48 +37,4 @@ class MediaGenerateVariantsCommandTest extends TestCase
Storage::disk('public')->assertExists(ResponsiveImage::variantPath($path, $width)); 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

@@ -129,41 +129,6 @@ class HomePageContentTest extends TestCase
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']); ->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 public function test_blank_quote_testimonials_are_skipped(): void
{ {
Testimonial::factory()->published()->create([ Testimonial::factory()->published()->create([
@@ -215,8 +180,8 @@ class HomePageContentTest extends TestCase
'Segunda autora', 'Segunda autora',
], false); ], false);
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="left"/i', $response->getContent())); $this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="left"'));
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="right"/i', $response->getContent())); $this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="right"'));
} }
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
@@ -233,7 +198,7 @@ class HomePageContentTest extends TestCase
->assertDontSee('Relato vazio'); ->assertDontSee('Relato vazio');
} }
public function test_home_removes_ornamental_folios_and_counter_css(): void public function test_home_folios_follow_rendered_chapter_order_and_hide_decorative_numbers(): void
{ {
SiteSetting::instance(); SiteSetting::instance();
@@ -243,15 +208,54 @@ class HomePageContentTest extends TestCase
$html = $this->get(route('home'))->assertOk()->getContent(); $html = $this->get(route('home'))->assertOk()->getContent();
$this->assertStringNotContainsString('data-home-folio', (string) $html); $this->assertSame([
$this->assertStringNotContainsString('home-folio', (string) $html); '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->assertSame(1, preg_match_all('/<h1\b/i', (string) $html)); $this->assertSame(1, preg_match_all('/<h1\b/i', (string) $html));
$css = (string) file_get_contents(resource_path('css/app.css')); $css = (string) file_get_contents(resource_path('css/app.css'));
$this->assertStringNotContainsString('counter-reset: home-chapter', $css); $this->assertStringContainsString('counter-reset: home-chapter -1', $css);
$this->assertStringNotContainsString('counter-increment: home-chapter', $css); $this->assertStringContainsString('counter-increment: home-chapter', $css);
$this->assertStringNotContainsString('counter(home-chapter', $css); $this->assertStringContainsString('counter(home-chapter, decimal-leading-zero)', $css);
$this->assertFileDoesNotExist(resource_path('views/components/home/folio.blade.php')); }
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],
));
} }
} }

View File

@@ -1,125 +0,0 @@
<?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-chapter="hero"', false)
->assertSee('data-motion="page-open"', false)
->assertSee('h-[calc(100dvh-5rem)]', false)
->assertSee('data-hero-content', false)
->assertSee('data-split-hero', false)
->assertSee('data-motion-beat="media"', false)
->assertSee('content/heroes/home.jpg', false)
->assertSee('loading="eager"', false)
->assertSee('fetchpriority="high"', false)
->assertSee('sizes="(max-width: 767px) 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) {
$response = $this->get($route)
->assertOk()
->assertSee('data-tonal-hero', false);
if ($route === route('home')) {
$response
->assertSee('data-motion="page-open"', false)
->assertSee('id="hero-heading"', false)
->assertSee('data-testid="home-primary-cta"', false)
->assertDontSee('data-split-hero', false)
->assertDontSee('content/heroes/home.jpg', 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,11 +6,9 @@ namespace Tests\Feature\PublicSite;
use App\Support\ResponsiveImage; use App\Support\ResponsiveImage;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class MediaImageComponentTest extends TestCase class MediaImageComponentTest extends TestCase
@@ -113,26 +111,4 @@ class MediaImageComponentTest extends TestCase
$this->assertStringNotContainsString('<picture', $html); $this->assertStringNotContainsString('<picture', $html);
$this->assertStringNotContainsString('image/webp', $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

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

View File

@@ -9,7 +9,6 @@ use App\Models\PortfolioImage;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Tests\TestCase; use Tests\TestCase;
@@ -104,56 +103,6 @@ class PublicPagesTest extends TestCase
$this->get(route('portfolio.show', 'rascunho'))->assertNotFound(); $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 public function test_about_privacy_and_contact_use_site_settings_without_creating_leads(): void
{ {
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
@@ -170,7 +119,7 @@ class PublicPagesTest extends TestCase
->assertSee('Sobre a Amare boutique') ->assertSee('Sobre a Amare boutique')
->assertSee('São Paulo - SP') ->assertSee('São Paulo - SP')
->assertDontSee('Fortaleza') ->assertDontSee('Fortaleza')
->assertSee('data-tonal-hero', false); ->assertSee('min-h-[calc(100dvh-14rem)]', false);
$this->get(route('privacy')) $this->get(route('privacy'))
->assertOk() ->assertOk()
@@ -226,8 +175,6 @@ class PublicPagesTest extends TestCase
public function test_portfolio_show_avoids_n_plus_one_on_gallery(): void public function test_portfolio_show_avoids_n_plus_one_on_gallery(): void
{ {
config(['cache.default' => 'array']);
Cache::store('array')->flush();
SiteSetting::instance(); SiteSetting::instance();
$case = PortfolioCase::factory()->published()->create(['slug' => 'caso-n1']); $case = PortfolioCase::factory()->published()->create(['slug' => 'caso-n1']);

View File

@@ -0,0 +1,40 @@
<?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

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

View File

@@ -5,23 +5,13 @@ declare(strict_types=1);
namespace Tests\Unit; namespace Tests\Unit;
use App\Support\ResponsiveImage; use App\Support\ResponsiveImage;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use RuntimeException;
use Tests\TestCase; use Tests\TestCase;
class ResponsiveImageTest extends 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 public function test_generates_named_variants_and_removes_them_with_original(): void
{ {
Storage::fake('public'); Storage::fake('public');
@@ -117,74 +107,4 @@ class ResponsiveImageTest extends TestCase
Storage::disk('public')->assertMissing('content/already-480.webp.webp'); Storage::disk('public')->assertMissing('content/already-480.webp.webp');
$this->assertSame([], ResponsiveImage::availableWebpVariants($path, 'public')); $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

@@ -0,0 +1,47 @@
<?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([]);
});