Files
amare/openspec/changes/build-public-site/design.md
manoel freitas 27f3cee855 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>
2026-07-29 08:36:59 -03:00

9.7 KiB

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.

  • 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.