Compare commits
8 Commits
perf/man-1
...
f7fdf3930c
| Author | SHA1 | Date | |
|---|---|---|---|
| f7fdf3930c | |||
| d2d1b436fa | |||
| d044911d24 | |||
| 7902b35393 | |||
| 28e25b4464 | |||
| 8afb925e17 | |||
| 338e3c9adb | |||
| 0ecae4c7d6 |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -205,15 +205,12 @@ jobs:
|
||||
- run: npm run build
|
||||
- run: npx playwright install chromium --with-deps
|
||||
- run: php artisan migrate --force
|
||||
- run: php artisan db:seed --class=VisualContentSeeder --force
|
||||
- run: php artisan storage:link
|
||||
|
||||
- name: Build application image
|
||||
run: docker build -t amare-app:ci .
|
||||
|
||||
- name: Run browser tests against FrankenPHP container
|
||||
env:
|
||||
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
|
||||
run: |
|
||||
docker run -d --name amare-web \
|
||||
-e APP_ENV=testing \
|
||||
@@ -222,7 +219,6 @@ jobs:
|
||||
-e APP_LOCALE=pt_BR \
|
||||
-e APP_FALLBACK_LOCALE=pt_BR \
|
||||
-e APP_TIMEZONE=America/Sao_Paulo \
|
||||
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
|
||||
-e DB_CONNECTION=pgsql \
|
||||
-e DB_HOST=host.docker.internal \
|
||||
-e DB_PORT=5432 \
|
||||
@@ -253,8 +249,6 @@ jobs:
|
||||
mkdir -p artifacts/browser
|
||||
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
|
||||
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
|
||||
cp -R tests/Browser/Screenshots artifacts/browser/screenshots 2>/dev/null || true
|
||||
cp -R tests/.pest artifacts/browser/pest 2>/dev/null || true
|
||||
|
||||
- name: Upload browser failure artifacts
|
||||
if: failure()
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/tests/Browser/Screenshots
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -53,18 +53,9 @@ Site-wide content is a singleton row reached via `SiteSetting::instance()`. Publ
|
||||
|
||||
`App\Application` is **not** covered by that rule. `app/Domain/` currently holds a single placeholder (`DomainModule.php`); business reads live in `app/Application/Queries`. Extend the arch test when you add a boundary.
|
||||
|
||||
## Visual regression — read before touching baselines
|
||||
## Deterministic test support
|
||||
|
||||
- Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`.
|
||||
- `tests/Browser/Screenshots/` is gitignored — it only holds diff output.
|
||||
- `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.
|
||||
`APP_FROZEN_NOW` configures `CarbonImmutable::setTestNow()` through `AppServiceProvider::freezeClockWhenConfigured()` outside production. `VisualContentSeeder` provides deterministic image/content fixtures for tests that explicitly need them. Neither setting is a browser-CI global default.
|
||||
|
||||
## Other things that bite
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
|
||||
## Evidence on Hand
|
||||
|
||||
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP.
|
||||
- Código atual contém site público, CMS, estrutura de acessibilidade, testes visuais e conteúdo demonstrativo.
|
||||
- Código atual contém site público, CMS, estrutura de acessibilidade, testes browser e conteúdo demonstrativo.
|
||||
- Textos, fotografias, portfólio, depoimentos, contatos e perfis sociais presentes nos seeders são dados fictícios de desenvolvimento e não constituem prova comercial.
|
||||
- Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los.
|
||||
|
||||
@@ -68,7 +68,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
|
||||
2. **Uma informação, um fluxo:** conduzir o dado do briefing ao evento sem redigitação ou planilhas paralelas.
|
||||
3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo.
|
||||
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
|
||||
5. **Qualidade verificável:** tratar acessibilidade, desempenho, testes visuais e jornadas críticas como critérios de entrega.
|
||||
5. **Qualidade verificável:** tratar acessibilidade, desempenho, motion e jornadas críticas como critérios de entrega.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
|
||||
63
SPEC.md
63
SPEC.md
@@ -23,7 +23,7 @@
|
||||
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) |
|
||||
| Servidor de aplicação | FrankenPHP em modo regular |
|
||||
| Banco de dados | PostgreSQL |
|
||||
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
||||
| Testes | Pest e Pest Browser/Playwright |
|
||||
|
||||
### 0.1 Vocabulário normativo
|
||||
|
||||
@@ -80,7 +80,6 @@ O agente:
|
||||
- NÃO DEVE colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário.
|
||||
- NÃO DEVE usar `float` para dinheiro.
|
||||
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte.
|
||||
- NÃO DEVE atualizar snapshots visuais apenas para fazer o CI passar sem revisar o diff.
|
||||
|
||||
### 1.2 Entrega incremental
|
||||
|
||||
@@ -135,7 +134,6 @@ Uma assessora adotará o sistema quando ele:
|
||||
| Operação | Tarefas vencidas sem responsável | `0` |
|
||||
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo |
|
||||
| Qualidade | Jornadas E2E críticas passando | 100% antes de deploy |
|
||||
| Visual | Snapshots aprovados | 100% |
|
||||
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
|
||||
|
||||
---
|
||||
@@ -207,7 +205,7 @@ No recorte do lançamento (Fases 0–1):
|
||||
- formulário de briefing;
|
||||
- usuários internos e papéis simples;
|
||||
- SEO básico;
|
||||
- acessibilidade e testes visuais;
|
||||
- acessibilidade automatizada;
|
||||
- CI/CD e deploy em contêiner com FrankenPHP.
|
||||
|
||||
Adiados para depois do lançamento (Fases 2–5, ver ADR-016):
|
||||
@@ -1901,7 +1899,7 @@ A pirâmide deve possuir:
|
||||
1. muitos testes unitários rápidos;
|
||||
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
|
||||
3. poucos testes E2E cobrindo jornadas críticas;
|
||||
4. testes visuais determinísticos nas telas mais importantes.
|
||||
4. testes E2E, de acessibilidade, motion e smoke nas jornadas públicas mais importantes.
|
||||
|
||||
A distribuição é por intenção, não por percentual rígido.
|
||||
|
||||
@@ -1983,40 +1981,7 @@ Em falha, CI deve publicar:
|
||||
- logs do browser;
|
||||
- HTML report quando disponível.
|
||||
|
||||
### 13.5 Regressão visual
|
||||
|
||||
Usar `assertScreenshotMatches()` ou API equivalente do Pest Browser.
|
||||
|
||||
Snapshots obrigatórios:
|
||||
|
||||
| Tela | Desktop | Mobile |
|
||||
|---|---:|---:|
|
||||
| Home | 1440×1000 | 390×844 |
|
||||
| Serviços | 1440×1000 | 390×844 |
|
||||
| Portfólio | 1440×1000 | 390×844 |
|
||||
| Detalhe do portfólio | 1440×1000 | 390×844 |
|
||||
| Briefing vazio | 1280×900 | 390×844 |
|
||||
| Briefing com erros | 1280×900 | 390×844 |
|
||||
| Briefing sucesso | 1280×900 | 390×844 |
|
||||
| Login | 1280×900 | Opcional |
|
||||
| Dashboard | 1440×1000 | Não obrigatório |
|
||||
| Detalhe do evento | 1440×1000 | Não obrigatório |
|
||||
|
||||
Determinismo obrigatório:
|
||||
|
||||
- Chromium e imagem Linux fixos;
|
||||
- viewport fixo;
|
||||
- timezone `America/Sao_Paulo`;
|
||||
- locale `pt-BR`;
|
||||
- fontes instaladas na imagem;
|
||||
- relógio congelado;
|
||||
- seed determinístico;
|
||||
- animações e transições desabilitadas;
|
||||
- dados dinâmicos mascarados quando necessário.
|
||||
|
||||
Atualização de baseline deve usar comando explícito e revisão humana do diff.
|
||||
|
||||
### 13.6 Testes de arquitetura
|
||||
### 13.5 Testes de arquitetura
|
||||
|
||||
Criar regras Pest Architecture:
|
||||
|
||||
@@ -2033,14 +1998,14 @@ arch()
|
||||
|
||||
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
|
||||
|
||||
### 13.7 Cobertura
|
||||
### 13.6 Cobertura
|
||||
|
||||
- meta de 80% para `Domain` e `Application`;
|
||||
- não medir views, migrations, código gerado e framework;
|
||||
- cobertura não substitui critérios de aceite;
|
||||
- queda de cobertura em módulo crítico deve bloquear merge.
|
||||
|
||||
### 13.8 Acessibilidade automatizada
|
||||
### 13.7 Acessibilidade automatizada
|
||||
|
||||
Smoke/browser tests devem verificar:
|
||||
|
||||
@@ -2058,7 +2023,7 @@ Revisão manual mínima antes do lançamento:
|
||||
- zoom a 200%;
|
||||
- leitor de tela nos fluxos de briefing e login.
|
||||
|
||||
### 13.9 Comandos padronizados
|
||||
### 13.8 Comandos padronizados
|
||||
|
||||
O projeto deve expor scripts equivalentes:
|
||||
|
||||
@@ -2068,7 +2033,6 @@ composer test:feature
|
||||
composer test:browser
|
||||
composer test
|
||||
composer quality
|
||||
composer visual:update
|
||||
```
|
||||
|
||||
Composição esperada:
|
||||
@@ -2076,7 +2040,7 @@ Composição esperada:
|
||||
```text
|
||||
test:unit → Unit + Architecture
|
||||
test:feature → Feature + Livewire + Filament
|
||||
test:browser → E2E + visual + acessibilidade + smoke
|
||||
test:browser → E2E + acessibilidade + motion + smoke
|
||||
quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
```
|
||||
|
||||
@@ -2091,7 +2055,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim |
|
||||
| `container` | Build da imagem final e healthcheck | Sim |
|
||||
|
||||
### 14.2 Regras do pipeline
|
||||
@@ -2125,7 +2089,6 @@ Uma história só está concluída quando:
|
||||
- PHPStan/Larastan está verde;
|
||||
- unit e feature tests cobrem regras relevantes;
|
||||
- E2E foi criado ou atualizado para fluxo crítico;
|
||||
- snapshot foi criado ou revisado para UI coberta;
|
||||
- acessibilidade automatizada não possui issues críticas/sérias;
|
||||
- migration foi testada;
|
||||
- autorização foi verificada;
|
||||
@@ -2383,10 +2346,9 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
||||
- [x] privacidade;
|
||||
- [x] SEO;
|
||||
- [x] mídia otimizada;
|
||||
- [x] snapshots desktop/mobile;
|
||||
- [x] testes de acessibilidade.
|
||||
|
||||
**Critério de saída:** conteúdo gerenciável no Filament e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
|
||||
**Critério de saída:** conteúdo gerenciável no Filament, testes browser funcionais, de acessibilidade e motion aprovados, e revisão humana do site público no PR.
|
||||
|
||||
### Fase 2 — Leads — ADIADA (ADR-016)
|
||||
|
||||
@@ -2497,8 +2459,7 @@ Toda operação financeira deve:
|
||||
| Risco | Mitigação obrigatória |
|
||||
|---|---|
|
||||
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
|
||||
| Site parecer genérico | design tokens, conteúdo real, fotografia e visual tests |
|
||||
| Snapshots instáveis | contêiner fixo, relógio/dados/fontes determinísticos |
|
||||
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana |
|
||||
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real |
|
||||
| Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
|
||||
| Worker mode vazar estado | manter modo regular até benchmark e ADR |
|
||||
@@ -2517,7 +2478,7 @@ Toda operação financeira deve:
|
||||
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita |
|
||||
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
|
||||
| ADR-004 | Pagamentos somente manuais | Aceita |
|
||||
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
||||
| ADR-005 | Pest unifica unit, feature e browser | Aceita |
|
||||
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
|
||||
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
|
||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||
|
||||
@@ -29,6 +29,7 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (! Storage::disk($disk)->exists($path)) {
|
||||
ResponsiveImage::forgetMetadata($path, $disk);
|
||||
$this->warn("Missing file: {$path}");
|
||||
$skipped++;
|
||||
|
||||
@@ -56,9 +57,21 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
if ($settings && filled($settings->default_og_image_path)) {
|
||||
$paths[] = (string) $settings->default_og_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->hero_image_path)) {
|
||||
$paths[] = (string) $settings->hero_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->about_image_path)) {
|
||||
$paths[] = (string) $settings->about_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->hero_image_path)) {
|
||||
$paths[] = (string) $settings->hero_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->services_hero_image_path)) {
|
||||
$paths[] = (string) $settings->services_hero_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->portfolio_hero_image_path)) {
|
||||
$paths[] = (string) $settings->portfolio_hero_image_path;
|
||||
}
|
||||
|
||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||
$paths[] = (string) $path;
|
||||
|
||||
@@ -160,11 +160,24 @@ class ManageSiteSettings extends Page
|
||||
Textarea::make('hero_note')
|
||||
->label('Nota do hero')
|
||||
->rows(2),
|
||||
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
|
||||
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||
Textarea::make('about_summary')
|
||||
->label('Resumo institucional')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Imagens das aberturas')
|
||||
->description('Fotos reais e autorizadas para as primeiras dobras. Sem imagem, cada rota mantém a abertura tonal.')
|
||||
->schema([
|
||||
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem da home', 'content/heroes'),
|
||||
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||
PublicImageUploadRules::fileUpload('services_hero_image_path', 'Imagem de Serviços', 'content/heroes'),
|
||||
PublicImageUploadRules::altTextField('services_hero_image_alt', 'services_hero_image_path'),
|
||||
PublicImageUploadRules::fileUpload('portfolio_hero_image_path', 'Imagem de Portfólio', 'content/heroes'),
|
||||
PublicImageUploadRules::altTextField('portfolio_hero_image_alt', 'portfolio_hero_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Manifesto editorial')
|
||||
->schema([
|
||||
TextInput::make('manifesto_title')
|
||||
|
||||
@@ -18,6 +18,12 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @property string|null $default_og_image_alt
|
||||
* @property string|null $about_image_path
|
||||
* @property string|null $about_image_alt
|
||||
* @property string|null $hero_image_path
|
||||
* @property string|null $hero_image_alt
|
||||
* @property string|null $services_hero_image_path
|
||||
* @property string|null $services_hero_image_alt
|
||||
* @property string|null $portfolio_hero_image_path
|
||||
* @property string|null $portfolio_hero_image_alt
|
||||
* @property string|null $logo_path
|
||||
* @property string|null $logo_alt
|
||||
*/
|
||||
@@ -31,6 +37,12 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'hero_cta_label',
|
||||
'hero_secondary_cta_label',
|
||||
'hero_note',
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'services_hero_image_path',
|
||||
'services_hero_image_alt',
|
||||
'portfolio_hero_image_path',
|
||||
'portfolio_hero_image_alt',
|
||||
'about_summary',
|
||||
'about_image_path',
|
||||
'about_image_alt',
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
@@ -25,90 +26,117 @@ final class ResponsiveImage
|
||||
|
||||
public static function generate(string $path, ?string $disk = null): void
|
||||
{
|
||||
self::forgetMetadata($path, $disk);
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
if (! $filesystem->exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$contents = $filesystem->get($path);
|
||||
|
||||
if ($contents === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = self::variantPath($path, $width);
|
||||
$variant = $manager->read($contents);
|
||||
|
||||
if ($variant->width() > $width) {
|
||||
$variant->scale(width: $width);
|
||||
try {
|
||||
if (! $filesystem->exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$encoded = match ($extension) {
|
||||
'png' => $variant->toPng(),
|
||||
'webp' => $variant->toWebp(quality: 82),
|
||||
default => $variant->toJpeg(quality: 82),
|
||||
};
|
||||
$manager = new ImageManager(new Driver);
|
||||
$contents = $filesystem->get($path);
|
||||
|
||||
$filesystem->put($variantPath, (string) $encoded);
|
||||
|
||||
// A webp sibling for every variant. The MAN-109 audit measured the
|
||||
// hero as the LCP element on every mobile page, at 143 KiB for a
|
||||
// 960 px jpeg — webp carries the same picture for roughly a third of
|
||||
// that. `x-media.image` offers these through a <source> so a browser
|
||||
// that cannot decode webp still gets the original format.
|
||||
if ($extension === 'webp') {
|
||||
continue;
|
||||
if ($contents === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webp = $manager->read($contents);
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if ($webp->width() > $width) {
|
||||
$webp->scale(width: $width);
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = self::variantPath($path, $width);
|
||||
$variant = $manager->read($contents);
|
||||
|
||||
if ($variant->width() > $width) {
|
||||
$variant->scale(width: $width);
|
||||
}
|
||||
|
||||
$encoded = match ($extension) {
|
||||
'png' => $variant->toPng(),
|
||||
'webp' => $variant->toWebp(quality: 82),
|
||||
default => $variant->toJpeg(quality: 82),
|
||||
};
|
||||
|
||||
$filesystem->put($variantPath, (string) $encoded);
|
||||
|
||||
// A webp sibling for every variant. The MAN-109 audit measured the
|
||||
// hero as the LCP element on every mobile page, at 143 KiB for a
|
||||
// 960 px jpeg — webp carries the same picture for roughly a third of
|
||||
// that. `x-media.image` offers these through a <source> so a browser
|
||||
// that cannot decode webp still gets the original format.
|
||||
if ($extension === 'webp') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$webp = $manager->read($contents);
|
||||
|
||||
if ($webp->width() > $width) {
|
||||
$webp->scale(width: $width);
|
||||
}
|
||||
|
||||
$filesystem->put(
|
||||
self::webpVariantPath($path, $width),
|
||||
(string) $webp->toWebp(quality: 80)
|
||||
);
|
||||
}
|
||||
|
||||
$filesystem->put(
|
||||
self::webpVariantPath($path, $width),
|
||||
(string) $webp->toWebp(quality: 80)
|
||||
);
|
||||
} finally {
|
||||
self::forgetMetadata($path, $disk);
|
||||
}
|
||||
|
||||
self::metadata($path, $disk);
|
||||
}
|
||||
|
||||
public static function deleteVariants(string $path, ?string $disk = null): void
|
||||
{
|
||||
self::forgetMetadata($path, $disk);
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
|
||||
if ($filesystem->exists($variantPath)) {
|
||||
$filesystem->delete($variantPath);
|
||||
try {
|
||||
foreach (self::WIDTHS as $width) {
|
||||
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
|
||||
if ($filesystem->exists($variantPath)) {
|
||||
$filesystem->delete($variantPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
self::forgetMetadata($path, $disk);
|
||||
}
|
||||
}
|
||||
|
||||
public static function delete(string $path, ?string $disk = null): void
|
||||
{
|
||||
self::forgetMetadata($path, $disk);
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
self::deleteVariants($path, $disk);
|
||||
try {
|
||||
self::deleteVariants($path, $disk);
|
||||
|
||||
if ($filesystem->exists($path)) {
|
||||
$filesystem->delete($path);
|
||||
if ($filesystem->exists($path)) {
|
||||
$filesystem->delete($path);
|
||||
}
|
||||
} finally {
|
||||
self::forgetMetadata($path, $disk);
|
||||
}
|
||||
}
|
||||
|
||||
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
|
||||
{
|
||||
if ($previousPath !== '' && $previousPath !== $newPath) {
|
||||
self::delete($previousPath, $disk);
|
||||
self::forgetMetadata($previousPath, $disk);
|
||||
self::forgetMetadata($newPath, $disk);
|
||||
|
||||
try {
|
||||
if ($previousPath !== '' && $previousPath !== $newPath) {
|
||||
self::delete($previousPath, $disk);
|
||||
}
|
||||
|
||||
self::generate($newPath, $disk);
|
||||
} finally {
|
||||
self::forgetMetadata($previousPath, $disk);
|
||||
self::forgetMetadata($newPath, $disk);
|
||||
}
|
||||
|
||||
self::generate($newPath, $disk);
|
||||
self::metadata($newPath, $disk);
|
||||
}
|
||||
|
||||
public static function variantPath(string $path, int $width): string
|
||||
@@ -137,21 +165,7 @@ final class ResponsiveImage
|
||||
*/
|
||||
public static function availableVariants(string $path, ?string $disk = null): array
|
||||
{
|
||||
$filesystem = self::filesystem($disk);
|
||||
$variants = [];
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = self::variantPath($path, $width);
|
||||
|
||||
if ($filesystem->exists($variantPath)) {
|
||||
$variants[] = [
|
||||
'path' => $variantPath,
|
||||
'width' => $width,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
return self::metadata($path, $disk)['variants'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,21 +177,7 @@ final class ResponsiveImage
|
||||
*/
|
||||
public static function availableWebpVariants(string $path, ?string $disk = null): array
|
||||
{
|
||||
$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;
|
||||
return self::metadata($path, $disk)['webp_variants'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,13 +185,38 @@ final class ResponsiveImage
|
||||
*/
|
||||
public static function dimensions(string $path, ?string $disk = null): ?array
|
||||
{
|
||||
$filesystem = self::filesystem($disk);
|
||||
$metadata = self::metadata($path, $disk);
|
||||
|
||||
if (! $filesystem->exists($path)) {
|
||||
return null;
|
||||
return $metadata === null ? null : [
|
||||
'width' => $metadata['width'],
|
||||
'height' => $metadata['height'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* width: int,
|
||||
* height: int,
|
||||
* variants: list<array{path: string, width: int}>,
|
||||
* webp_variants: list<array{path: string, width: int}>
|
||||
* }|null
|
||||
*/
|
||||
public static function metadata(string $path, ?string $disk = null): ?array
|
||||
{
|
||||
$key = self::metadataCacheKey($path, $disk);
|
||||
$cached = self::cachedMetadata($key);
|
||||
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
try {
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
if (! $filesystem->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = $filesystem->get($path);
|
||||
|
||||
if ($contents === null) {
|
||||
@@ -199,18 +224,88 @@ final class ResponsiveImage
|
||||
}
|
||||
|
||||
$image = (new ImageManager(new Driver))->read($contents);
|
||||
|
||||
return [
|
||||
$metadata = [
|
||||
'width' => $image->width(),
|
||||
'height' => $image->height(),
|
||||
'variants' => self::existingVariants($filesystem, $path, false),
|
||||
'webp_variants' => self::existingVariants($filesystem, $path, true),
|
||||
];
|
||||
|
||||
self::storeMetadata($key, $metadata);
|
||||
|
||||
return $metadata;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function forgetMetadata(string $path, ?string $disk = null): void
|
||||
{
|
||||
try {
|
||||
Cache::forget(self::metadataCacheKey($path, $disk));
|
||||
} catch (Throwable) {
|
||||
// Cache availability must not block public media rendering or cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private static function filesystem(?string $disk): Filesystem
|
||||
{
|
||||
return Storage::disk($disk ?? PublicImageUploadRules::disk());
|
||||
return Storage::disk(self::diskName($disk));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{path: string, width: int}>
|
||||
*/
|
||||
private static function existingVariants(Filesystem $filesystem, string $path, bool $webp): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = $webp
|
||||
? self::webpVariantPath($path, $width)
|
||||
: self::variantPath($path, $width);
|
||||
|
||||
if ($filesystem->exists($variantPath)) {
|
||||
$variants[] = ['path' => $variantPath, 'width' => $width];
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
private static function metadataCacheKey(string $path, ?string $disk): string
|
||||
{
|
||||
return 'responsive-image:metadata:'.sha1(self::diskName($disk).'|'.$path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function cachedMetadata(string $key): ?array
|
||||
{
|
||||
try {
|
||||
$cached = Cache::get($key);
|
||||
|
||||
return is_array($cached) ? $cached : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private static function storeMetadata(string $key, array $metadata): void
|
||||
{
|
||||
try {
|
||||
Cache::forever($key, $metadata);
|
||||
} catch (Throwable) {
|
||||
// The uncached result remains safe to use for this request.
|
||||
}
|
||||
}
|
||||
|
||||
private static function diskName(?string $disk): string
|
||||
{
|
||||
return $disk ?? PublicImageUploadRules::disk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,6 @@
|
||||
"npm audit --omit=dev --audit-level=high",
|
||||
"@test"
|
||||
],
|
||||
"visual:update": [
|
||||
"@php artisan test --testsuite=Browser --update-snapshots"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi",
|
||||
|
||||
@@ -73,7 +73,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When set outside production, the application clock is frozen for
|
||||
| deterministic rendering (visual regression / seeded content).
|
||||
| deterministic time-dependent tests or seeded content.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('hero_image_path')->nullable()->after('hero_note');
|
||||
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
|
||||
$table->string('services_hero_image_path')->nullable()->after('hero_image_alt');
|
||||
$table->string('services_hero_image_alt')->nullable()->after('services_hero_image_path');
|
||||
$table->string('portfolio_hero_image_path')->nullable()->after('services_hero_image_alt');
|
||||
$table->string('portfolio_hero_image_alt')->nullable()->after('portfolio_hero_image_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'services_hero_image_path',
|
||||
'services_hero_image_alt',
|
||||
'portfolio_hero_image_path',
|
||||
'portfolio_hero_image_alt',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -61,6 +61,12 @@ class ContentSeeder extends Seeder
|
||||
'hero_cta_label' => 'Solicitar proposta',
|
||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||
'hero_image_path' => $this->copyFixture('case-casamento-ana-lucas.jpg', 'content/heroes/home.jpg'),
|
||||
'hero_image_alt' => 'Celebração ao ar livre em São Paulo',
|
||||
'services_hero_image_path' => $this->copyFixture('service-eventos-corporativos.jpg', 'content/heroes/services.jpg'),
|
||||
'services_hero_image_alt' => 'Mesa preparada para um evento corporativo',
|
||||
'portfolio_hero_image_path' => $this->copyFixture('case-lancamento-verano.jpg', 'content/heroes/portfolio.jpg'),
|
||||
'portfolio_hero_image_alt' => 'Ambientação de um evento de lançamento',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
||||
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Deterministic content for visual regression. Keep separate from ContentSeeder demo data.
|
||||
* Deterministic content and image fixtures. Keep separate from ContentSeeder demo data.
|
||||
*/
|
||||
class VisualContentSeeder extends Seeder
|
||||
{
|
||||
@@ -42,6 +42,12 @@ class VisualContentSeeder extends Seeder
|
||||
'hero_cta_label' => 'Solicitar proposta',
|
||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||
'hero_image_path' => $this->writeSolidJpeg('visual/heroes/home.jpg', 1600, 1100, [218, 224, 205]),
|
||||
'hero_image_alt' => 'Imagem editorial da home',
|
||||
'services_hero_image_path' => $this->writeSolidJpeg('visual/heroes/services.jpg', 1600, 1100, [196, 200, 184]),
|
||||
'services_hero_image_alt' => 'Imagem editorial de Serviços',
|
||||
'portfolio_hero_image_path' => $this->writeSolidJpeg('visual/heroes/portfolio.jpg', 1600, 1100, [228, 226, 221]),
|
||||
'portfolio_hero_image_alt' => 'Imagem editorial de Portfólio',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
||||
'about_image_alt' => 'Imagem editorial da página Sobre',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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"]
|
||||
@@ -101,9 +101,8 @@ 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
|
||||
usa hoje.
|
||||
|
||||
Isto absorve MAN-122: os 16 baselines visuais foram regenerados no runner Linux
|
||||
(`scripts/test/visual-update-ci.sh`), e o diff é imperceptível a 2× de zoom —
|
||||
mesma forma, mesma cor, só menos bytes.
|
||||
Isto absorve MAN-122: os ativos de marca foram reencodados sem alterar a forma
|
||||
ou a cor percebida, reduzindo apenas o peso transferido.
|
||||
|
||||
Os arquivos versionados aqui são: `2026-08-10-local-antes.md` (commit `2e43fde`),
|
||||
`2026-08-10-local-etapa-fontes-e-marca.md` (passada intermediária) e
|
||||
@@ -174,12 +173,11 @@ em ordem de custo:
|
||||
|
||||
## Cobertura que este trabalho não tem
|
||||
|
||||
Os testes de regressão visual nunca exercitam `srcset` nem `<picture>`: nem
|
||||
`ContentSeeder` nem `VisualContentSeeder` geram variantes, e `media:generate-variants`
|
||||
não roda no runner visual. Naquele ambiente `availableVariants()` volta vazio e o
|
||||
componente renderiza `<img>` puro — foi por isso que os 16 baselines não mudaram
|
||||
com a introdução do `<picture>`. A cobertura do caminho com variantes fica nos
|
||||
testes de feature (`MediaImageComponentTest`), não nos baselines.
|
||||
Os testes browser não exercitam `srcset` nem `<picture>` com variantes geradas:
|
||||
nem `ContentSeeder` nem `VisualContentSeeder` geram variantes. Nesses cenários,
|
||||
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A
|
||||
cobertura do caminho com variantes fica nos testes de feature
|
||||
(`MediaImageComponentTest`).
|
||||
|
||||
## Staging
|
||||
|
||||
|
||||
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-11
|
||||
@@ -0,0 +1,37 @@
|
||||
## Context
|
||||
|
||||
A suíte browser contém uma camada exclusiva de comparação de pixels: um teste com baselines versionados, um trait para estabilizar capturas, exportação de diffs e configuração de CI para seeds e artefatos determinísticos. O restante da cobertura browser — smoke, acessibilidade, motion e fixtures de conteúdo — não depende dessa camada.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Remover integralmente a comparação de pixels, os baselines e a infraestrutura exclusiva de suporte.
|
||||
- Manter o job browser em FrankenPHP e suas verificações funcionais, de acessibilidade e motion.
|
||||
- Atualizar as fontes normativas e as specs ativas para que não exijam snapshots visuais.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Não substituir a comparação de pixels por outro serviço ou ferramenta de regressão visual.
|
||||
- Não remover `VisualContentSeeder`, fixtures JPEG, `APP_FROZEN_NOW` que tenham usos fora das capturas, screenshots documentais ou testes browser não relacionados.
|
||||
- Não alterar APIs públicas, rotas, conteúdo nem comportamentos de interface.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Eliminar a capability em vez de reduzir a matriz.** Todos os requisitos de `visual-regression` serão removidos, pois o produto não terá mais comparação de pixels. Alternativa rejeitada: conservar apenas algumas telas, pois ainda manteria baselines e o custo operacional indesejado.
|
||||
2. **Preservar diagnósticos gerais de browser.** O CI manterá screenshots, logs e relatórios úteis a falhas funcionais; serão removidos apenas seed global, relógio congelado e artefatos específicos de baseline/diff. Alternativa rejeitada: retirar todos os diagnósticos, pois reduziria a capacidade de investigar falhas não visuais.
|
||||
3. **Separar determinismo reutilizável da captura visual.** `VisualContentSeeder` e `APP_FROZEN_NOW` permanecem quando usados por outros testes ou pela aplicação; somente as referências exclusivas a screenshot são excluídas. Alternativa rejeitada: remover os nomes por associação, pois quebraria fixtures e cenários existentes.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Uma mudança puramente visual deixa de bloquear CI] → acessibilidade, smoke, motion e revisão humana continuam no fluxo de qualidade.
|
||||
- [Remover preparação comum do job browser afeta testes restantes] → inspecionar usos de seed/relógio e executar a suíte browser contra FrankenPHP após a alteração.
|
||||
- [Uma spec ativa ainda reintroduz snapshots] → remover as menções no delta de `enhance-public-motion` antes da validação OpenSpec.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Publicar a remoção junto com a atualização da documentação e do CI; não há migração de dados. O rollback é um revert do commit, restaurando os arquivos versionados e a configuração anterior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Nenhuma.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
As comparações pixel-a-pixel e seus baselines adicionam manutenção e infraestrutura exclusiva sem serem um gate de qualidade desejado. A qualidade do site continuará protegida por jornadas browser, acessibilidade, motion, smoke e screenshots documentais, sem dependência de snapshots versionados.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Remove a capability `visual-regression` e todos os requisitos de baselines, snapshots e diffs visuais.
|
||||
- Atualiza os quality gates para manter a cobertura browser funcional, de acessibilidade e motion sem comparações de pixels.
|
||||
- Remove a infraestrutura de testes, CI e documentação usada exclusivamente para regressão visual.
|
||||
- **BREAKING** para contribuidores: o comando `composer visual:update` deixa de existir.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Nenhuma.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `visual-regression`: Remover os requisitos da capability descontinuada.
|
||||
- `quality-gates`: Remover a exigência de executar e publicar diagnósticos de snapshots visuais.
|
||||
|
||||
## Impact
|
||||
|
||||
Afeta os testes browser e seus helpers exclusivos, baselines rastreados, configuração de Composer e CI, `.gitignore`, `SPEC.md` e a documentação ativa de motion. Não altera rotas, APIs, dados de conteúdo, fixtures reutilizadas, nem a cobertura funcional, de acessibilidade ou motion.
|
||||
@@ -0,0 +1,21 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Browser tests run against FrankenPHP-served application
|
||||
|
||||
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, the automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison.
|
||||
|
||||
#### Scenario: Browser job validates served application
|
||||
|
||||
- **WHEN** the `browser` CI job runs
|
||||
- **THEN** tests execute against the built application artifact or equivalent production-like image
|
||||
|
||||
#### Scenario: Browser job covers functional, accessibility and motion assertions
|
||||
|
||||
- **WHEN** the `browser` CI job runs
|
||||
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites
|
||||
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge
|
||||
|
||||
#### Scenario: Browser failures publish diagnostics
|
||||
|
||||
- **WHEN** a browser test fails in CI
|
||||
- **THEN** the job MUST publish screenshots, application logs and browser logs as diagnostics
|
||||
@@ -0,0 +1,13 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Public screens have desktop and mobile visual baselines
|
||||
**Reason**: O projeto não manterá mais comparação de pixels nem baselines versionados para telas públicas.
|
||||
**Migration**: Remover as asserções de screenshot e os arquivos baseline; manter testes browser funcionais, de acessibilidade, motion e smoke.
|
||||
|
||||
### Requirement: Visual runs are deterministic
|
||||
**Reason**: Não haverá execução de captura para comparação visual.
|
||||
**Migration**: Preservar seeds e relógio congelado apenas onde forem necessários por outros testes.
|
||||
|
||||
### Requirement: Baseline updates are explicit and reviewed
|
||||
**Reason**: O comando e o fluxo de atualização de baselines foram descontinuados junto com a capability.
|
||||
**Migration**: Remover `composer visual:update` e qualquer configuração de CI dedicada a regenerar, transportar ou publicar baselines e diffs.
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. Contratos normativos
|
||||
|
||||
- [x] 1.1 Remover os requisitos e critérios de regressão visual de `SPEC.md`, ADR-005 e do delta ativo `enhance-public-motion`.
|
||||
- [x] 1.2 Sincronizar os deltas da mudança para remover a capability `visual-regression` e atualizar `quality-gates`.
|
||||
|
||||
## 2. Infraestrutura de regressão visual
|
||||
|
||||
- [x] 2.1 Remover o teste browser, os 16 baselines, o trait de screenshot, o exportador de diffs e seu teste unitário.
|
||||
- [x] 2.2 Simplificar o `TestCase` e remover o script Composer e a regra de ignore exclusivos de baselines.
|
||||
|
||||
## 3. Browser CI e documentação operacional
|
||||
|
||||
- [x] 3.1 Remover seed global, relógio congelado e cópias de snapshots/diffs exclusivos de baseline do job browser, preservando diagnósticos funcionais.
|
||||
- [x] 3.2 Atualizar a documentação operacional que referencia o fluxo descontinuado sem afetar fixtures ou congelamento usados fora dele.
|
||||
|
||||
## 4. Verificação e arquivamento
|
||||
|
||||
- [x] 4.1 Confirmar ausência dos símbolos e comandos removidos no código ativo e executar os gates de qualidade aplicáveis.
|
||||
- [x] 4.2 Validar OpenSpec estritamente, marcar as tarefas e arquivar a mudança sincronizada.
|
||||
@@ -10,7 +10,7 @@ O site público usa Blade/Tailwind e já contém um runtime pequeno em `resource
|
||||
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
|
||||
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
|
||||
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
|
||||
- Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
|
||||
- Cobrir marcação, comportamento real, acessibilidade e console.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
@@ -32,15 +32,14 @@ O site público usa Blade/Tailwind e já contém um runtime pequeno em `resource
|
||||
|
||||
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
|
||||
|
||||
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
|
||||
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe e console incluem sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
|
||||
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
|
||||
- [Snapshots ficam instáveis] → continuar capturando com `reducedMotion: reduce` e transições desabilitadas.
|
||||
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
|
||||
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é o baseline seguro; feature tests cobrem os cinco templates.
|
||||
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é seguro; feature tests cobrem os cinco templates.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ O site público já possui uma abertura focal na home, mas o restante da experi
|
||||
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
|
||||
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
|
||||
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
|
||||
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade, console e regressão visual.
|
||||
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade e console.
|
||||
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
|
||||
|
||||
## Capabilities
|
||||
@@ -23,7 +23,6 @@ O site público já possui uma abertura focal na home, mas o restante da experi
|
||||
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
|
||||
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
|
||||
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
|
||||
- `visual-regression`: Ampliar a matriz visual para sobre, contato, privacidade e 404, mantendo capturas determinísticas sem animação.
|
||||
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
|
||||
|
||||
## Impact
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public screens have desktop and mobile visual baselines
|
||||
|
||||
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio, Detalhe do portfólio, Sobre, Contato, Privacidade and branded 404, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
|
||||
|
||||
#### Scenario: Unintended visual change fails the suite
|
||||
|
||||
- **GIVEN** approved baselines exist
|
||||
- **WHEN** a code change alters the rendering of a covered screen
|
||||
- **THEN** the visual assertion MUST fail and report the diff
|
||||
|
||||
#### Scenario: Both viewports are covered
|
||||
|
||||
- **WHEN** the visual suite runs
|
||||
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
|
||||
|
||||
#### Scenario: Heritage Editorial identity is captured
|
||||
|
||||
- **WHEN** approved baselines for the home are reviewed after this change
|
||||
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
|
||||
|
||||
#### Scenario: Motion does not destabilize new baselines
|
||||
|
||||
- **WHEN** Sobre, Contato, Privacidade, or 404 is captured
|
||||
- **THEN** the browser MUST use the final reduced-motion state before asserting the screenshot
|
||||
@@ -15,7 +15,7 @@
|
||||
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
|
||||
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
|
||||
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
|
||||
- [x] 3.4 Extend deterministic desktop/mobile visual snapshots to about, contact, privacy and branded 404 and review the generated diffs
|
||||
- [x] 3.4 Extend axe and console route matrices to about, contact, privacy and branded 404
|
||||
|
||||
## 4. Verification and delivery
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-11
|
||||
44
openspec/changes/recompose-public-editorial/design.md
Normal file
44
openspec/changes/recompose-public-editorial/design.md
Normal file
@@ -0,0 +1,44 @@
|
||||
## Context
|
||||
|
||||
O commit de base ja separa as imagens hero de Open Graph, oferece variantes responsivas, fallbacks tonais e preserva o formulario de briefing. As views publicas ainda usam algumas grades simetricas, especialmente nas colecoes de portfolio. A referencia aprovada define composicao e materialidade, nunca novo acervo ou copy.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Aplicar uma cadencia de campos tonais, regras e proporcoes alternadas nas paginas narrativas.
|
||||
- Manter uma ordem DOM linear e completa em telas pequenas.
|
||||
- Reforcar os contratos existentes de acessibilidade, motion progressivo, performance de imagem e SEO.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Alterar dados CMS, contratos de rota, controller, metadata, uploads, variantes ou o formulario.
|
||||
- Usar imagens externas, criar conteudo comercial, adicionar dependencias, atualizar snapshots ou reintroduzir regressao visual.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Usar variacoes de `col-span` e `aspect-ratio` nas galerias existentes
|
||||
|
||||
As imagens continuam no mesmo componente responsivo, mas a primeira imagem e os itens pares recebem encaixes editoriais no desktop. A alternativa seria uma galeria JavaScript ou masonry; foi descartada por prejudicar a ordem de leitura e adicionar comportamento sem necessidade.
|
||||
|
||||
### Aplicar assimetria pela composicao, nao por posicionamento absoluto
|
||||
|
||||
Grid responsivo, margens e campos tonais criam o deslocamento sem tirar conteudo do fluxo. Isso preserva foco, leitura mobile e fallback sem JavaScript. Posicionamento absoluto foi descartado por aumentar risco de sobreposicao e overflow.
|
||||
|
||||
### Reutilizar motion de transformacao e clip ja existente
|
||||
|
||||
As novas regioes conservam `data-reveal` e grupos existentes. Nenhum texto passa a depender de opacidade; `prefers-reduced-motion` continua exibindo o estado final imediatamente.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Ritmo visual pode parecer irregular com poucos cases] -> regras e metadados mantem uma leitura coerente mesmo com um unico item.
|
||||
- [Classes responsivas podem afetar o fluxo em mobile] -> DOM linear e classes de uma coluna continuam sendo a base abaixo de `md`.
|
||||
- [Mudar CSS pode gerar diferencas de ambiente] -> validar markup, acessibilidade funcional e build; snapshots permanecem fora do escopo.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Nao ha migracao de dados. A mudanca e retrocompativel: sem imagens, os fallbacks tonais existentes continuam ativos; com imagens, o mesmo `srcset` responsivo e usado. Reverter o commit restaura somente apresentacao.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Nenhuma. Fotografia autorizada de producao permanece uma dependencia de conteudo futura.
|
||||
30
openspec/changes/recompose-public-editorial/proposal.md
Normal file
30
openspec/changes/recompose-public-editorial/proposal.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
O site público já possui os conteúdos, fluxos e a base Heritage Editorial, mas algumas páginas ainda repetem grades regulares que enfraquecem a cadência da referência aprovada. A recomposição consolida uma leitura editorial contínua sem alterar dados CMS, SEO ou a jornada de briefing.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Recompõe as superfícies públicas narrativas com campos tonais, regras finas, assimetria desktop e sequência vertical legível em mobile.
|
||||
- Organiza a home nos capítulos editoriais existentes, com portfólio em campo oliva e recortes de imagens em proporções alternadas.
|
||||
- Reestiliza listagens e detalhe de portfólio para que imagens e blocos de leitura não dependam de uma grade de cartões repetida.
|
||||
- Mantém contato, privacidade e erros em tratamento sóbrio; preserva menu móvel, foco, formulário, SEO, motion progressivo e conteúdo do CMS.
|
||||
- Documenta que heros fotográficos e sua administração já estão presentes na base atual, sem acoplar mídia editorial à imagem Open Graph.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- Nenhuma.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `public-site-pages`: composição e cadência visual das rotas públicas passam a exigir ritmos editoriais assimétricos.
|
||||
- `content-media`: a apresentação de mídia de portfólio passa a suportar proporções editoriais alternadas sem mudar armazenamento ou variantes.
|
||||
|
||||
## Impact
|
||||
|
||||
Afeta Blade e estilos do site público, testes de estrutura/renderização e a documentação OpenSpec. Não altera rotas, controladores, dados de conteúdo, dependências ou contratos de SEO.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Não introduz itens excluídos por SPEC.md §4.2, novos canais, prova comercial, conteúdo de produção, imagens externas, dependências de animação, snapshots visuais ou mudanças no Filament.
|
||||
@@ -0,0 +1,16 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Editorial image treatment remains self-hosted and deterministic
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Editorial layouts MUST use CSS-only tonal treatment and alternating aspect ratios for portfolio media while retaining document order and the image component's `srcset`, `sizes`, dimensions and loading behavior. The system MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
|
||||
|
||||
#### Scenario: Public pages do not depend on external stock hosts
|
||||
|
||||
- **WHEN** the visual or browser suite loads covered public routes
|
||||
- **THEN** content images MUST resolve from the application media disk or static fixtures
|
||||
- **AND** MUST NOT require network access to third-party stock hosts
|
||||
|
||||
#### Scenario: Editorial portfolio media retains responsive delivery
|
||||
|
||||
- **WHEN** a portfolio listing or gallery applies an editorial image proportion
|
||||
- **THEN** the rendered image MUST still expose the responsive media component markup
|
||||
- **AND** image order and lazy-loading behavior MUST remain intact
|
||||
@@ -0,0 +1,69 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Home renders the editorial structure from CMS content
|
||||
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition: asymmetric spreads and tonal fields on desktop, with portfolio images in alternating editorial proportions, and a linear complete sequence on mobile rather than rounded card grids.
|
||||
|
||||
#### Scenario: Published content is displayed in configured order
|
||||
|
||||
- **GIVEN** published services, cases and testimonials exist
|
||||
- **WHEN** a visitor loads the home
|
||||
- **THEN** the published content MUST be displayed following the `sort_order` and featured flags
|
||||
- **AND** the hero MUST show the values stored in `site_settings`
|
||||
- **AND** the manifesto, method and positioning sections MUST be present
|
||||
- **AND** the portfolio section MUST use an olive tonal field with editorial image proportions
|
||||
|
||||
#### Scenario: CTA leads to the contact placeholder page
|
||||
|
||||
- **WHEN** a visitor activates the primary or final CTA on the home
|
||||
- **THEN** the visitor MUST be taken to the `contact` route
|
||||
- **AND** no lead record MUST be created
|
||||
|
||||
#### Scenario: Empty catalog sections are omitted
|
||||
|
||||
- **GIVEN** no published services, cases or testimonials
|
||||
- **WHEN** a visitor loads the home
|
||||
- **THEN** the response MUST be 200
|
||||
- **AND** the services, portfolio and testimonials sections MUST be omitted instead of rendering empty containers
|
||||
- **AND** hero, manifesto, method, positioning and final CTA MUST still render
|
||||
|
||||
#### Scenario: Home has no console errors
|
||||
|
||||
- **WHEN** the home is loaded in a real browser at desktop and mobile viewports
|
||||
- **THEN** the browser console MUST contain no JavaScript errors
|
||||
|
||||
### Requirement: Listing and detail pages exist for catalog content
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
|
||||
|
||||
#### Scenario: Services listing shows published services
|
||||
|
||||
- **WHEN** a visitor loads `/servicos`
|
||||
- **THEN** every published service MUST be listed with title and summary in `sort_order`
|
||||
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
|
||||
|
||||
#### Scenario: Gallery respects stored order
|
||||
|
||||
- **GIVEN** a published case with multiple gallery images
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the images MUST be rendered ordered by `sort_order`
|
||||
- **AND** desktop presentation MUST alternate editorial image proportions without changing that order
|
||||
|
||||
#### Scenario: Listings paginate open-ended growth
|
||||
|
||||
- **WHEN** the number of published cases exceeds the page size
|
||||
- **THEN** `/portfolio` MUST paginate instead of rendering all records
|
||||
|
||||
### Requirement: Institutional and error pages have brand identity
|
||||
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. Contact, privacy and error surfaces MUST remain sober tonal layouts without decorative photography. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
|
||||
|
||||
#### Scenario: Unknown URL renders branded 404
|
||||
|
||||
- **WHEN** a visitor requests a non-existent public URL
|
||||
- **THEN** the response status MUST be 404
|
||||
- **AND** the page MUST use the public layout and offer navigation back to the home
|
||||
|
||||
#### Scenario: Server error hides internals in production
|
||||
|
||||
- **GIVEN** `APP_DEBUG` is false
|
||||
- **WHEN** an unhandled exception occurs on a public route
|
||||
- **THEN** the response MUST be a generic branded error page
|
||||
- **AND** MUST NOT contain a stack trace, file path, or environment variable
|
||||
10
openspec/changes/recompose-public-editorial/tasks.md
Normal file
10
openspec/changes/recompose-public-editorial/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Editorial composition
|
||||
|
||||
- [x] 1.1 Add regression coverage for alternating public portfolio composition and sober functional surfaces.
|
||||
- [x] 1.2 Recompose home portfolio, portfolio listing and case gallery with responsive editorial proportions while preserving order and responsive media delivery.
|
||||
- [x] 1.3 Refine public content-page rhythm with tonal fields and desktop offsets without changing routes, CMS content or briefing behavior.
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 Run focused feature and browser checks for public routes, motion, accessibility and navigation.
|
||||
- [x] 2.2 Run formatting, static analysis, frontend build and strict OpenSpec validation without updating snapshots.
|
||||
@@ -127,7 +127,7 @@ The system SHALL provide optimized Amare brand logo assets derived from the offi
|
||||
|
||||
### Requirement: Editorial image treatment remains self-hosted and deterministic
|
||||
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that break deterministic visual tests.
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
|
||||
|
||||
#### Scenario: Public pages do not depend on external stock hosts
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# quality-gates Specification
|
||||
|
||||
## Purpose
|
||||
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser visual and accessibility coverage.
|
||||
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser functional, accessibility and motion coverage.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Standardized Composer test scripts exist
|
||||
@@ -53,20 +53,20 @@ The system SHALL configure `phpunit.xml` so that the Feature test suite uses Pos
|
||||
|
||||
### Requirement: Browser tests run against FrankenPHP-served application
|
||||
|
||||
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, the visual regression assertions and the automated accessibility checks for public routes, and MUST run in assertion mode without regenerating baselines (SPEC §13.4, §13.5, §13.8, §14.1).
|
||||
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison.
|
||||
|
||||
#### Scenario: Browser job validates served application
|
||||
|
||||
- **WHEN** the `browser` CI job runs
|
||||
- **THEN** tests execute against the built application artifact or equivalent production-like image
|
||||
|
||||
#### Scenario: Browser job covers visual and accessibility assertions
|
||||
#### Scenario: Browser job covers functional, accessibility and motion assertions
|
||||
|
||||
- **WHEN** the `browser` CI job runs
|
||||
- **THEN** it MUST execute the visual regression suite and the accessibility suite
|
||||
- **AND** a failing snapshot or a critical/serious accessibility issue MUST block merge
|
||||
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites
|
||||
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge
|
||||
|
||||
#### Scenario: Browser failures publish diagnostics
|
||||
|
||||
- **WHEN** a browser test fails in CI
|
||||
- **THEN** the job MUST publish screenshots, snapshot diffs, application logs and browser logs as artifacts
|
||||
- **THEN** the job MUST publish application logs and browser logs as diagnostics
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# visual-regression Specification
|
||||
|
||||
## Purpose
|
||||
Define deterministic visual baselines for public screens and the explicit baseline update workflow.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Public screens have desktop and mobile visual baselines
|
||||
|
||||
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio and Detalhe do portfólio, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
|
||||
|
||||
#### Scenario: Unintended visual change fails the suite
|
||||
|
||||
- **GIVEN** approved baselines exist
|
||||
- **WHEN** a code change alters the rendering of a covered screen
|
||||
- **THEN** the visual assertion MUST fail and report the diff
|
||||
|
||||
#### Scenario: Both viewports are covered
|
||||
|
||||
- **WHEN** the visual suite runs
|
||||
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
|
||||
|
||||
#### Scenario: Heritage Editorial identity is captured
|
||||
|
||||
- **WHEN** approved baselines for the home are reviewed after this change
|
||||
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
|
||||
|
||||
### Requirement: Visual runs are deterministic
|
||||
|
||||
Visual runs SHALL be deterministic per SPEC §13.5: fixed Chromium and Linux image, fixed viewport, timezone `America/Sao_Paulo`, locale `pt-BR`, self-hosted fonts installed/bundled for the suite, frozen clock, deterministic seed (including real testimonial subset and São Paulo settings), animations and transitions disabled, and no dependency on external network.
|
||||
|
||||
#### Scenario: Repeated run without code change produces no diff
|
||||
|
||||
- **WHEN** the visual suite runs twice against the same commit and seed
|
||||
- **THEN** both runs MUST pass with no pixel diff
|
||||
|
||||
#### Scenario: Time-dependent content does not cause drift
|
||||
|
||||
- **GIVEN** the clock is frozen and the seed is deterministic
|
||||
- **WHEN** the suite runs on a different calendar day
|
||||
- **THEN** rendered dates MUST remain identical to the baseline
|
||||
|
||||
#### Scenario: Motion is disabled during capture
|
||||
|
||||
- **WHEN** a screenshot is captured
|
||||
- **THEN** CSS animations and transitions MUST be disabled
|
||||
|
||||
### Requirement: Baseline updates are explicit and reviewed
|
||||
|
||||
Baselines SHALL only be updated through the explicit `composer visual:update` command, and the resulting diff MUST be reviewed by a human before merge. Baselines MUST NOT be regenerated automatically to make CI pass.
|
||||
|
||||
#### Scenario: CI does not regenerate baselines
|
||||
|
||||
- **WHEN** the `browser` CI job runs
|
||||
- **THEN** it MUST run in assertion mode
|
||||
- **AND** MUST NOT write new baselines
|
||||
|
||||
#### Scenario: Developer updates baselines intentionally
|
||||
|
||||
- **WHEN** a developer runs `composer visual:update`
|
||||
- **THEN** the updated baseline files MUST be written to the versioned baseline directory for review
|
||||
@@ -130,43 +130,6 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.home-chapters {
|
||||
counter-reset: home-chapter -1;
|
||||
}
|
||||
|
||||
.home-chapter {
|
||||
counter-increment: home-chapter;
|
||||
}
|
||||
|
||||
.home-folio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--amare-color-accent);
|
||||
font-size: var(--amare-text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.home-folio--inverse {
|
||||
color: color-mix(in srgb, var(--amare-color-accent-text) 82%, transparent);
|
||||
}
|
||||
|
||||
.home-folio__separator {
|
||||
color: var(--amare-color-sage);
|
||||
}
|
||||
|
||||
.home-folio--inverse .home-folio__separator {
|
||||
color: color-mix(in srgb, var(--amare-color-accent-text) 55%, transparent);
|
||||
}
|
||||
|
||||
.home-folio__number::before {
|
||||
content: counter(home-chapter, decimal-leading-zero);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.main-nav {
|
||||
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
|
||||
@@ -181,85 +144,6 @@
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
||||
[data-chapter-index] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-chapter-progress] {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-chapter-index] a[aria-current="true"] {
|
||||
color: var(--amare-color-accent-deep);
|
||||
}
|
||||
|
||||
[data-chapter-index] a[aria-current="true"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.35em;
|
||||
bottom: 0.35em;
|
||||
width: 1px;
|
||||
background: var(--amare-color-accent);
|
||||
}
|
||||
|
||||
[data-chapter-progress] > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--chapter-progress, 0%);
|
||||
background: var(--amare-color-accent);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
[data-chapter-index] {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
|
||||
z-index: 30;
|
||||
max-width: 6.5rem;
|
||||
translate: 0 -50%;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
[data-chapter-index] a {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2.75rem;
|
||||
padding-left: 0.75rem;
|
||||
font-size: var(--amare-text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amare-color-muted);
|
||||
text-decoration: none;
|
||||
overflow-wrap: break-word;
|
||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||
}
|
||||
|
||||
[data-chapter-index] a:hover {
|
||||
color: var(--amare-color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
[data-chapter-progress] {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 45;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:where(a, button, input, textarea, select) {
|
||||
transition-duration: var(--amare-motion-feedback);
|
||||
|
||||
@@ -84,86 +84,11 @@ function observeReveals(enhance) {
|
||||
nodes.forEach((node) => observer.observe(node));
|
||||
}
|
||||
|
||||
function setupChapterIndex() {
|
||||
const index = document.querySelector('[data-chapter-index]');
|
||||
const progress = document.querySelector('[data-chapter-progress] span');
|
||||
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
|
||||
|
||||
if (!index || chapters.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
||||
let framePending = false;
|
||||
|
||||
const setActive = (id) => {
|
||||
links.forEach((link) => {
|
||||
const isCurrent = link.getAttribute('href') === `#${id}`;
|
||||
if (isCurrent) {
|
||||
link.setAttribute('aria-current', 'true');
|
||||
} else {
|
||||
link.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateProgress = (ratio) => {
|
||||
if (!progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.min(1, Math.max(0, ratio));
|
||||
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
|
||||
let current = chapters[0];
|
||||
|
||||
chapters.forEach((chapter) => {
|
||||
if (chapter.offsetTop <= marker) {
|
||||
current = chapter;
|
||||
}
|
||||
});
|
||||
|
||||
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
|
||||
const headingId = heading?.id
|
||||
|| current.getAttribute('aria-labelledby')
|
||||
|| `${current.dataset.chapter}-heading`;
|
||||
|
||||
setActive(headingId);
|
||||
|
||||
const doc = document.documentElement;
|
||||
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
|
||||
updateProgress(window.scrollY / max);
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (framePending) {
|
||||
return;
|
||||
}
|
||||
|
||||
framePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
framePending = false;
|
||||
sync();
|
||||
});
|
||||
};
|
||||
|
||||
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
||||
sync();
|
||||
|
||||
window.addEventListener('scroll', scheduleSync, { passive: true });
|
||||
window.addEventListener('resize', scheduleSync);
|
||||
}
|
||||
|
||||
function bootMotion() {
|
||||
initializeMotionDelays();
|
||||
const enhance = enableEnhancement();
|
||||
activatePageOpen(enhance);
|
||||
observeReveals(enhance);
|
||||
setupChapterIndex();
|
||||
|
||||
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
||||
if (event.matches) {
|
||||
document.documentElement.removeAttribute('data-motion');
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
@props([
|
||||
'chapters' => [],
|
||||
])
|
||||
|
||||
@if (count($chapters) > 0)
|
||||
<nav data-chapter-index aria-label="Índice de capítulos">
|
||||
@foreach ($chapters as $chapter)
|
||||
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div data-chapter-progress aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
@endif
|
||||
@@ -7,14 +7,12 @@
|
||||
'border-t border-amare-border bg-amare-bg-deep py-20',
|
||||
'home-chapter' => $editorial,
|
||||
]) data-chapter="final-cta" data-reveal-group>
|
||||
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
|
||||
@if ($editorial)
|
||||
<x-home.folio label="Próximo passo" class="justify-center" />
|
||||
@else
|
||||
<div class="container-amare max-w-4xl space-y-6 py-4" data-reveal data-reveal-from="up">
|
||||
@unless ($editorial)
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||
@endif
|
||||
@endunless
|
||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||
<p class="max-w-2xl text-amare-muted">
|
||||
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||
</p>
|
||||
<div>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@props([
|
||||
'label',
|
||||
'tone' => 'default',
|
||||
])
|
||||
|
||||
@php
|
||||
$tone = $tone === 'inverse' ? 'inverse' : 'default';
|
||||
@endphp
|
||||
|
||||
<p
|
||||
{{ $attributes->class([
|
||||
'home-folio',
|
||||
'home-folio--inverse' => $tone === 'inverse',
|
||||
]) }}
|
||||
data-home-folio
|
||||
>
|
||||
<span data-home-folio-label>{{ $label }}</span>
|
||||
<span class="home-folio__separator" aria-hidden="true">·</span>
|
||||
<span class="home-folio__number" data-home-folio-number aria-hidden="true"></span>
|
||||
</p>
|
||||
@@ -4,66 +4,91 @@
|
||||
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
@class([
|
||||
'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-motion="page-open"
|
||||
>
|
||||
<div class="container-amare pt-8 md:pt-10">
|
||||
<x-home.folio label="Capa" data-motion-beat="folio" />
|
||||
</div>
|
||||
@if (filled($settings->hero_image_path))
|
||||
<div class="grid md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div
|
||||
data-hero-content
|
||||
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 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>
|
||||
|
||||
<div class="container-amare grid gap-12 pb-20 pt-10 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:pb-28 md:pt-14" data-reveal-group>
|
||||
<div class="space-y-8">
|
||||
<div 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->default_og_image_path))
|
||||
<div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
|
||||
<x-media.image
|
||||
:path="$settings->default_og_image_path"
|
||||
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
||||
class="img-editorial h-full w-full object-cover"
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<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>
|
||||
<div class="flex flex-col justify-center space-y-8 md:py-12">
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<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>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||
<div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Manifesto" />
|
||||
<span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span>
|
||||
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" data-chapter="manifesto">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group>
|
||||
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left">
|
||||
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
||||
</div>
|
||||
<div class="space-y-6" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-6 md:col-span-7 md:col-start-6" data-reveal data-reveal-from="up">
|
||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-24" data-chapter="method">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start" data-reveal-group>
|
||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Método" />
|
||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||
<x-brand.logo
|
||||
|
||||
@@ -3,25 +3,43 @@
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Portfólio" tone="inverse" />
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up">
|
||||
<div class="md:col-span-7 md:col-start-5 space-y-3">
|
||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
||||
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-10 md:grid-cols-2">
|
||||
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio>
|
||||
@foreach ($cases as $case)
|
||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal data-reveal-from="up">
|
||||
@php
|
||||
$isFeature = $loop->first;
|
||||
$isOffset = ! $isFeature && $loop->even;
|
||||
@endphp
|
||||
<article
|
||||
@class([
|
||||
'space-y-4 border-t border-amare-accent-text/30 pt-4',
|
||||
'md:col-span-7' => $isFeature,
|
||||
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
|
||||
'md:col-span-5' => ! $isFeature && ! $isOffset,
|
||||
])
|
||||
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
|
||||
data-reveal
|
||||
data-reveal-from="up"
|
||||
>
|
||||
@if (filled($case->cover_image_path))
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
:alt="$case->cover_image_alt ?: $case->title"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||
@class([
|
||||
'img-editorial w-full object-cover',
|
||||
'aspect-[5/4]' => $isFeature,
|
||||
'aspect-[4/5]' => $isOffset,
|
||||
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
|
||||
])
|
||||
/>
|
||||
@endif
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -7,18 +7,27 @@
|
||||
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="positioning">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="A Amare" />
|
||||
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="positioning">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group>
|
||||
<div class="space-y-4 md:col-span-5" data-reveal data-reveal-from="left">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span>
|
||||
<p class="text-sm uppercase tracking-[0.12em] text-amare-text-muted">Assessoria boutique · São Paulo</p>
|
||||
</div>
|
||||
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||
</div>
|
||||
<div class="space-y-8" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-8 md:col-span-6 md:col-start-7" data-reveal data-reveal-from="up">
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||
@if (filled($settings->about_image_path))
|
||||
<div class="overflow-hidden bg-amare-bg-deep" data-reveal-media>
|
||||
<x-media.image
|
||||
:path="$settings->about_image_path"
|
||||
:alt="$settings->about_image_alt ?: $settings->brand_name"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
<ul class="grid gap-3 border-t border-amare-border">
|
||||
@foreach ($principles as $principle)
|
||||
<li class="border-b border-amare-border py-3 text-amare-text-muted">{{ $principle }}</li>
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
])
|
||||
|
||||
@if ($services->isNotEmpty())
|
||||
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="services">
|
||||
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-3 md:col-span-4">
|
||||
<x-home.folio label="Serviços" />
|
||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||
</div>
|
||||
<div class="space-y-4 md:col-span-6 md:col-start-7">
|
||||
|
||||
@@ -9,21 +9,23 @@
|
||||
@endphp
|
||||
|
||||
@if ($testimonials->isNotEmpty())
|
||||
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-8" data-reveal-group>
|
||||
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Depoimentos" />
|
||||
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up">
|
||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="grid gap-6 md:grid-cols-12">
|
||||
@foreach ($testimonials as $testimonial)
|
||||
@php
|
||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||
@endphp
|
||||
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
|
||||
<blockquote @class([
|
||||
'space-y-4 border-t border-amare-border pt-4 md:col-span-5' => $loop->odd,
|
||||
'space-y-4 border-t border-amare-border pt-4 md:col-span-5 md:col-start-7 md:mt-16' => $loop->even,
|
||||
]) data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
|
||||
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
|
||||
<span class="text-3xl leading-none text-amare-sage" aria-hidden="true">“</span>
|
||||
<div class="space-y-3 text-lg text-amare-text">
|
||||
|
||||
@@ -27,22 +27,21 @@
|
||||
->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w')
|
||||
->implode(', ');
|
||||
|
||||
$variants = ResponsiveImage::availableVariants($path, $diskName);
|
||||
// All public rendering reads one durable metadata record. On a cache hit this
|
||||
// avoids both existence probes and downloading the original from R2.
|
||||
$metadata = ResponsiveImage::metadata($path, $diskName);
|
||||
$variants = $metadata['variants'] ?? [];
|
||||
$srcset = $toSrcset($variants);
|
||||
|
||||
if ($srcset === '' && $filesystem->exists($path)) {
|
||||
$srcset = null;
|
||||
}
|
||||
|
||||
// Offered ahead of the original format because webp carries the same picture
|
||||
// for roughly a third of the bytes, and the MAN-109 audit found the hero
|
||||
// image to be the LCP element on every page at mobile widths. Media uploaded
|
||||
// before `media:generate-variants` learned to emit webp has no siblings, so
|
||||
// the <source> is skipped rather than pointed at nothing.
|
||||
$webpSrcset = $toSrcset(ResponsiveImage::availableWebpVariants($path, $diskName));
|
||||
$webpSrcset = $toSrcset($metadata['webp_variants'] ?? []);
|
||||
|
||||
$dimensions = ($width === null || $height === null)
|
||||
? ResponsiveImage::dimensions($path, $diskName)
|
||||
? $metadata
|
||||
: null;
|
||||
|
||||
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
||||
@@ -59,7 +58,8 @@
|
||||
@endif
|
||||
<img
|
||||
src="{{ $src }}"
|
||||
@if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
|
||||
@if ($srcset) srcset="{{ $srcset }}" @endif
|
||||
sizes="{{ $sizes }}"
|
||||
alt="{{ $alt }}"
|
||||
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
|
||||
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
|
||||
|
||||
76
resources/views/components/public/photo-hero.blade.php
Normal file
76
resources/views/components/public/photo-hero.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
@props([
|
||||
'imagePath' => null,
|
||||
'imageAlt' => null,
|
||||
'eyebrow' => null,
|
||||
'title',
|
||||
'summary' => null,
|
||||
'metadata' => null,
|
||||
'headingId' => 'hero-heading',
|
||||
'brandMark' => false,
|
||||
])
|
||||
|
||||
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
|
||||
@if (filled($imagePath))
|
||||
<div class="relative flex min-h-[calc(100dvh-5.5rem)] items-end overflow-hidden" data-photo-hero>
|
||||
<x-media.image
|
||||
:path="$imagePath"
|
||||
:alt="$imageAlt ?: $title"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
sizes="100vw"
|
||||
class="img-editorial absolute inset-0 h-full w-full object-cover"
|
||||
data-motion-beat="media"
|
||||
/>
|
||||
|
||||
<div class="container-amare relative z-10 w-full py-8 md:py-12">
|
||||
<div class="max-w-3xl border border-amare-border bg-amare-bg p-6 text-amare-text md:p-10" data-motion-beat="heading">
|
||||
@if ($brandMark || filled($eyebrow))
|
||||
<div class="mb-5 flex items-center gap-4" data-motion-beat="seal">
|
||||
@if ($brandMark)
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@endif
|
||||
@if (filled($eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">{{ $title }}</h1>
|
||||
@if (filled($summary))
|
||||
<p class="mt-5 max-w-2xl text-lg text-amare-text-muted">{{ $summary }}</p>
|
||||
@endif
|
||||
@if (filled($metadata))
|
||||
<p class="mt-4 text-sm break-words text-amare-text-muted">{{ $metadata }}</p>
|
||||
@endif
|
||||
@if (trim((string) $slot) !== '')
|
||||
<div class="mt-7" data-motion-beat="cta">{{ $slot }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="container-amare py-16 md:py-24" data-tonal-hero>
|
||||
<div class="max-w-3xl space-y-4" data-motion-beat="heading">
|
||||
@if ($brandMark || filled($eyebrow))
|
||||
<div class="flex items-center gap-4" data-motion-beat="seal">
|
||||
@if ($brandMark)
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@endif
|
||||
@if (filled($eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="text-headline font-medium tracking-tight text-amare-text">{{ $title }}</h1>
|
||||
@if (filled($summary))
|
||||
<p class="text-lg text-amare-muted">{{ $summary }}</p>
|
||||
@endif
|
||||
@if (filled($metadata))
|
||||
<p class="text-sm break-words text-amare-muted">{{ $metadata }}</p>
|
||||
@endif
|
||||
@if (trim((string) $slot) !== '')
|
||||
<div data-motion-beat="cta">{{ $slot }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@@ -10,6 +10,24 @@
|
||||
<title>{{ $pageMeta->title }}</title>
|
||||
<x-seo.meta :page-meta="$pageMeta" />
|
||||
|
||||
@php
|
||||
$origin = static function (string $url): ?string {
|
||||
$parts = parse_url($url);
|
||||
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $parts['scheme'].'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
|
||||
};
|
||||
|
||||
$r2Origin = $origin((string) config('filesystems.disks.r2.url'));
|
||||
$siteOrigin = $origin((string) config('app.url'));
|
||||
@endphp
|
||||
@if (\App\Support\PublicImageUploadRules::disk() === 'r2' && $r2Origin !== null && $r2Origin !== $siteOrigin)
|
||||
<link rel="preconnect" href="{{ $r2Origin }}" crossorigin>
|
||||
@endif
|
||||
|
||||
<x-fonts />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
|
||||
@@ -8,30 +8,12 @@
|
||||
$city = $siteSettings->city ?: 'São Paulo - SP';
|
||||
@endphp
|
||||
|
||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||
<div class="space-y-6" data-motion-beat="heading">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
||||
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||
<p class="text-amare-muted">
|
||||
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
||||
presença no dia do evento e uma condução serena do início ao fim.
|
||||
</p>
|
||||
|
||||
@if (filled($siteSettings->about_image_path))
|
||||
<div class="overflow-hidden bg-amare-bg-deep">
|
||||
<x-media.image
|
||||
:path="$siteSettings->about_image_path"
|
||||
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-public.photo-hero :image-path="$siteSettings->about_image_path" :image-alt="$siteSettings->about_image_alt" eyebrow="A Amare" title="Humana no cuidado. Precisa na entrega." :summary="$siteSettings->about_summary">
|
||||
<p class="max-w-xl text-amare-text-muted">A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo, presença no dia do evento e uma condução serena do início ao fim.</p>
|
||||
</x-public.photo-hero>
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare py-16 md:py-24">
|
||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare" data-reveal-group>
|
||||
@foreach ($principles as $index => $principle)
|
||||
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">
|
||||
|
||||
@@ -1,36 +1,6 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$testimonials = $content->testimonials
|
||||
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||
->values();
|
||||
|
||||
$chapters = [
|
||||
['id' => 'hero-heading', 'label' => 'Capa'],
|
||||
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
||||
];
|
||||
|
||||
if ($content->featuredServices->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'services-heading', 'label' => 'Serviços'];
|
||||
}
|
||||
|
||||
if ($content->featuredCases->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'portfolio-heading', 'label' => 'Portfólio'];
|
||||
}
|
||||
|
||||
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
||||
|
||||
if ($testimonials->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
||||
}
|
||||
|
||||
$chapters[] = ['id' => 'positioning-heading', 'label' => 'A Amare'];
|
||||
$chapters[] = ['id' => 'final-cta-heading', 'label' => 'Próximo passo'];
|
||||
@endphp
|
||||
|
||||
<x-home.chapter-index :chapters="$chapters" />
|
||||
|
||||
<div class="home-chapters">
|
||||
<x-home.hero :settings="$content->settings" />
|
||||
<x-home.manifesto :settings="$content->settings" />
|
||||
|
||||
@@ -1,29 +1,56 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
||||
<x-public.photo-hero :image-path="$siteSettings->portfolio_hero_image_path" :image-alt="$siteSettings->portfolio_hero_image_alt" eyebrow="Portfólio" title="Atmosferas que contam histórias." summary="Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência." />
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
||||
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||
</div>
|
||||
|
||||
@if ($cases->isEmpty())
|
||||
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
||||
@else
|
||||
<div class="grid gap-10 md:grid-cols-2" data-reveal-group>
|
||||
@php
|
||||
$hasPrioritizedImage = false;
|
||||
@endphp
|
||||
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio-grid data-reveal-group>
|
||||
@foreach ($cases as $case)
|
||||
<article class="space-y-4" data-reveal data-reveal-from="up">
|
||||
@php
|
||||
$isFeature = $loop->first;
|
||||
$isOffset = ! $isFeature && $loop->even;
|
||||
@endphp
|
||||
<article
|
||||
@class([
|
||||
'space-y-4',
|
||||
'md:col-span-7' => $isFeature,
|
||||
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
|
||||
'md:col-span-5' => ! $isFeature && ! $isOffset,
|
||||
])
|
||||
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
|
||||
data-reveal
|
||||
data-reveal-from="up"
|
||||
>
|
||||
@if (filled($case->cover_image_path))
|
||||
@php
|
||||
$prioritizeImage = ! $hasPrioritizedImage;
|
||||
@endphp
|
||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
:alt="$case->cover_image_alt ?: $case->title"
|
||||
:loading="$prioritizeImage ? 'eager' : 'lazy'"
|
||||
:fetchpriority="$prioritizeImage ? 'high' : null"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]"
|
||||
@class([
|
||||
'img-editorial w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]',
|
||||
'aspect-[5/4]' => $isFeature,
|
||||
'aspect-[4/5]' => $isOffset,
|
||||
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
|
||||
])
|
||||
/>
|
||||
</a>
|
||||
@php
|
||||
$hasPrioritizedImage = true;
|
||||
@endphp
|
||||
@endif
|
||||
<div class="space-y-2 border-t border-amare-border pt-4">
|
||||
<h2 class="text-2xl font-medium text-amare-text">
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<article data-motion="page-open">
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||
<header class="max-w-3xl space-y-4" data-motion-beat="heading">
|
||||
@if (filled($case->event_type))
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||
@endif
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
|
||||
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
||||
<p class="text-sm break-words text-amare-muted">
|
||||
@if ($case->city){{ $case->city }}@endif
|
||||
@if ($case->venue) · {{ $case->venue }}@endif
|
||||
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (filled($case->cover_image_path))
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
:alt="$case->cover_image_alt ?: $case->title"
|
||||
loading="eager"
|
||||
sizes="(max-width: 1024px) calc(100vw - 3rem), 1120px"
|
||||
class="img-editorial aspect-[16/9] w-full object-cover"
|
||||
data-motion-beat="media"
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<article>
|
||||
<x-public.photo-hero :image-path="$case->cover_image_path" :image-alt="$case->cover_image_alt" :eyebrow="$case->event_type" :title="$case->title" :summary="$case->summary" :metadata="collect([$case->city, $case->venue, $case->event_date?->format('d/m/Y')])->filter()->implode(' · ')" />
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
||||
<div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
|
||||
@@ -53,14 +27,34 @@
|
||||
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
|
||||
<div class="container-amare space-y-8 py-16">
|
||||
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2" data-reveal-group>
|
||||
<div class="grid gap-6 md:grid-cols-12 md:gap-x-8 md:gap-y-12" data-editorial-gallery data-reveal-group>
|
||||
@foreach ($case->images as $image)
|
||||
<figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
|
||||
@php
|
||||
$isFeature = $loop->first;
|
||||
$isOffset = ! $isFeature && $loop->even;
|
||||
@endphp
|
||||
<figure
|
||||
@class([
|
||||
'space-y-2',
|
||||
'md:col-span-7' => $isFeature,
|
||||
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
|
||||
'md:col-span-5' => ! $isFeature && ! $isOffset,
|
||||
])
|
||||
data-editorial-gallery-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
|
||||
data-reveal
|
||||
data-reveal-from="up"
|
||||
data-reveal-media
|
||||
>
|
||||
<x-media.image
|
||||
:path="$image->path"
|
||||
:alt="$image->alt_text"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||
@class([
|
||||
'img-editorial w-full object-cover',
|
||||
'aspect-[5/4]' => $isFeature,
|
||||
'aspect-[4/5]' => $isOffset,
|
||||
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
|
||||
])
|
||||
/>
|
||||
@if (filled($image->caption))
|
||||
<figcaption class="text-sm text-amare-muted">{{ $image->caption }}</figcaption>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<x-public.photo-hero :image-path="$siteSettings->services_hero_image_path" :image-alt="$siteSettings->services_hero_image_alt" eyebrow="Serviços" title="Uma mesma excelência, diferentes ocasiões." summary="O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento." />
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
||||
</div>
|
||||
|
||||
@if ($services->isEmpty())
|
||||
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||
@else
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border" data-reveal-group>
|
||||
@php($hasPrioritizedImage = false)
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
|
||||
@foreach ($services as $index => $service)
|
||||
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start" data-reveal data-reveal-from="up">
|
||||
<article @class([
|
||||
'grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start',
|
||||
'md:translate-x-8' => $loop->even,
|
||||
]) data-reveal data-reveal-from="up">
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
||||
@@ -22,12 +23,16 @@
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@if (filled($service->cover_image_path))
|
||||
@php($prioritizeImage = ! $hasPrioritizedImage)
|
||||
<x-media.image
|
||||
:path="$service->cover_image_path"
|
||||
:alt="$service->cover_image_alt ?: $service->title"
|
||||
:loading="$prioritizeImage ? 'eager' : 'lazy'"
|
||||
:fetchpriority="$prioritizeImage ? 'high' : null"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
||||
class="img-editorial aspect-[16/10] w-full object-cover"
|
||||
/>
|
||||
@php($hasPrioritizedImage = true)
|
||||
@endif
|
||||
@if (filled($service->description))
|
||||
<div class="text-amare-muted">
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/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
19
tasks.md
@@ -1,19 +0,0 @@
|
||||
# Fix visual regression baselines (CI-parity)
|
||||
|
||||
- [x] 1. Worktree `fix/visual-baselines-ci` a partir de `origin/main` (.worktrees/visual-baselines-ci @ 0aee15e)
|
||||
- [x] 2. Postgres `amare_test` up (amare-postgres reutilizado, DB criado)
|
||||
- [x] 3. Build `amare-app:ci` + container amare-web com env idêntica ao job browser (APP_FROZEN_NOW etc.) — /up ok
|
||||
- [x] 4. Build imagem amare-ci-runner (ubuntu:24.04 + PHP 8.4 + node 22 + playwright deps)
|
||||
- [x] 5. Regenerar 16 snapshots via `php artisan test --testsuite=Browser --update-snapshots` dentro do ci-runner (network host)
|
||||
- [x] 6. Verificar: re-run Browser suite verde (35/35)
|
||||
- [x] 7. Commit .snap, push, PR #24, CI verde; bump commonmark 2.9.0 p/ auditoria; merged
|
||||
|
||||
# PR #23 — cadência editorial à home (sync + merge)
|
||||
|
||||
- [x] 1. Reset worktree home-editorial-cadence p/ origin/feat/home-editorial-cadence (8ae5824, incl. reconcile)
|
||||
- [x] 2. Merge origin/main (PR #24): resolver conflitos home snaps (theirs); 14 snaps não-home via auto-merge
|
||||
- [x] 3. Rebuild amare-app:ci com código PR #23; container amare-web recriado
|
||||
- [x] 4. Regenerar home desktop/mobile snaps no ci-runner (--update-snapshots); commit d9751b1
|
||||
- [x] 5. Verificação: Browser suite 37 passed (156 assertions) — inclui HomeEditorialCadenceTest/MotionTest
|
||||
- [x] 6. Push; CI 5/5 verde (run 31230461189)
|
||||
- [x] 7. Merge squash PR #23 (commit 42b282c1); worktree/branch/remota limpos
|
||||
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
File diff suppressed because one or more lines are too long
@@ -12,6 +12,86 @@ beforeEach(function (): void {
|
||||
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 {
|
||||
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||
$page = $this->visit('/', [
|
||||
@@ -23,7 +103,6 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
||||
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
|
||||
const clippedValues = new Set(['clip', 'hidden']);
|
||||
const textNodes = Array.from(document.querySelectorAll(
|
||||
'.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span'
|
||||
@@ -36,10 +115,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
|
||||
const current = chapter.getBoundingClientRect();
|
||||
return current.top < previous.bottom - 1;
|
||||
}),
|
||||
overflowingFolios: folios.filter((folio) => {
|
||||
const rect = folio.getBoundingClientRect();
|
||||
return rect.left < -1 || rect.right > window.innerWidth + 1;
|
||||
}).length,
|
||||
ornamentalFolios: document.querySelectorAll('[data-home-folio]').length,
|
||||
clippedText: textNodes.filter((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
|
||||
@@ -53,7 +129,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
|
||||
expect($layout)->toBe([
|
||||
'horizontalOverflow' => false,
|
||||
'overlappingChapters' => false,
|
||||
'overflowingFolios' => 0,
|
||||
'ornamentalFolios' => 0,
|
||||
'clippedText' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ it('keeps every home motion target in final visible state when reduced motion is
|
||||
return {
|
||||
heroActive: hero?.classList.contains('is-active') ?? false,
|
||||
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
||||
hasIndex: Boolean(index),
|
||||
hasActiveChapter: Boolean(active),
|
||||
hasIndex: Boolean(index),
|
||||
hasActiveChapter: Boolean(active),
|
||||
finalReveals,
|
||||
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
|
||||
};
|
||||
@@ -51,8 +51,8 @@ it('keeps every home motion target in final visible state when reduced motion is
|
||||
|
||||
expect($state['heroActive'])->toBeTrue();
|
||||
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
|
||||
expect($state['hasIndex'])->toBeTrue();
|
||||
expect($state['hasActiveChapter'])->toBeTrue();
|
||||
expect($state['hasIndex'])->toBeFalse();
|
||||
expect($state['hasActiveChapter'])->toBeFalse();
|
||||
expect($state['finalReveals'])->toBeTrue();
|
||||
expect($state['enhancementEnabled'])->toBeFalse();
|
||||
});
|
||||
@@ -259,51 +259,7 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
|
||||
}
|
||||
});
|
||||
|
||||
it('updates the active chapter while scrolling the home dossier', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$page->resize(1440, 1000);
|
||||
|
||||
$before = $page->script(<<<'JS'
|
||||
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => {
|
||||
const target = document.querySelector('#method-heading');
|
||||
target?.scrollIntoView({ block: 'start' });
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
return Boolean(target);
|
||||
}
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
const check = () => {
|
||||
const href = document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href');
|
||||
if (href === '#method-heading' || frames > 60) {
|
||||
resolve(href);
|
||||
return;
|
||||
}
|
||||
frames += 1;
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
})
|
||||
JS);
|
||||
|
||||
$after = $page->script(<<<'JS'
|
||||
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
|
||||
JS);
|
||||
|
||||
expect($before)->not->toBeNull();
|
||||
expect($after)->toBe('#method-heading');
|
||||
});
|
||||
|
||||
it('applies CSS numbering to only rendered home folios in chapter order', function (): void {
|
||||
it('keeps rendered home chapters free of ornamental folios and counters', function (): void {
|
||||
PortfolioCase::factory()->published()->create([
|
||||
'is_featured' => true,
|
||||
]);
|
||||
@@ -315,7 +271,7 @@ it('applies CSS numbering to only rendered home folios in chapter order', functi
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
$numbering = $page->script(<<<'JS'
|
||||
$editorial = $page->script(<<<'JS'
|
||||
() => {
|
||||
const flow = document.querySelector('.home-chapters');
|
||||
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
||||
@@ -324,26 +280,12 @@ it('applies CSS numbering to only rendered home folios in chapter order', functi
|
||||
return {
|
||||
reset: flow ? getComputedStyle(flow).counterReset : '',
|
||||
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
|
||||
labels: folios.map((folio) => folio.querySelector('[data-home-folio-label]')?.textContent?.trim() ?? ''),
|
||||
contents: folios.map((folio) => {
|
||||
const number = folio.querySelector('[data-home-folio-number]');
|
||||
return number ? getComputedStyle(number, '::before').content.replace(/["']/g, '') : '';
|
||||
}),
|
||||
folioCount: folios.length,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($numbering['reset'])->toContain('home-chapter -1');
|
||||
expect($numbering['increments'])->each->toContain('home-chapter 1');
|
||||
expect($numbering['labels'])->toBe([
|
||||
'Capa',
|
||||
'Manifesto',
|
||||
'Serviços',
|
||||
'Portfólio',
|
||||
'Método',
|
||||
'Depoimentos',
|
||||
'A Amare',
|
||||
'Próximo passo',
|
||||
]);
|
||||
expect($numbering['contents'])->each->toBe('counter(home-chapter, decimal-leading-zero)');
|
||||
expect($editorial['reset'])->toBe('none');
|
||||
expect($editorial['increments'])->each->toBe('none');
|
||||
expect($editorial['folioCount'])->toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Database\Seeders\VisualContentSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
putenv('APP_FROZEN_NOW='.VisualContentSeeder::FROZEN_NOW);
|
||||
$_ENV['APP_FROZEN_NOW'] = VisualContentSeeder::FROZEN_NOW;
|
||||
$_SERVER['APP_FROZEN_NOW'] = VisualContentSeeder::FROZEN_NOW;
|
||||
|
||||
$this->refreshApplication();
|
||||
|
||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||
|
||||
SiteSetting::instance();
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
putenv('APP_FROZEN_NOW');
|
||||
unset($_ENV['APP_FROZEN_NOW'], $_SERVER['APP_FROZEN_NOW']);
|
||||
});
|
||||
|
||||
$screens = [
|
||||
'home' => '/',
|
||||
'services' => '/servicos',
|
||||
'portfolio' => '/portfolio',
|
||||
'portfolio-detail' => '/portfolio/casamento-ana-lucas',
|
||||
'about' => '/sobre',
|
||||
'contact' => '/contato',
|
||||
'privacy' => '/privacidade',
|
||||
'not-found' => '/__missing-visual-page',
|
||||
];
|
||||
|
||||
$viewports = [
|
||||
'desktop' => [1440, 1000],
|
||||
'mobile' => [390, 844],
|
||||
];
|
||||
|
||||
foreach ($screens as $screen => $path) {
|
||||
foreach ($viewports as $viewport => [$width, $height]) {
|
||||
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
||||
$this->assertStableScreenshotMatches(
|
||||
$this->visit($path, [
|
||||
'reducedMotion' => 'reduce',
|
||||
])
|
||||
->withLocale('pt-BR')
|
||||
->withTimezone('America/Sao_Paulo')
|
||||
->resize($width, $height),
|
||||
resetScroll: $path === '/',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,48 @@ class SiteSettingsTest extends TestCase
|
||||
->assertHasNoFormErrors();
|
||||
}
|
||||
|
||||
public function test_admin_can_upload_a_home_hero_image_with_alt_text(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
SiteSetting::instance();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(ManageSiteSettings::class)
|
||||
->set('data.hero_image_path', [
|
||||
UploadedFile::fake()->image('hero.jpg', 1600, 1000),
|
||||
])
|
||||
->set('data.hero_image_alt', 'Convidados em uma celebração ao ar livre')
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$settings = SiteSetting::instance()->refresh();
|
||||
|
||||
$this->assertSame('Convidados em uma celebração ao ar livre', $settings->hero_image_alt);
|
||||
$this->assertNotNull($settings->hero_image_path);
|
||||
Storage::disk('public')->assertExists($settings->hero_image_path);
|
||||
}
|
||||
|
||||
public function test_home_hero_image_requires_alt_text(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
SiteSetting::instance();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(ManageSiteSettings::class)
|
||||
->set('data.hero_image_path', [
|
||||
UploadedFile::fake()->image('hero.jpg', 1600, 1000),
|
||||
])
|
||||
->set('data.hero_image_alt', null)
|
||||
->call('save')
|
||||
->assertHasFormErrors(['hero_image_alt' => 'required']);
|
||||
}
|
||||
|
||||
public function test_content_seeder_includes_default_og_image_alt_text(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Support\ResponsiveImage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
@@ -37,4 +39,48 @@ class MediaGenerateVariantsCommandTest extends TestCase
|
||||
Storage::disk('public')->assertExists(ResponsiveImage::variantPath($path, $width));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_command_invalidates_metadata_for_a_missing_original(): void
|
||||
{
|
||||
config(['cache.default' => 'array']);
|
||||
Cache::store('array')->flush();
|
||||
Storage::fake('public');
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$path = 'content/services/missing.jpg';
|
||||
Storage::disk('public')->put($path, (string) $manager->create(1400, 900)->toJpeg());
|
||||
ResponsiveImage::generate($path, 'public');
|
||||
ResponsiveImage::metadata($path, 'public');
|
||||
Storage::disk('public')->delete($path);
|
||||
|
||||
Service::factory()->published()->create([
|
||||
'cover_image_path' => $path,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('media:generate-variants');
|
||||
|
||||
$this->assertSame(0, $exitCode);
|
||||
$this->assertNull(ResponsiveImage::metadata($path, 'public'));
|
||||
}
|
||||
|
||||
public function test_command_backfills_variants_for_the_home_hero_image(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$path = 'content/home/hero.jpg';
|
||||
Storage::disk('public')->put($path, (string) $manager->create(1600, 1000)->toJpeg());
|
||||
|
||||
SiteSetting::instance()->update([
|
||||
'hero_image_path' => $path,
|
||||
'hero_image_alt' => 'Celebração ao ar livre',
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('media:generate-variants');
|
||||
|
||||
$this->assertSame(0, $exitCode);
|
||||
foreach (ResponsiveImage::WIDTHS as $width) {
|
||||
Storage::disk('public')->assertExists(ResponsiveImage::variantPath($path, $width));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,41 @@ class HomePageContentTest extends TestCase
|
||||
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
|
||||
}
|
||||
|
||||
public function test_home_hero_uses_its_own_cms_image_instead_of_the_social_og_image(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'hero_image_path' => 'content/home/hero.jpg',
|
||||
'hero_image_alt' => 'Celebração ao ar livre',
|
||||
'default_og_image_path' => 'content/og/social.jpg',
|
||||
'default_og_image_alt' => 'Imagem social',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertSee('content/home/hero.jpg', false)
|
||||
->assertSee('alt="Celebração ao ar livre"', false);
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/data-motion-beat="media"[^>]*>\s*<img[^>]*content\/home\/hero\.jpg/s',
|
||||
$response->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_home_hero_keeps_an_intentional_typographic_fallback_without_media(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'hero_image_path' => null,
|
||||
'hero_image_alt' => null,
|
||||
]);
|
||||
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertSee('min-h-[100dvh]', false)
|
||||
->assertSee('id="hero-heading"', false)
|
||||
->assertSee('data-testid="home-primary-cta"', false)
|
||||
->assertDontSee('data-motion-beat="media"', false);
|
||||
}
|
||||
|
||||
public function test_blank_quote_testimonials_are_skipped(): void
|
||||
{
|
||||
Testimonial::factory()->published()->create([
|
||||
@@ -180,8 +215,8 @@ class HomePageContentTest extends TestCase
|
||||
'Segunda autora',
|
||||
], false);
|
||||
|
||||
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="left"'));
|
||||
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="right"'));
|
||||
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="left"/i', $response->getContent()));
|
||||
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="right"/i', $response->getContent()));
|
||||
}
|
||||
|
||||
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
|
||||
@@ -198,7 +233,7 @@ class HomePageContentTest extends TestCase
|
||||
->assertDontSee('Relato vazio');
|
||||
}
|
||||
|
||||
public function test_home_folios_follow_rendered_chapter_order_and_hide_decorative_numbers(): void
|
||||
public function test_home_removes_ornamental_folios_and_counter_css(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
@@ -208,54 +243,15 @@ class HomePageContentTest extends TestCase
|
||||
|
||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||
|
||||
$this->assertSame([
|
||||
'Capa',
|
||||
'Manifesto',
|
||||
'Serviços',
|
||||
'Portfólio',
|
||||
'Método',
|
||||
'Depoimentos',
|
||||
'A Amare',
|
||||
'Próximo passo',
|
||||
], $this->folioLabels((string) $html));
|
||||
$this->assertSame(8, preg_match_all('/data-home-folio-number[^>]*aria-hidden="true"/i', (string) $html));
|
||||
$this->assertStringNotContainsString('data-home-folio', (string) $html);
|
||||
$this->assertStringNotContainsString('home-folio', (string) $html);
|
||||
$this->assertSame(1, preg_match_all('/<h1\b/i', (string) $html));
|
||||
|
||||
$css = (string) file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
$this->assertStringContainsString('counter-reset: home-chapter -1', $css);
|
||||
$this->assertStringContainsString('counter-increment: home-chapter', $css);
|
||||
$this->assertStringContainsString('counter(home-chapter, decimal-leading-zero)', $css);
|
||||
}
|
||||
|
||||
public function test_home_folios_are_omitted_with_conditional_chapters_without_leaving_markup_gaps(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||
|
||||
$this->assertSame([
|
||||
'Capa',
|
||||
'Manifesto',
|
||||
'Método',
|
||||
'A Amare',
|
||||
'Próximo passo',
|
||||
], $this->folioLabels((string) $html));
|
||||
$this->assertSame(5, preg_match_all('/data-home-folio-number[^>]*aria-hidden="true"/i', (string) $html));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function folioLabels(string $html): array
|
||||
{
|
||||
preg_match_all(
|
||||
'/<span\b[^>]*data-home-folio-label[^>]*>\s*([^<]+?)\s*<\/span>/is',
|
||||
$html,
|
||||
$matches,
|
||||
);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (string $label): string => trim(html_entity_decode($label)),
|
||||
$matches[1],
|
||||
));
|
||||
$this->assertStringNotContainsString('counter-reset: home-chapter', $css);
|
||||
$this->assertStringNotContainsString('counter-increment: home-chapter', $css);
|
||||
$this->assertStringNotContainsString('counter(home-chapter', $css);
|
||||
$this->assertFileDoesNotExist(resource_path('views/components/home/folio.blade.php'));
|
||||
}
|
||||
}
|
||||
|
||||
125
tests/Feature/PublicSite/ImmersivePhotoHeroTest.php
Normal file
125
tests/Feature/PublicSite/ImmersivePhotoHeroTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Support\ResponsiveImage;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MediaImageComponentTest extends TestCase
|
||||
@@ -111,4 +113,26 @@ class MediaImageComponentTest extends TestCase
|
||||
$this->assertStringNotContainsString('<picture', $html);
|
||||
$this->assertStringNotContainsString('image/webp', $html);
|
||||
}
|
||||
|
||||
public function test_render_consults_responsive_metadata_once(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
Cache::shouldReceive('get')
|
||||
->once()
|
||||
->with(Mockery::type('string'))
|
||||
->andReturn([
|
||||
'width' => 1600,
|
||||
'height' => 900,
|
||||
'variants' => [['path' => 'content/cached-component-720.jpg', 'width' => 720]],
|
||||
'webp_variants' => [['path' => 'content/cached-component-720.jpg.webp', 'width' => 720]],
|
||||
]);
|
||||
|
||||
$html = Blade::render('<x-media.image path="content/cached-component.jpg" alt="Cache" />');
|
||||
|
||||
$this->assertStringContainsString('content/cached-component-720.jpg 720w', $html);
|
||||
$this->assertStringContainsString('content/cached-component-720.jpg.webp 720w', $html);
|
||||
$this->assertStringContainsString('width="1600"', $html);
|
||||
$this->assertStringContainsString('height="900"', $html);
|
||||
}
|
||||
}
|
||||
|
||||
95
tests/Feature/PublicSite/MediaPerformanceTest.php
Normal file
95
tests/Feature/PublicSite/MediaPerformanceTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MediaPerformanceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_only_the_first_visible_content_image_is_prioritized(): void
|
||||
{
|
||||
SiteSetting::instance()->update(['about_image_path' => 'content/about.jpg']);
|
||||
|
||||
Service::factory()->published()->create([
|
||||
'cover_image_path' => 'content/service-first.jpg',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
Service::factory()->published()->create([
|
||||
'cover_image_path' => 'content/service-later.jpg',
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$firstCase = PortfolioCase::factory()->published()->create([
|
||||
'cover_image_path' => 'content/case-first.jpg',
|
||||
'slug' => 'case-first',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
PortfolioCase::factory()->published()->create([
|
||||
'cover_image_path' => 'content/case-later.jpg',
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$services = $this->get(route('services.index'))->assertOk()->getContent();
|
||||
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
|
||||
$about = $this->get(route('about'))->assertOk()->getContent();
|
||||
$detail = $this->get(route('portfolio.show', $firstCase->slug))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression('/service-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
|
||||
$this->assertMatchesRegularExpression('/service-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $services);
|
||||
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
||||
$this->assertMatchesRegularExpression('/case-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $portfolio);
|
||||
$this->assertMatchesRegularExpression('/about\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $about);
|
||||
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $detail);
|
||||
}
|
||||
|
||||
public function test_public_layout_preconnects_only_to_an_external_r2_media_origin(): void
|
||||
{
|
||||
config([
|
||||
'app.url' => 'https://amare.example.test',
|
||||
'filesystems.default' => 'r2',
|
||||
'filesystems.disks.r2.url' => 'https://media.example.test/public',
|
||||
]);
|
||||
|
||||
$external = $this->get(route('home'));
|
||||
|
||||
$external
|
||||
->assertOk()
|
||||
->assertSee('<link rel="preconnect" href="https://media.example.test" crossorigin>', false);
|
||||
|
||||
config([
|
||||
'filesystems.default' => 'public',
|
||||
'filesystems.disks.public.url' => '/storage',
|
||||
]);
|
||||
|
||||
$local = $this->get(route('home'));
|
||||
|
||||
$local
|
||||
->assertOk()
|
||||
->assertDontSee('rel="preconnect"', false);
|
||||
}
|
||||
|
||||
public function test_first_visible_index_images_are_prioritized_when_earlier_records_have_no_cover(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
Service::factory()->published()->create(['cover_image_path' => '', 'sort_order' => 1]);
|
||||
Service::factory()->published()->create(['cover_image_path' => 'content/visible-service.jpg', 'sort_order' => 2]);
|
||||
|
||||
PortfolioCase::factory()->published()->create(['cover_image_path' => '', 'sort_order' => 1]);
|
||||
PortfolioCase::factory()->published()->create(['cover_image_path' => 'content/visible-case.jpg', 'sort_order' => 2]);
|
||||
|
||||
$services = $this->get(route('services.index'))->assertOk()->getContent();
|
||||
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression('/visible-service\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
|
||||
$this->assertMatchesRegularExpression('/visible-case\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ class MotionMarkupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_home_exposes_page_open_beats_reveal_groups_and_chapter_index_links(): void
|
||||
public function test_home_exposes_page_open_beats_and_reveal_groups_without_chapter_navigation(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$settings->update([
|
||||
'hero_title' => 'Celebrações com propósito',
|
||||
'default_og_image_path' => 'media/hero.jpg',
|
||||
'default_og_image_alt' => 'Capa editorial',
|
||||
'hero_image_path' => 'media/hero.jpg',
|
||||
'hero_image_alt' => 'Capa editorial',
|
||||
]);
|
||||
|
||||
Service::factory()->published()->featured()->create(['title' => 'Casamentos']);
|
||||
@@ -42,22 +42,15 @@ class MotionMarkupTest extends TestCase
|
||||
->assertSee('data-motion-beat="cta"', false)
|
||||
->assertSee('data-reveal-group', false)
|
||||
->assertSee('data-reveal-from="up"', false)
|
||||
->assertSee('data-chapter-index', false)
|
||||
->assertSee('data-chapter-progress', false)
|
||||
->assertSee('href="#hero-heading"', false)
|
||||
->assertSee('href="#manifesto-heading"', false)
|
||||
->assertSee('href="#services-heading"', false)
|
||||
->assertSee('href="#portfolio-heading"', false)
|
||||
->assertSee('href="#method-heading"', false)
|
||||
->assertSee('href="#testimonials-heading"', false)
|
||||
->assertSee('href="#positioning-heading"', false)
|
||||
->assertSee('href="#final-cta-heading"', false)
|
||||
->assertDontSee('data-chapter-index', false)
|
||||
->assertDontSee('data-chapter-progress', false)
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-chapter="manifesto"', false)
|
||||
->assertSee('data-reveal', false);
|
||||
->assertSee('data-reveal', false)
|
||||
->assertDontSee('data-home-folio', false);
|
||||
}
|
||||
|
||||
public function test_chapter_index_omits_empty_cms_sections(): void
|
||||
public function test_home_has_no_chapter_navigation_when_cms_sections_are_empty(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
@@ -65,15 +58,8 @@ class MotionMarkupTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-chapter-index', false)
|
||||
->assertSee('href="#hero-heading"', false)
|
||||
->assertSee('href="#manifesto-heading"', false)
|
||||
->assertSee('href="#method-heading"', false)
|
||||
->assertSee('href="#positioning-heading"', false)
|
||||
->assertSee('href="#final-cta-heading"', false)
|
||||
->assertDontSee('href="#services-heading"', false)
|
||||
->assertDontSee('href="#portfolio-heading"', false)
|
||||
->assertDontSee('href="#testimonials-heading"', false)
|
||||
->assertDontSee('data-chapter-index', false)
|
||||
->assertDontSee('data-chapter-progress', false)
|
||||
->assertDontSee('id="services-heading"', false)
|
||||
->assertDontSee('id="portfolio-heading"', false)
|
||||
->assertDontSee('id="testimonials-heading"', false);
|
||||
@@ -101,7 +87,7 @@ class MotionMarkupTest extends TestCase
|
||||
$this->assertStringContainsString('[data-reveal-from="left"]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal-from="right"]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal]', $appCss);
|
||||
$this->assertStringContainsString('[data-chapter-index]', $appCss);
|
||||
$this->assertStringNotContainsString('[data-chapter-index]', $appCss);
|
||||
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
|
||||
|
||||
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
|
||||
@@ -121,13 +107,13 @@ class MotionMarkupTest extends TestCase
|
||||
$this->assertFileExists(resource_path('js/motion.js'));
|
||||
$this->assertStringContainsString("import './motion.js'", $appJs);
|
||||
$motionJs = (string) file_get_contents(resource_path('js/motion.js'));
|
||||
$this->assertStringNotContainsString('setupChapterIndex', $motionJs);
|
||||
$this->assertStringContainsString('prefers-reduced-motion', $motionJs);
|
||||
$this->assertStringContainsString("querySelectorAll('[data-reveal-group]')", $motionJs);
|
||||
$this->assertStringContainsString('Math.min(index, 3)', $motionJs);
|
||||
$this->assertStringContainsString('observer.unobserve(entry.target)', $motionJs);
|
||||
$this->assertStringContainsString("typeof IntersectionObserver !== 'function'", $motionJs);
|
||||
$this->assertStringContainsString('requestAnimationFrame', $motionJs);
|
||||
$this->assertStringContainsString('framePending', $motionJs);
|
||||
}
|
||||
|
||||
public function test_all_visual_public_pages_expose_shared_opening_and_reveal_hooks(): void
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\PortfolioImage;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\TestCase;
|
||||
@@ -103,6 +104,56 @@ class PublicPagesTest extends TestCase
|
||||
$this->get(route('portfolio.show', 'rascunho'))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_portfolio_surfaces_expose_editorial_composition_without_changing_content_order(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$firstCase = PortfolioCase::factory()->published()->create([
|
||||
'title' => 'Primeiro caso',
|
||||
'slug' => 'primeiro-caso-editorial',
|
||||
'cover_image_path' => 'content/cases/primeiro.jpg',
|
||||
'is_featured' => true,
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
PortfolioCase::factory()->published()->create([
|
||||
'title' => 'Segundo caso',
|
||||
'slug' => 'segundo-caso-editorial',
|
||||
'cover_image_path' => 'content/cases/segundo.jpg',
|
||||
'is_featured' => true,
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
|
||||
PortfolioImage::query()->create([
|
||||
'portfolio_case_id' => $firstCase->id,
|
||||
'path' => 'content/gallery-primeira.jpg',
|
||||
'alt_text' => 'Primeira imagem',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
PortfolioImage::query()->create([
|
||||
'portfolio_case_id' => $firstCase->id,
|
||||
'path' => 'content/gallery-segunda.jpg',
|
||||
'alt_text' => 'Segunda imagem',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
|
||||
$home = $this->get(route('home'));
|
||||
$portfolio = $this->get(route('portfolio.index'));
|
||||
$detail = $this->get(route('portfolio.show', $firstCase->slug));
|
||||
|
||||
$home
|
||||
->assertOk()
|
||||
->assertSee('data-editorial-portfolio', false)
|
||||
->assertSee('data-editorial-portfolio-item="feature"', false);
|
||||
$portfolio
|
||||
->assertOk()
|
||||
->assertSee('data-editorial-portfolio-grid', false)
|
||||
->assertSeeInOrder(['Primeiro caso', 'Segundo caso']);
|
||||
$detail
|
||||
->assertOk()
|
||||
->assertSee('data-editorial-gallery', false)
|
||||
->assertSeeInOrder(['Primeira imagem', 'Segunda imagem']);
|
||||
}
|
||||
|
||||
public function test_about_privacy_and_contact_use_site_settings_without_creating_leads(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
@@ -119,7 +170,7 @@ class PublicPagesTest extends TestCase
|
||||
->assertSee('Sobre a Amare boutique')
|
||||
->assertSee('São Paulo - SP')
|
||||
->assertDontSee('Fortaleza')
|
||||
->assertSee('min-h-[calc(100dvh-14rem)]', false);
|
||||
->assertSee('data-tonal-hero', false);
|
||||
|
||||
$this->get(route('privacy'))
|
||||
->assertOk()
|
||||
@@ -175,6 +226,8 @@ class PublicPagesTest extends TestCase
|
||||
|
||||
public function test_portfolio_show_avoids_n_plus_one_on_gallery(): void
|
||||
{
|
||||
config(['cache.default' => 'array']);
|
||||
Cache::store('array')->flush();
|
||||
SiteSetting::instance();
|
||||
|
||||
$case = PortfolioCase::factory()->published()->create(['slug' => 'caso-n1']);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class MismatchScreenshotExporter
|
||||
{
|
||||
/**
|
||||
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
|
||||
* HTML file and writes them as standalone PNG files so they can be inspected
|
||||
* directly in CI artifacts.
|
||||
*
|
||||
* @return list<string> paths of the written PNG files
|
||||
*/
|
||||
public static function export(string $htmlPath, string $targetDir): array
|
||||
{
|
||||
$html = (string) file_get_contents($htmlPath);
|
||||
|
||||
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
|
||||
$labels = ['diff', 'expected', 'actual'];
|
||||
$written = [];
|
||||
|
||||
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
|
||||
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
|
||||
|
||||
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$written[] = $path;
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Pest\Browser\Api\AwaitableWebpage;
|
||||
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
|
||||
use Pest\Browser\Support\Screenshot;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
trait StableScreenshot
|
||||
{
|
||||
/**
|
||||
* Asserts the page matches its visual baseline without relying on the
|
||||
* networkidle load state, which is inherently flaky (long-lived connections
|
||||
* like fonts, polling, or keep-alive can keep it from ever firing and cause
|
||||
* spurious timeouts at the 5s client default).
|
||||
*
|
||||
* On mismatch the vendor only writes an HTML diff view with base64-embedded
|
||||
* images, so this helper additionally exports standalone diff/expected/actual
|
||||
* PNG files that are directly viewable in CI artifacts.
|
||||
*/
|
||||
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable, bool $resetScroll = false): void
|
||||
{
|
||||
$page = $awaitable->page();
|
||||
|
||||
$page->addStyleTag('* {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
font-family: Arial, sans-serif !important;
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased !important;
|
||||
-moz-osx-font-smoothing: grayscale !important;
|
||||
}
|
||||
}');
|
||||
|
||||
$page->waitForFunction(
|
||||
'document.readyState === "complete"'
|
||||
.' && document.fonts.status === "loaded"'
|
||||
);
|
||||
|
||||
$page->evaluate(<<<'JS'
|
||||
async () => {
|
||||
const viewport = Math.max(window.innerHeight, 1);
|
||||
|
||||
for (let top = 0; top < document.documentElement.scrollHeight; top += viewport) {
|
||||
window.scrollTo(0, top);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}
|
||||
|
||||
await Promise.all([...document.images].map(async (image) => {
|
||||
if (! image.complete) {
|
||||
await new Promise((resolve) => {
|
||||
image.addEventListener('load', resolve, { once: true });
|
||||
image.addEventListener('error', resolve, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
await image.decode().catch(() => {});
|
||||
}));
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}
|
||||
JS);
|
||||
|
||||
if ($resetScroll) {
|
||||
$page->evaluate('window.scrollTo(0, 0)');
|
||||
$page->waitForFunction('window.scrollY === 0');
|
||||
}
|
||||
|
||||
usleep(300_000);
|
||||
|
||||
try {
|
||||
$page->expectScreenshot(true, false);
|
||||
} catch (ExpectationFailedException $exception) {
|
||||
$this->exportMismatchScreenshots();
|
||||
|
||||
throw BrowserExpectationFailedException::from($page, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function exportMismatchScreenshots(): void
|
||||
{
|
||||
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
|
||||
|
||||
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
|
||||
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
|
||||
|
||||
if (! is_file($htmlPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Tests\Support\StableScreenshot;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
use StableScreenshot;
|
||||
}
|
||||
abstract class TestCase extends BaseTestCase {}
|
||||
|
||||
@@ -5,13 +5,23 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\ResponsiveImage;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResponsiveImageTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['cache.default' => 'array']);
|
||||
Cache::store('array')->flush();
|
||||
}
|
||||
|
||||
public function test_generates_named_variants_and_removes_them_with_original(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
@@ -107,4 +117,74 @@ class ResponsiveImageTest extends TestCase
|
||||
Storage::disk('public')->assertMissing('content/already-480.webp.webp');
|
||||
$this->assertSame([], ResponsiveImage::availableWebpVariants($path, 'public'));
|
||||
}
|
||||
|
||||
public function test_metadata_is_cached_after_the_first_storage_read(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$path = 'content/cached.jpg';
|
||||
Storage::disk('public')->put($path, (string) $manager->create(1600, 900)->toJpeg());
|
||||
ResponsiveImage::generate($path, 'public');
|
||||
|
||||
$metadata = ResponsiveImage::metadata($path, 'public');
|
||||
|
||||
$this->assertSame(['width' => 1600, 'height' => 900], [
|
||||
'width' => $metadata['width'],
|
||||
'height' => $metadata['height'],
|
||||
]);
|
||||
$this->assertSame(ResponsiveImage::WIDTHS, array_column($metadata['variants'], 'width'));
|
||||
$this->assertSame(ResponsiveImage::WIDTHS, array_column($metadata['webp_variants'], 'width'));
|
||||
|
||||
Storage::disk('public')->delete(array_merge(
|
||||
[$path],
|
||||
array_map(fn (int $width): string => ResponsiveImage::variantPath($path, $width), ResponsiveImage::WIDTHS),
|
||||
array_map(fn (int $width): string => ResponsiveImage::webpVariantPath($path, $width), ResponsiveImage::WIDTHS),
|
||||
));
|
||||
|
||||
$this->assertSame($metadata, ResponsiveImage::metadata($path, 'public'));
|
||||
}
|
||||
|
||||
public function test_generation_populates_metadata_and_deletion_invalidates_it(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$path = 'content/generated.jpg';
|
||||
Storage::disk('public')->put($path, (string) $manager->create(1400, 800)->toJpeg());
|
||||
|
||||
ResponsiveImage::generate($path, 'public');
|
||||
|
||||
$this->assertSame(['width' => 1400, 'height' => 800], ResponsiveImage::dimensions($path, 'public'));
|
||||
$this->assertSame(ResponsiveImage::WIDTHS, array_column(ResponsiveImage::availableVariants($path, 'public'), 'width'));
|
||||
|
||||
ResponsiveImage::delete($path, 'public');
|
||||
|
||||
$this->assertNull(ResponsiveImage::metadata($path, 'public'));
|
||||
}
|
||||
|
||||
public function test_replacement_invalidates_old_metadata_and_populates_new_metadata(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
Storage::disk('public')->put('content/old-metadata.jpg', (string) $manager->create(1600, 900)->toJpeg());
|
||||
ResponsiveImage::generate('content/old-metadata.jpg', 'public');
|
||||
ResponsiveImage::metadata('content/old-metadata.jpg', 'public');
|
||||
|
||||
Storage::disk('public')->put('content/new-metadata.jpg', (string) $manager->create(1200, 675)->toJpeg());
|
||||
ResponsiveImage::replace('content/old-metadata.jpg', 'content/new-metadata.jpg', 'public');
|
||||
|
||||
$this->assertNull(ResponsiveImage::metadata('content/old-metadata.jpg', 'public'));
|
||||
$this->assertSame(['width' => 1200, 'height' => 675], ResponsiveImage::dimensions('content/new-metadata.jpg', 'public'));
|
||||
}
|
||||
|
||||
public function test_metadata_falls_back_safely_when_the_cache_is_unavailable(): void
|
||||
{
|
||||
Cache::shouldReceive('get')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Cache unavailable'));
|
||||
|
||||
$this->assertNull(ResponsiveImage::metadata('content/unavailable.jpg', 'public'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\MismatchScreenshotExporter;
|
||||
|
||||
it('exports diff, expected, and actual PNGs from an ImageDiffView html', function (): void {
|
||||
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
|
||||
mkdir($targetDir, 0755, true);
|
||||
|
||||
$expected = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||
$actual = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||
$diff = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<img alt="Diff" src="data:image/png;base64,{$diff}"/>
|
||||
<img slot="image-1" alt="Expected" src="data:image/png;base64,{$expected}"/>
|
||||
<img slot="image-2" alt="Actual" src="data:image/png;base64,{$actual}"/>
|
||||
</body></html>
|
||||
HTML;
|
||||
|
||||
$htmlPath = $targetDir.'/it_matches_home_desktop.html';
|
||||
file_put_contents($htmlPath, $html);
|
||||
|
||||
$written = MismatchScreenshotExporter::export($htmlPath, $targetDir);
|
||||
|
||||
expect($written)->toHaveCount(3);
|
||||
expect($written)->toContain($targetDir.'/it_matches_home_desktop-diff.png');
|
||||
expect($written)->toContain($targetDir.'/it_matches_home_desktop-expected.png');
|
||||
expect($written)->toContain($targetDir.'/it_matches_home_desktop-actual.png');
|
||||
|
||||
expect(base64_decode($diff, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-diff.png'));
|
||||
expect(base64_decode($expected, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-expected.png'));
|
||||
expect(base64_decode($actual, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-actual.png'));
|
||||
});
|
||||
|
||||
it('returns an empty list when the html has no embedded images', function (): void {
|
||||
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
|
||||
mkdir($targetDir, 0755, true);
|
||||
|
||||
$htmlPath = $targetDir.'/plain.html';
|
||||
file_put_contents($htmlPath, '<html><body>no images</body></html>');
|
||||
|
||||
expect(MismatchScreenshotExporter::export($htmlPath, $targetDir))->toBe([]);
|
||||
});
|
||||
Reference in New Issue
Block a user