* 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>
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-bookwormsem extensãogd/imagicke sem fontes instaladas. docker/entrypoint.shfaz apenasconfig:cache,route:cache,view:cache; não executastorage:link, então o discopublicnão é servido pelo contêiner.- O job
browserdo CI sobe o contêiner apontando para o mesmo PostgreSQL do runner, rodaphp artisan migrate --forceno host e não executa seed — hoje as páginas testadas não dependem de conteúdo. PublicImageUploadRulesgrava um único arquivo por upload, com nome UUID, no discopublic(ous3quando padrão).- Tailwind 4 sem
tailwind.config.js; tokens emresources/css/tokens.cssmapeados por@themeemapp.css, com blocoprefers-reduced-motionjá 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_settingse conteúdo publicado, na ordem do SPEC §6.2. - SEO renderizado por página (title, description, canonical, Open Graph, JSON-LD),
sitemap.xmlerobots.txtpor 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
saveUploadedFileUsingdas FileUploads dePublicImageUploadRules, portanto vale para todos os Resources do CMS sem duplicação. - Componente Blade
<x-media.image>montasrcset/sizes,width/height,alteloading(eager só no hero). - Comando
php artisan media:generate-variantspara 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 eAPP_ENV !== 'production', um provider chamaCarbonImmutable::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 emtests/fixtures/images/), executado no jobbrowserantes da suíte.- Fontes self-hosted via Vite, sem CDN, e
font-familysem 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-motionjá existente emtokens.css, com o Playwright emulandoreduce. 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.shpassa a executarphp artisan storage:linkde forma idempotente, senão as imagens do discopublicnão são servidas pelo contêiner e todo snapshot com imagem falha.- Job
browserdo CI passa a rodarphp artisan db:seed --class=VisualContentSeeder --forceantes 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,
reducemotion, mesma imagem de aplicação do deploy; baseline só muda porcomposer visual:updatecom revisão humana. gd+intervention/imageaumentam 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:cacheno 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
browsera dados fixos → seeder dedicado e versionado, separado doContentSeederde 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
- Sem migração de banco: nenhuma coluna nova.
- Deploy exige
gdna imagem estorage:linkno entrypoint — ambos entram no mesmo build, validados pelo jobcontainer. - Após o deploy, rodar
php artisan media:generate-variantsuma vez para as imagens já enviadas; a renderização usa o original enquanto o backfill não roda. - 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 injetaraxe-coremanualmente? 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.