* feat: ship public site with SEO and visuals Publish CMS content on public routes with responsive media, accessibility checks, and deterministic visual baselines. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: make browser visual CI deterministic for media Mount host public storage into FrankenPHP so seeded fixtures are served, and replace PNG-as-JPG fixtures with real JPEGs so Chromium can render them. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: serve public media on same-origin storage paths Pest Browser hosts on 127.0.0.1:port while Storage::url used http://localhost, so screenshots captured broken images. Use relative /storage URLs for media and absolutize only OG tags via url(). Co-authored-by: Cursor <cursoragent@cursor.com> * test: refresh visual baselines from CI Ubuntu screenshots Media now loads on same-origin /storage paths, so baselines must capture the rendered fixtures. Use full-page snapshots from the CI runner to keep Pest's exact snapshot match stable across environments. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
109 lines
9.7 KiB
Markdown
109 lines
9.7 KiB
Markdown
## Context
|
|
|
|
O repositório está no fim da Fase 1: CMS completo no Filament (`site_settings`, `services`, `portfolio_cases`, `portfolio_images`, `testimonials`), Policies admin-only, factories, `ContentSeeder` e testes feature. O site público, porém, ainda é o placeholder da Fase 0: `routes/web.php` tem apenas `Route::view('/', 'pages.home')`, o layout `resources/views/layouts/public.blade.php` só emite `@yield('title')`, não existe `app/Application/`, não existem componentes Blade de conteúdo, nem snapshots visuais, nem testes de acessibilidade.
|
|
|
|
Restrições relevantes já materializadas no repositório e que condicionam o desenho:
|
|
|
|
- Runtime é `dunglas/frankenphp:1-php8.4-bookworm` **sem extensão `gd`/`imagick`** e **sem fontes instaladas**.
|
|
- `docker/entrypoint.sh` faz apenas `config:cache`, `route:cache`, `view:cache`; **não executa `storage:link`**, então o disco `public` não é servido pelo contêiner.
|
|
- O job `browser` do CI sobe o contêiner apontando para o mesmo PostgreSQL do runner, roda `php artisan migrate --force` no host e **não executa seed** — hoje as páginas testadas não dependem de conteúdo.
|
|
- `PublicImageUploadRules` grava um único arquivo por upload, com nome UUID, no disco `public` (ou `s3` quando padrão).
|
|
- Tailwind 4 sem `tailwind.config.js`; tokens em `resources/css/tokens.css` mapeados por `@theme` em `app.css`, com bloco `prefers-reduced-motion` já presente.
|
|
|
|
## Goals / Non-Goals
|
|
|
|
**Goals:**
|
|
|
|
- Entregar as rotas públicas do SPEC §5.1 que faltam, renderizando somente conteúdo publicado.
|
|
- Home editorial dirigida por `site_settings` e conteúdo publicado, na ordem do SPEC §6.2.
|
|
- SEO renderizado por página (title, description, canonical, Open Graph, JSON-LD), `sitemap.xml` e `robots.txt` por rota.
|
|
- Mídia responsiva com variantes, `srcset`, lazy loading e dimensões reservadas.
|
|
- Regressão visual determinística e verificação automatizada de acessibilidade nas rotas públicas, plugadas no gate `browser`.
|
|
|
|
**Non-Goals:**
|
|
|
|
- Briefing/lead (WEB-05, Fase 2), page builder, busca no site, i18n, PWA.
|
|
- CDN, cache HTTP/edge, otimização além do necessário para as metas do SPEC §6.6.
|
|
- Refatorar o CMS existente além do hook de variantes de imagem.
|
|
|
|
## Decisions
|
|
|
|
### D1 — Páginas públicas em Blade + controllers finos, sem Livewire
|
|
|
|
Rotas apontam para controllers em `app/Http/Controllers/PublicSite/` (`HomeController`, `ServiceController`, `PortfolioController`, `PageController`, `SitemapController`, `RobotsController`). Nenhuma seção pública tem estado ou interação, e o SPEC §11.1 é explícito em não transformar seção estática em componente Livewire.
|
|
|
|
*Alternativas:* componentes Livewire full-page (rejeitado: overhead sem estado, prejudica cache de view e snapshots); rotas `Route::view` (rejeitado: precisam de dados e de metadados de SEO).
|
|
|
|
### D2 — Leitura via Queries finas na camada Application
|
|
|
|
`app/Application/Queries/Marketing/`: `GetHomeContent`, `GetPublishedServices`, `GetPublishedPortfolioCases`, `FindPublishedPortfolioCaseBySlug`, `GetSitemapEntries`. Usam Eloquent direto com `published()` + eager loading explícito, conforme SPEC §9.3 e §9.6. Sem repositórios genéricos, sem Actions (não há escrita nesta change).
|
|
|
|
`GetHomeContent` retorna um DTO readonly (`app/Application/Data/HomeContent.php`) para o controller não montar array solto.
|
|
|
|
*Alternativa:* consultar Models direto na view (rejeitado: N+1 e regra de publicação espalhada).
|
|
|
|
### D3 — SEO por DTO + partial único no layout
|
|
|
|
Cada controller monta `App\Application\Data\PageMeta` (title, description, canonical, ogType, ogImageUrl, ogImageAlt, jsonLd) com fallback para `site_settings`. O layout renderiza um partial `components/seo/meta.blade.php`. A escolha de fallback fica em um único lugar (`PageMeta::forPage()` / `::forCase()`), testável em unit test sem banco.
|
|
|
|
*Alternativa:* pacote de SEO (rejeitado: dependência desnecessária para 7 rotas); `@section('meta')` por página (rejeitado: duplicação e fallback inconsistente).
|
|
|
|
### D4 — Sitemap e robots por rota, sem pacote
|
|
|
|
`/sitemap.xml` retorna uma view Blade XML com `Content-Type: application/xml`, alimentada por `GetSitemapEntries` (rotas estáticas + slugs publicados com `updated_at`). `/robots.txt` vira rota `text/plain` referenciando o sitemap absoluto; o arquivo estático `public/robots.txt` é removido para não sombrear a rota no Caddy.
|
|
|
|
### D5 — Variantes responsivas geradas no upload, nomeadas por convenção
|
|
|
|
Novo `App\Support\ResponsiveImage`:
|
|
|
|
- Larguras fixas: 480, 960, 1440. Formato mantido (jpeg/png/webp), qualidade fixa.
|
|
- Nome derivado do original: `<uuid>.jpg` → `<uuid>-480.jpg`, `<uuid>-960.jpg`, `<uuid>-1440.jpg`, sem coluna nova no banco.
|
|
- Geração síncrona no hook `saveUploadedFileUsing` das FileUploads de `PublicImageUploadRules`, portanto vale para todos os Resources do CMS sem duplicação.
|
|
- Componente Blade `<x-media.image>` monta `srcset`/`sizes`, `width`/`height`, `alt` e `loading` (eager só no hero).
|
|
- Comando `php artisan media:generate-variants` para backfill de imagens já existentes e para o seed.
|
|
|
|
Requer extensão **`gd`** no `Dockerfile`, no CI e no ambiente local, e a dependência `intervention/image` (v3, driver GD).
|
|
|
|
*Alternativas:* coluna `jsonb` com variantes (rejeitado: migração e sincronização de estado por um dado derivável do nome); resize on-the-fly por rota (rejeitado: CPU por request, cache e risco de path traversal); `spatie/laravel-medialibrary` (rejeitado: peso e reescrita do CMS já entregue); checar existência de variante em cada render (rejeitado: `stat`/HEAD por imagem, caro no S3 — por isso a geração é garantida no upload e no backfill).
|
|
|
|
### D6 — Determinismo visual: relógio congelado por env, seed dedicado e fontes self-hosted
|
|
|
|
- `APP_FROZEN_NOW`: quando definido e `APP_ENV !== 'production'`, um provider chama `CarbonImmutable::setTestNow()`. Isso congela o relógio **do servidor**, que é o processo que renderiza — `travelTo()` no processo de teste não afeta o contêiner FrankenPHP.
|
|
- `VisualContentSeeder`: conteúdo fixo (textos, datas, ordem, imagens fixture versionadas em `tests/fixtures/images/`), executado no job `browser` antes da suíte.
|
|
- Fontes **self-hosted** via Vite, sem CDN, e `font-family` sem depender de fonte do sistema — o Chromium do Playwright roda no runner, não na imagem da aplicação, então fonte do sistema seria não determinística.
|
|
- Animações desabilitadas reutilizando o bloco `prefers-reduced-motion` já existente em `tokens.css`, com o Playwright emulando `reduce`. Sem código condicional de teste na aplicação.
|
|
|
|
### D7 — Acessibilidade via axe-core na suíte browser
|
|
|
|
Verificação automatizada nas rotas cobertas usando a assertion de acessibilidade do Pest Browser quando disponível; caso contrário, injetar `axe-core` (devDependency npm) na página e falhar em issues `critical`/`serious`. Regras estruturais baratas (um `h1`, landmarks, `alt` presente) ficam também em feature tests de HTML, que rodam sem browser e falham mais cedo.
|
|
|
|
### D8 — Infra de suporte: `storage:link` e seed no CI
|
|
|
|
- `docker/entrypoint.sh` passa a executar `php artisan storage:link` de forma idempotente, senão as imagens do disco `public` não são servidas pelo contêiner e todo snapshot com imagem falha.
|
|
- Job `browser` do CI passa a rodar `php artisan db:seed --class=VisualContentSeeder --force` antes dos testes e a publicar screenshots, diffs, logs da aplicação e logs do browser em falha (`if: failure()`).
|
|
|
|
### D9 — Páginas de erro
|
|
|
|
`resources/views/errors/404.blade.php` e `500.blade.php` usando o layout público. A 500 não recebe dados da exceção; em produção `APP_DEBUG=false` garante o handler genérico. Um feature test força uma exceção em rota de teste para verificar ausência de stack trace.
|
|
|
|
## Risks / Trade-offs
|
|
|
|
- **Snapshot instável por ambiente de renderização** → fontes self-hosted, viewport fixo, relógio congelado, seed determinístico, `reduce` motion, mesma imagem de aplicação do deploy; baseline só muda por `composer visual:update` com revisão humana.
|
|
- **`gd` + `intervention/image` aumentam a imagem e o tempo de upload** → três larguras fixas, sem editor de imagem, sem fila; se o upload ficar lento na prática, mover para job em fila é mudança local no hook.
|
|
- **Variantes órfãs ao trocar/excluir imagem** → remoção das variantes junto do original no mesmo hook; backfill pelo comando artisan quando houver divergência.
|
|
- **`route:cache` no entrypoint com nova rota `/robots.txt`** → remover o arquivo estático evita que o Caddy sirva o arquivo antes da aplicação; teste feature garante que a rota responde.
|
|
- **Seed no CI acopla o job `browser` a dados fixos** → seeder dedicado e versionado, separado do `ContentSeeder` de demonstração, para que mudança de demo não quebre snapshot.
|
|
- **Metas de LCP/CLS não são medidas automaticamente nesta change** → mitigado parcialmente por dimensões reservadas, lazy loading e hero eager; medição formal fica na Fase 5 (performance).
|
|
|
|
## Migration Plan
|
|
|
|
1. Sem migração de banco: nenhuma coluna nova.
|
|
2. Deploy exige `gd` na imagem e `storage:link` no entrypoint — ambos entram no mesmo build, validados pelo job `container`.
|
|
3. Após o deploy, rodar `php artisan media:generate-variants` uma vez para as imagens já enviadas; a renderização usa o original enquanto o backfill não roda.
|
|
4. Rollback: promover a imagem anterior. Variantes extras no storage ficam inertes e não quebram a versão antiga.
|
|
|
|
## Open Questions
|
|
|
|
- A API de acessibilidade do Pest Browser 4 cobre axe (`critical`/`serious`) ou será necessário injetar `axe-core` manualmente? Resolver na primeira fatia da suíte browser.
|
|
- Fonte tipográfica definitiva da marca (arquivo self-hosted) ainda não foi escolhida; até lá, usar a stack de tokens atual e ajustar antes de gravar as baselines.
|