# F4 — Produto & CMS (shell · piloto · widgets)

> **Épico:** `F4` · **Depende de:** F3 · **Habilita:** F5 · **Planos:** 12, 13, 16

## Objetivo

O CMS próprio (shell + editor rico **TipTap**, ADR-028), o **módulo piloto** que valida todo o
blueprint, e o construtor de widgets/banners. Sem Livewire/Filament (ADR-009).

## Issues e tasks

### `F4.I1` — CMS próprio e editor (plano 12)
- **T1** Shell `Cms\` com seções (`page-breadcrumbs`,`page-actions`,`content`,`css`,`scripts`,
  `modals`) + catálogo de partials (Blade + daisyUI) + **shell como dado** (ADR-036): identidade
  (`title`/`logo`/`--cms-*`), presença das regiões e posição da navegação (`left|right|orbital`).
  **Entregue**: engine (`CmsShellResolver`/`CmsShell`/`CmsSidebarPosition`), `config/cms.php`,
  identidade same-origin (`/cms-theme.css`), regiões opcionais com ordem de DOM por posição,
  órbita em Popover nativo (sem JS) com fallback, i18n do shell (`layout::shell.*`, 4 locales),
  `CmsShellTest` + gate de texto fixo cobrindo `src/layout/Cms`. **Catálogo de partials completo**:
  alert, badge, breadcrumb, button, card, empty-state, filter-card, form-group, modal, pagination,
  seo-button, status-badge, table, table-actions (`CmsPartialsTest`).
- **T4** Acesso ao painel (**ADR-037**): sessão nativa sobre `cms_users`, login/logout na área CMS
  (domínio próprio), painel mínimo autenticado, freio por e-mail+IP (`ThrottlePanelLogin`, 429 +
  `Retry-After`), auditoria herdada (`core.login`/`core.logout`/`core.login_failed`), hash Argon2id.
  **Entregue** com `CmsAuthTest` (7 testes) e E2E do CMS (`cms-desktop`: acesso, erro anunciado,
  painel com axe e alvos mínimos, logout, **sem JS**).
- **T2** Editor de texto rico **TipTap** (ADR-028): AST JSON→`jsonb`, parser/renderers semânticos,
  preview split-screen, links/imagens/tabela; **sem nó de HTML livre**.
- **T3** Sidebar a partir do manifesto (agrupada) + busca nativa Ctrl+K; `--no-JS` funcional.
  **Entregue**: `Core\Internal\Navigation\PanelMenu` (agrupamento, ordem determinística, negação por
  padrão, sem link morto e sem link de outra área), sidebar do shell, navegação rápida em popover
  nativo (abre e navega sem JS; filtro/setas/`Esc` com JS), rótulos de grupo nas 4 locales, tela de
  CMS do módulo **Demo** (`demo.cms.index`, `Permission::authorize`) e perfil+usuário de demonstração
  no `demo-data`. Testes: `PanelMenuTest` (6), `CmsNavigationTest` (4) e E2E `cms-desktop` (7).
- **T2 (feito)** Editor rico TipTap (ADR-028):
  - **T2.1 — contrato de conteúdo + parser semântico (feito):** `ContentASTParser` com um método por
    nó/marca (parágrafo/título/citação/listas/linha/colunas/tabela/mídia + 8 marcas), **classes em vez
    de estilo inline**, marcas em ordem canônica (HTML determinístico), links com esquema restrito e
    locale pelo invariante (ADR-012), `toText()` para resumo/SEO; `MediaUrlResolver` (porta) +
    `MediaStreamUrls`; `config/richtext.php`; CSS do serviço (`richtext-*`) importado pelo Portal e
    pelo CMS e cobrado pelo `DesignTokensTest`. Testes: 36 no serviço (contrato por nó, XSS, mídia,
    links). Documentação: `src/services/RichText/README.md`.
  - **T2.2 — mídia na mesma origem (feito):** rota de service `media.stream`
    (`src/services/Media/Routes/portal.php`, por convenção do `AreaRegistrar`), controller com prefixo
    permitido/anti-travessia/extensão fechada, `inline` + cache imutável; **bug real corrigido** no
    `NoticiaMediaController` (chamava `isSuccess()`/`value()` inexistentes no contrato — 500 ao servir
    capa) com teste de regressão.
  - **libvips na imagem base (feito):** `libvips-tools` + plugins AVIF do `libheif` entram no
    `docker/php-fpm/Dockerfile` (ADR-031) — libvips é o encoder padrão do ADR-026 §6 (streaming/
    threaded: mais rápido e com pico de memória menor que o Imagick); `vips` gera AVIF **e** WebP.
  - **T2.3 — upload de mídia (feito):** `MediaUploadRequest` (allowlist fechada: imagem + PDF,
    tamanho em config), `MediaUploadController` (rota de service na área CMS: `auth` + `throttle`,
    ADR-021/ADR-037) e `StoreEditorMedia` (digestão AVIF **e** WebP via libvips, chaves de objeto,
    auditoria `media.upload` — ADR-035). MIME decidido no **servidor** (finfo). Vídeo **não** entra
    (decisão: YouTube é link) — casos cobertos por `MediaUploadTest` (7).
  - **T2.4 — editor TipTap (feito):** TipTap 3 vendorizado (npm/Vite, **sem jQuery/Bootstrap/global**),
    bundle próprio (`editor.js`, 142 kB gzip, só no CMS); schema sóbrio (ADR-028 §5) com recuo em
    passos, colunas isoladas, nó `media` (URL só para exibir, nunca persistida) e marcas; toolbar com
    `aria-pressed`/`disabled` e barras contextuais (tabela/mídia); diálogos `<dialog>` nativos com
    prévia do destino; preview split-screen servido pelo **mesmo parser** (`richtext.preview`);
    `<textarea>` preservado como campo do formulário **sem JS**. Componente `<x-richtext::editor>` +
    `richtext-button`; 4 locales de rótulos (`RichTextEditorTest`, 8).
  - **Feito (fechamento do T2):** `LocaleCast` nativo (`src/core/Localization/LocaleCast.php`) —
    conteúdo por locale em `jsonb` (objeto completo, ordem canônica `{pt_BR,en,es,fr}`, leitura pelo
    locale vigente com fallback `pt_BR`); `Locales::label()` (endônimos); abas por locale no editor
    (`<x-richtext::editor :locales :translations>` gera um `content[locale]` por idioma — radio group
    revela o painel, **funciona sem JS**); E2E de criação de conteúdo (`08-cms-noticias`, com e sem JS).
  - **Correções do T2:** dialogs do editor **sem `<form>` aninhado** (o `</form>` interno fechava o
    formulário da página e o Salvar não enviava — `RichTextEditorTest::the_editor_does_not_nest_a_form`);
    `injectCSS: false` + CSS base do ProseMirror **vendorizado no bundle** (CSP estrita sem `unsafe-inline`).
  - **Legenda/alt da mídia (2026-10-17):** `window.prompt` substituído por **`<dialog>` nativo**
    (`createTextDialog` em `dialogs.js`, `data-rich-editor-text-dialog` no componente, `dialog_apply`
    nos 4 locales). `RichTextEditorTest::the_editor_never_uses_the_blocking_window_prompt`; E2E
    `08-cms-noticias` cobre upload + legenda (`figcaption`).
  - **Revisão do editor (2026-10-17):** TipTap mantido (ADR-028) — 41 pacotes TipTap/ProseMirror,
    todos MIT; AST JSON headless (dado vivo, sem HTML rígido). Alternativas descartadas: Lexical
    (React-first), Quill (Delta), Editor.js (blocos), TinyMCE (HTML), CKEditor (GPL), BlockNote (MPL),
    Slate/Remirror/Novel (React). Ver `decision_011houxu`.

### `F4.I2` — Módulo piloto + blueprint (plano 13)
- **T1** `make:module Grupo/Modulo` a partir do Blueprint (stub consistente com os ADRs).
- **T2** Implementar **Notícias** ponta a ponta (model, migration segura, action, rota, views CMS e
  portal, i18n, SEO hook, teste unit + feature + e2e). **Feito:** `content` migrou de `text` para
  `jsonb` (AST) com backfill do legado (`2026_10_18_000000_convert_noticias_content_to_jsonb`); o
  portal renderiza pelo `ContentASTParser` (partial legado removido); CRUD no CMS (list/create/edit/
  store/update/status/destroy) protegido por `Permission::authorize` (`noticias.{ver,editar,aprovar,
  arquivar}`), capa pelo contrato `Services\Media\Contracts\EditorMediaStore` (ADR-015); seeder em AST;
  `NoticiaContentTest` (5), `NoticiaCmsTest` (8), E2E `08-cms-noticias` (2, com e sem JS).
- **T3** `MODULE.md` do módulo; token canônico em todos os artefatos (ADR-004). **Feito:** o stub
  `src/core/Stub/Module/MODULE.md.stub` gera o contrato de leitura (propósito, estrutura, rotas,
  eventos, comandos, convenções) em todo `make:module`; o piloto tem
  `src/modules/Publicacoes/Noticias/MODULE.md` preenchido. **Corrigido no caminho:**
  `make:module --register` (e `core:module:activate`) falhava para módulo novo porque o manifesto em
  cache não o conhecia — `ModuleDiscoveryService::refresh()` revarre o filesystem e o manifesto é
  regenerado; `make create:module MODULE=Grupo/Modulo` gera + ativa + migra. Regressão:
  `ModuleDiscoveryTest::test_refresh_discovers_a_module_created_after_the_manifest_cache`.

### `F4.I3` — Widgets, banners e builder (plano 16)
- **T1** `WidgetRegistry` no Core; destino referencia o `id` (**embed permitido, import proibido**).
  **Feito:** porta `Core\Contracts\Ports\WidgetSource` (`id`/`title`/`icon`/`slots`/`embeddable`/`render`);
  `WidgetRegistry` redesenhado (fonte por `id`, `forSlot`, `all` determinístico, widget de módulo
  **inativo** não aparece — ADR-034); componente `<x-layout::widget id="…" />`; widget de exemplo no
  módulo `Demo` (`demo.status`) registrado no `register()`; testes `WidgetRegistryTest`,
  `ContractsBindingTest`, `CoreTelemetryTest`.
- **T2** Banners e perfis por grupo com resolução determinística.
  **Feito:** service `services/Layout` headless (`LayoutProfileReader` + `ResolvePlacements`):
  `rota → perfil` pelo prefixo mais específico (fallback global `*`), pilha ordenada por (`ordem`,
  `widget`), agendamento de banner por janela `[inicio, fim]` (relógio injetado) e cache do mapa
  invalidado ao salvar. Tabela `layout.layout_profiles` (jsonb); `<x-layout::slot>` renderiza os
  placements por `id` e o shell colapsa regiões vazias considerando o perfil; seeder demo global +
  E2E `09-widgets-banners`.
- **T3** Admin surface no `/cms` apenas (nunca rota pública) — ADR-015.
  **Feito:** service `Layout` expõe `Routes/cms.php` (`layout.cms.index/create/edit/store/update`),
  controllers com `Permission::authorize('Layout', {ver,editar})` (deny-by-default) e views no shell
  `<x-layout::cms>`; services agora **declaram** permissões no `config()` e o `AccessGate`/`CmsUserSeeder`
  as registram (`layout.*`). E2E `10-cms-layout`.

## Auditoria de consistência F3/F4 (2026-10-17)

Verificação pedida antes de seguir: **views, simplicidade, logs estruturados, auditoria, i18n,
agnosticismo e licenças**. O que estava **verde** e o que foi **corrigido**:

| Eixo | Resultado |
|---|---|
| i18n (ADR-012) | ✅ views sem texto fixo (só um comentário), `ModuleI18nTest`/`I18nTest` verdes, 4 locales no editor. **Corrigido**: `<iframe>` do preview fixava `lang="pt-BR"` (agora segue o locale). |
| Logs estruturados (ADR-024) | ✅ todo service/módulo com `span`+`log_event` (`ServicesTelemetryTest`, `ModulesTelemetryTest`); upload de mídia e render do conteúdo logam. |
| Auditoria (ADR-035) | ✅ `GlobalAuditSubscriber` cobre toda mutação Eloquent (login/logout/falha, perfil, módulo) + escrita explícita `media.upload`; leitura do CMS via `audit.access`. |
| Agnosticismo (ADR-013/015) | ✅ `make check` verde: `services` não importa `modules`/`layout`, `core` não importa `services`; nomes de rota de service vêm de config (`services.media.*`, `richtext.preview_route`). |
| Simplicidade (ADR-001) | **Corrigido**: view morta de Filament (`cover-preview`), `media-viewer` obsoleto (px + crypto-shredding removido), `NoticiaMediaController`/rota `noticias.media` (duplicavam o `media.stream`), `import` morto de `Core\Domains` (namespace extinto), 6 views com **cor de paleta hardcoded** (34+28+27+6+1+1) → tokens + novos pares `--civic-on-*`. |
| Design gates | **Corrigido**: `shell.css` (F4) **não** estava no `DesignTokensTest`; o gate de estrutura aceitava `<header>` como se fosse `<head>` (regex frouxa). |
| Licenças (ADR-002) | **Corrigido/novo gate**: `tools/assets-licenses-check.php` cobre npm + assets + binários; **removidos** Ghostscript (AGPL-3.0) e `pdftotext` (GPL-2.0) do pipeline de PDF; `qpdf` (Apache-2.0) instalado; libvips (LGPL) documentado como CLI separado. |

## DoD (binário)

- [x] Módulo piloto completo com e2e e a11y verde; nenhum acesso a outro módulo.
- [x] Editor **TipTap** persistindo `jsonb`; renderer semântico sem HTML/CSS no banco; e2e do
      editor (link/imagem/tabela) verde — ADR-028.
- [x] Shell reutilizável (módulo só preenche seções).
- [x] Widgets via registry; admin só em `/cms`.
- [x] `make check` 2× + `make check E2E=1` verdes.

## Evidência

`make check` verde (363 testes ×2 — anti-flaky/ADR-001), `make check E2E=1` 63/63 (portal desktop/mobile/
no-JS + CMS), `make check` (sniff) OK, `make check`/`check-gates`/`optimize-check` OK.
