# P5 — Composição do site: home por seções, páginas, destaques e widgets

| Campo | Valor |
|---|---|
| **Fase** | P5 — Composição do site (programa F7+), **implementação fechada / revisada com correções** |
| **`kd` épico** | `epic_00xjhe75` (aberto até o commit humano; correções em `task_010zonqz`) |
| **`kd` fatias** | `task_00ect94a` (S1.1) · `task_00aua4t1` (S1.2) · `task_00foju6c` (S2.1) · `task_01694hou` (S2.2) · `task_00nz5zd2` (S3.1) · `task_01lsnppn` (S3.2) · `task_00gqf6mq` (S4.1) · `task_01rdz39n` (S4.2) · `task_00ihuggp` (S5.1) · `task_00ph53v5` (S5.2) · `task_0090qw6e` (S6.1) · `task_0083fxz4` (S6.2) · `task_010zonqz` (P5.FIX + P5.FIX2) |
| **Âncoras** | `wiki/implementation-phases/P5-composicao-site.md` §3/S1.1, §5, §6, §8 · `wiki/implementation-phases/TECNICO.md` §2, §4, §5, §7, §11, §14 · emenda ADR-016 de 2026-10-25 · ADR-017, ADR-028, ADR-026, ADR-025, ADR-022 · `DECISOES.md` D2/D12 · `src/layout/Blocks/**` · `src/layout/Components/blocks/**` · `src/layout/Lang/{pt_BR,en,es,fr}/blocks.php` · `src/layout/Test/**` · `tests/Feature/BlocksComponentsTest.php` · `tests/Feature/BlocksRendererTest.php` · `tests/Feature/HomeModuleTest.php` · `src/modules/Portal/Home/**` · `src/core/Contracts/Ports/WidgetSource.php` |
| **Status da revisão** | ✅ **revisado** — 2 rodadas em 2026-10-26: a 1ª (`P5.FIX`, commit `e8a7311`) corrigiu 18 achados; a 2ª (`P5.FIX2`) fechou a revisão interrompida com mais 20 achados (2 altos, 9 médios, 7 baixos, 2 observações registradas), todos com teste vermelho→verde. Nada foi commitado pelo agente. |
| **Estado do gate** | `make check` **2×** verde + `make check E2E=1` verde em 2026-10-26 — números na seção **Evidência (P5.FIX2)** |
| **Unidade de revisão** | fase (as 12 sub-fatias formam um bloco único: o catálogo de blocos e o renderizador só fazem sentido com Home + Páginas + Destaques + widgets juntos) |

## S1.1 — Emenda à ADR-016 e catálogo fechado de 12 blocos (`task_00ect94a`)

### O que foi implementado

- **Emenda curta na ADR-016** (`## Emenda 2026-10-25 — documento de página e catálogo fechado de
  12 blocos`), sem arquitetura nova: documento `{version, sections:[{id,type,props,hidden}]}`;
  `hidden` preserva posição; `props` validado por schema fechado por tipo; tipo desconhecido
  ignorado; `version` desconhecida não produz documento (o chamador cai no default); proibições de
  HTML/Blade/CSS/iframe/posição absoluta; camada dona do catálogo; regras de a11y.
- **Catálogo fechado dos 12 tipos** (`Layout\Blocks\BlockCatalog`): `hero`, `quick-links`, `text`,
  `text-image`, `cards-links`, `cards-numbered`, `steps`, `faq`, `video`, `latest-news`,
  `highlights`, `banner`. Cada definição (`BlockDefinition`) declara schema de `props`
  (`BlockField`: tipo, obrigatoriedade, `min`/`max`, enum, itens de lista, companheiro
  `requiredWith`), componente Blade concreto (`layout::blocks.{tipo}`), campos de mídia (**id
  Media** — UUID) e regras de acessibilidade.
- **Schema do documento** (`Layout\Blocks\BlocksSchema`):
  - `version` precisa ser exatamente `1` (`int`); qualquer outra coisa (2, `"1"`, `1.0`, `null`,
    ausente) devolve `null`;
  - no máximo **40** seções; `sections` precisa ser lista;
  - **tipo desconhecido é ignorado** sem invalidar o documento;
  - **chave proibida** (`html`, `template`, `iframe`, `css`, `style`, `class(es)`, `script`,
    `javascript`, `component`, `view`, `embed`, `raw`, `dangerouslySetInnerHTML`, `position`,
    `absolute`, `relative`, `fixed`, `sticky`, `top`, `right`, `bottom`, `left`, `inset`,
    `z_index`, `width_px`, `height_px`) em qualquer nível das `props` (inclusive itens de lista e
    CTA) **invalida o bloco**; no nível do documento **invalida o documento**;
  - chave desconhecida é **descartada** (`props` fechado); campo inválido (tipo, limite, enum, URL
    insegura, id Media inválido) **invalida o bloco** (nunca meio bloco renderizado);
  - **texto puro não aceita marcação**: tag HTML, `{{ }}`, `{!! !!}`, `@php` invalidam o bloco;
  - **AST** só aceita raiz `type: doc` e é limpo (`html`, `iframe`, `script`, `style`, `template`,
    `blade` como **nó** invalidam); o conteúdo rico continua sendo o caminho legítimo (ADR-028);
  - `hidden` aceita booleano e cai para `false` quando inválido, **preservando a posição**;
  - `id` ausente/inválido é derivado de `{tipo}-{índice}`; duplicata é ignorada.
- **12 componentes Blade** em `src/layout/Components/blocks/*.blade.php`, como **fragmentos
  semânticos mínimos** (sem editor, sem páginas, sem rota nova), com contrato uniforme
  `props` + `media` (mídia já resolvida) + `blockId` (âncora de `aria-labelledby`):
  - `hero` emite o **único** `<h1>` da composição; os demais usam `<h2>` e `<h3>` nos itens;
  - FAQ usa `<details>/<summary>` nativos; vídeo é **link** (`rel="noopener noreferrer"`, aviso de
    nova aba), **sem `iframe`/autoplay** (D12); destaques e banner são estáticos (sem carrossel);
  - mídia sempre por `<x-media::picture>` (AVIF/WebP, `width`/`height`, `alt` obrigatório — ADR-026);
  - `latest-news` resolve a fonte `noticias.ultimas` no `WidgetRegistry` do Core (embed, sem import
    entre módulos — ADR-013/ADR-034): sem fonte/módulo inativo, a seção não aparece (nunca título
    vazio nem 500);
  - tudo funciona **sem JS** e usa os tokens/classes existentes (`text-civic-*`, `bg-civic-*`,
    `border-civic-border`, `prose`), sem `style=""`, sem `px` e sem CDN.
- **Rótulos i18n** dos 12 tipos nas 4 locales (`layout::blocks.{tipo}`), com paridade de chaves.
- **Camada escolhida:** `src/layout` (apresentação). `Layout\Blocks\*` é consumível por
  `Portal/Home` e `Portal/Paginas` sem que um módulo importe o outro; **nenhum contrato novo no
  Core** (o catálogo é apresentação, não porta cross-módulo). `tools/arch-check` permanece verde.

### Arquivos

- `wiki/adr/ADR-016-tema-layout-como-dado.md` — emenda 2026-10-25.
- `src/layout/Blocks/BlockType.php` — enum fechado dos 12 tipos + chave i18n.
- `src/layout/Blocks/BlockField.php` — schema de campo (tipos, limites, enum, lista, AST, mídia, URL).
- `src/layout/Blocks/BlockDefinition.php` — definição do bloco + sanitização das `props`.
- `src/layout/Blocks/BlockCatalog.php` — catálogo dos 12 tipos (schema/componente/mídia/a11y).
- `src/layout/Blocks/BlockSection.php` — seção validada `{id,type,props,hidden}`.
- `src/layout/Blocks/BlocksDocument.php` — documento `{version,sections[]}` + `visible()`.
- `src/layout/Blocks/BlocksSchema.php` — parser/validador do documento e das proibições.
- `src/layout/Components/blocks/{hero,quick-links,text,text-image,cards-links,cards-numbered,steps,faq,video,latest-news,highlights,banner}.blade.php`.
- `src/layout/Lang/{pt_BR,en,es,fr}/blocks.php` — rótulos dos tipos.
- `src/layout/Test/BlocksCatalogTest.php` — contrato do catálogo.
- `src/layout/Test/BlocksSchemaTest.php` — contrato do documento (12 tipos, proibições, versão, hidden).
- `tests/Feature/BlocksComponentsTest.php` — fragmentos renderizados sem JS.

### Provas

- `vendor/bin/phpunit --filter='BlocksCatalogTest'` → 5 testes / 426 asserções.
- `vendor/bin/phpunit --filter='BlocksCatalogTest|BlocksSchemaTest'` → **53 testes, 635
  asserções, OK** (12 tipos com schema, tipo desconhecido, versão inválida, `html`/`template`/
  `iframe`/`position`/marcação, `hidden` preservado, mídia por id Media, ids derivados/duplicados).
- `vendor/bin/phpunit --filter='BlocksComponentsTest'` → **10 testes, 142 asserções, OK**
  (um `<h1>` só no hero, headings em ordem, `<details>` no FAQ, vídeo como link, degradação sem
  widget/mídia).
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**.
- `vendor/bin/pint --test` nos arquivos novos → limpo (após correção).
- `php tools/arch-check` → `arch-check: OK`; `tools/name-check.sh` → `name-check: OK`.
- `make check` → ver evidência no `kd` (`task_00ect94a`); E2E não se aplica (sem rota/UI nova).

### Riscos e observações

- **Schema × renderizador:** o teste de componentes passa `props` explícitas (válidas). A S1.2 deve
  consumir `BlocksSchema::parse()` como única porta e nunca renderizar `props` cruas.
- **Mídia:** o schema guarda **id Media** (UUID); o componente recebe a mídia já resolvida
  (`path/sources/width/height/alt`). A resolução id → variantes é do renderizador/módulo (S1.2/S4),
  com `<x-media::picture>` — sem consulta na view e sem N+1.
- **`latest-news`:** depende de `noticias.ultimas` (S5.1). Hoje o bloco é omitido (degradação
  intencional); o enum `source` já fecha a allowlist em `noticias.ultimas`.
- **Rejeição vs. descarte:** chave proibida invalida o bloco (sinal de tentativa de smuggling);
  chave apenas desconhecida é descartada. Está documentado na emenda e testado.
- **Sem `px`/inline/CDN:** os componentes usam apenas tokens Tailwind do projeto; nenhuma rota,
  estilo ou script novos foram introduzidos nesta fatia.

## S1.2 — Parser `ParseHomepage`, renderizador `x-layout::blocks` e default da home (`task_00aua4t1`)

### O que foi implementado

- **Parser puro** `Layout\Blocks\ParseHomepage::parse(mixed $document): BlocksDocument` — aceita
  **array** (já decodificado) ou **string JSON**; não toca banco, request, sessão nem cache. Usa
  `BlocksSchema::parse()` como **única porta** de validação e devolve **sempre** um documento
  válido: entrada ausente (`null`/vazia), JSON malformado, versão desconhecida ou documento
  inválido caem no default. JSON é decodificado com `json_decode(..., true, 64)` (profundidade
  limitada); qualquer coisa que não vire documento válido vira default.
- **Default da home como documento** (`Layout\Blocks\HomepageDefault`): `hero` + `quick-links` na
  mesma ordem e com as **mesmas fontes** de antes — cópia do módulo `Portal/Home`
  (`home::index.heading|lead|quick`, referenciada por chave) e atalhos do layout
  (`config('layout.portal.nav')`, rótulo traduzido). Nada foi duplicado: trocar o menu ou o texto
  continua sendo um lugar só. O default é **código confiável** (nasce tipado em `BlockSection`);
  o documento **armazenado** é que passa pelo schema.
- **Renderizador único** `<x-layout::blocks :document="$document" :media="$media" />`
  (`src/layout/Components/blocks.blade.php`): itera `BlocksDocument::visible()` (seções ocultas são
  **puladas**, nunca reordenadas) e delega cada seção ao componente declarado no catálogo
  (`BlockCatalog::get($type)->component`), repassando `props` já sanitizadas e `blockId` (âncora de
  `aria-labelledby`). Sem documento válido não emite nada; sem JS tudo continua funcionando.
  A mídia é **repassada por caminho do schema** (`image`, `items.0.image`, `thumbnail`), já resolvida
  pelo chamador — a view não consulta nada.
- **Home atual integrada**: a rota `portal.home` deixou de usar `Route::view` e passou a um
  controller invocável (`HomepageController`) que resolve o documento com
  `ParseHomepage::parse(config('layout.home.document'))`; a view agora só tem o shell + o
  renderizador. **Não existe segundo caminho**: o default é renderizado pelo mesmo componente das
  seções futuras. O `<h1>` continua sendo um só (o `hero`) e os atalhos continuam sendo os do
  layout.
- **Fonte provisória `layout.home.document`** (`config/layout.php`, `null` por padrão): o parser tem
  uma entrada real e testável; a S2.2 grava a versão publicada em `jsonb` e alimenta o **mesmo**
  contrato, sem mudar o parser.
- **Cutover**: `src/layout/Components/quick-links.blade.php` (componente antigo) foi removido — o
  bloco `quick-links` do catálogo é a única forma de compor atalhos. A classe do cartão foi alinhada
  para `bg-surface` (o mesmo `bg-surface` da home anterior), preservando o visual.

### Arquivos

- `src/layout/Blocks/ParseHomepage.php` — parser puro (JSON/array → `BlocksDocument`, default no fallback).
- `src/layout/Blocks/HomepageDefault.php` — default `hero` + `quick-links` (ids `home-hero` / `home-quick-links`).
- `src/layout/Components/blocks.blade.php` — renderizador `<x-layout::blocks>`.
- `src/layout/Components/blocks/quick-links.blade.php` — `bg-surface` (paridade visual com a home anterior).
- `src/layout/Components/quick-links.blade.php` — **removido** (substituído pelo bloco).
- `src/modules/Portal/Home/Logic/Controllers/HomepageController.php` — resolve o documento e entrega a view.
- `src/modules/Portal/Home/Routes/portal.php` — rota `portal.home` → controller (sem closure).
- `src/modules/Portal/Home/Layout/Views/index.blade.php` — shell + `<x-layout::blocks>`.
- `src/modules/Portal/Home/MODULE.md` — estrutura/rotas/contrato atualizados (docs de `src/` são contrato).
- `config/layout.php` — `layout.home.document` (entrada provisória do parser).
- `src/layout/Test/ParseHomepageTest.php` — contrato do parser.
- `tests/Feature/BlocksRendererTest.php` — contrato do renderizador.
- `tests/Feature/HomeModuleTest.php` — regressão da home + documento publicado.
- `src/tests-e2e/tests/01-explorar-portal.spec.ts` — âncora do `<h1>` da home (`#home-hero-title`).

### Contrato que a S2.1 (editor) deve reutilizar

- **Ler:** `ParseHomepage::parse($jsonbPublicado ?? null)` — a entrada é o `jsonb` cru (array) ou a
  string JSON; a saída é sempre `BlocksDocument` (nunca `null`, nunca exceção).
- **Gravar:** somente `{version: BlocksSchema::VERSION, sections[]}`. A ordem do array **é** a ordem
  visual; ocultar = `hidden: true` no mesmo índice (**nunca** remover/reordenar para esconder).
  `props` seguem o schema do catálogo — chave proibida (`html`/`template`/`iframe`/`style`/posição)
  invalida o bloco, campo inválido idem.
- **Renderizar (preview e portal):** o **mesmo** `<x-layout::blocks>` com o documento validado —
  não criar view/render paralelo.
- **Mídia:** `props` guardam **id Media**; cabe ao chamador resolver para
  `['path','sources','width','height']` e passar em `media` por caminho (`image`, `items.0.image`,
  `thumbnail`). Sem resolução, o bloco degrada (não renderiza a imagem), nunca quebra.
- **Testes de contrato já existentes:** `ParseHomepageTest` (default/hidden/tipo desconhecido/props
  inválidas/versão/id duplicado) e `BlocksRendererTest` (ordem, hidden, documento ausente).

### Provas

- `vendor/bin/phpunit --filter='ParseHomepageTest'` → **21 testes, 86 asserções, OK**
  (sem documento/default, JSON e array equivalentes, `hidden` preserva posição, tipo desconhecido,
  `props` inválidas descartam só o bloco, versão errada/ausente, id duplicado, documento inválido,
  default válido pelo schema).
- `vendor/bin/phpunit --filter='BlocksRendererTest'` → **3 testes, 18 asserções, OK**
  (ordem do documento, `hidden` pulado, sem documento → vazio, default renderiza a home atual).
- `vendor/bin/phpunit --filter='HomeModuleTest'` → **7 testes, 41 asserções, OK**
  (200 nas 4 locales, um `<h1>`, atalhos do layout, documento publicado renderizado, documento
  inválido → default sem HTML do payload).
- Suíte completa: **1455 testes / 24 235 asserções** verdes (via `make check` → `coverage`);
  cobertura `src/` **93,90%** (17 908/19 071; piso 90%); PHPStan max **0 erros**;
  `arch-check`/`name-check` OK; `pint` limpo.
- `make check` → **24 OK · 0 FAIL · 9 SKIP** (131 s; `e2e`/`perf` são opt-in).
- `make check E2E=1` → **24 OK · 0 FAIL · 9 SKIP** (347 s): `e2e` **131 testes Playwright
  verdes** (2,6 min, inclui `01-explorar-portal` com a nova âncora do `<h1>`), `a11y` sniffCSS
  desktop+mobile OK, `perf` **17 testes** (CLS da home dentro do orçamento, sem recurso bloqueante).

### Riscos e observações

- **Mídia ainda não resolvida no renderizador:** o contrato é de **entrada** (`media` por caminho),
  mas o default não tem mídia e não existe hoje um resolvedor público `id Media → picture` na
  camada de apresentação. Blocos com imagem (`text-image`, `highlights`, `banner`, `video`)
  renderizam sem a imagem até S4/S2.2 passarem a mídia resolvida — degradação segura, nunca meio
  bloco com HTML. Quem editar em S2.1 não deve prometer imagem no portal antes disso.
- **`layout.home.document` é transitório:** a S2.2 troca a fonte por `jsonb` publicado; o parser e
  o renderizador não mudam (só o argumento). Não promover a chave a configuração de tenant.
- **Ids de âncora mudaram:** o heading do hero virou `#home-hero-title` e o dos atalhos
  `#home-quick-links-title` (derivados do `id` da seção pelo componente). A âncora antiga
  `#home-greeting` não existe mais — não havia link curado (`seo.redirects`/sitemap) apontando para
  ela; o e2e foi atualizado.
- **Espaçamento/estrutura:** o renderizador embrulha as seções em
  `<div class="flex flex-col gap-10">` (equivalente ao `mt-10` anterior entre hero e atalhos). O
  `<main>` passa a começar por esse `<div>`; nenhum CSS depende do filho direto.
- **Default × schema:** o default nasce em código (traduções + nav do layout) e é coberto por
  `the_default_home_is_a_valid_document_by_the_schema`; se a cópia/atalhos ficarem inválidos, o teste
  acusa antes do fallback renderizar um bloco vazio.
- **i18n:** o default resolve a cópia no locale da requisição (`__()`), então `/`, `/en`, `/fr`
  continuam traduzidos; o parser em si não depende do locale para validar.

## S2.1 — Editor da home: rotas, permissões e formulário de seções (`task_00foju6c`)

### O que foi implementado

- **Persistência do rascunho no próprio módulo** (`Portal/Home`): schema `home`, tabela
  `home_documents` com `id` (`uuidv7()`), `variant` (**índice único** — `draft` agora, `published`
  na S2.2), `document` (`jsonb` com `CHECK jsonb_typeof = 'object'`), `lock_version` e
  `timestampsTz`. Migração **aditiva/idempotente** (`Schema::hasTable`, `IF NOT EXISTS`) e `down()`
  que renomeia para `backup_home_documents_<ts>` (tabela **e** índice) — nada é dropado (ADR-017).
  Documento = `{version: BlocksSchema::VERSION, sections[]}`; a ordem do array é a ordem visual e
  `hidden` preserva a posição. **Uma linha por variante**, garantida pelo banco.
- **Permissões no manifesto `home`**: `home.ver`, `home.editar` e `home.aprovar` declaradas via
  `Permission` (sem `arquivar`); ler o editor exige `home.ver`, gravar o rascunho exige
  `home.editar` e `home.aprovar` fica reservado à publicação (S2.2). Deny-by-default pelo
  `AccessGate`; a rota do painel no manifesto passou a ser `home.cms.index` (o item aparece no
  grupo `Portal` com `position: 10`).
- **Rotas CMS** `GET /cms/home` (`home.cms.index`) e `PUT /cms/home` (`home.cms.update`, com
  `throttle:60,1`), no domínio do painel e já sob `auth` da área (ADR-037). Controller fino +
  `FormRequest`; **403 antes de 422** (autorização na Action/controller e no
  `authorize()` do request, mesmo vocabulário `Permission`). Sem closure (ADR-008).
- **Tela do editor** (`Layout/Views/cms/index.blade.php`): lista **ordenada** de seções (a lista é
  o documento), com **botões** de subir/descer, ocultar/mostrar (**preserva a posição**), remover e
  salvar — todos submit do **mesmo** `PUT`, portanto funcionam **sem JS** e por teclado (WCAG 2.2
  SC 2.5.7; não há arrastar). Remover tem confirmação **nativa** (`<x-cms::modal>`, Popover API —
  sem `<form>` aninhado). Adicionar por tipo é um **GET** (`?novo=<tipo>`), com a seção nova apenas
  como estado de tela (não persiste); o tipo é um dos 12 do catálogo.
- **Edição em `<dialog>` nativo por seção**, **sem `<form>` aninhado** (os campos pertencem ao
  formulário do editor). Com JS, `Assets/js/home-editor.js` (0,6 kB) só abre/fecha o diálogo, foca o
  primeiro campo e devolve o foco ao gatilho; **sem JS**, `@media (scripting: none)` no
  `shell.css` mantém os campos no fluxo e esconde só o gatilho — o botão de salvar continua valendo.
  O formulário é `novalidate` (campo obrigatório em diálogo fechado não pode bloquear o submit);
  a validação é server-side.
- **Validação por schema, erro por campo**: o request valida a **forma** (ação, ids, tipos do
  catálogo, `props` array — sem regra curinga que descartaria o AST); a Action valida cada bloco
  com `BlockDefinition::invalidFieldNames()` (novo método público: mesma regra de `sanitize()`,
  apontando o campo) e responde `ValidationException` com resumo `role="alert"` (`x-cms::form-errors`)
  + mensagem/`aria-invalid`/`aria-describedby` no campo. Documento inválido **nunca** vira 500 nem
  grava parcial: chave proibida (`html`/`template`/`iframe`/`style`/posição) é procurada nas props
  **cruas** (a normalização não pode esconder o smuggling).
- **Normalização de formulário explícita** (`HomeDraftSections`): `null` (do
  `ConvertEmptyStringsToNull`) é **ausência** — campo opcional em branco desaparece e obrigatório
  vira “faltando”; `int`/`bool` convertidos; `ast` decodificado do `<textarea>`; item de lista
  totalmente em branco (a linha extra do editor) é descartado. Chave fora do schema é descartada.
- **Prévia mínima na tela do editor**: estrutura do documento **salvo** (tipo + resumo + oculta) —
  sem rota pública e **sem alterar a home pública** (que continua lendo `layout.home.document`;
  publicar é S2.2). Documento salvo inválido é avisado na prévia em vez de fingir que o default é
  o rascunho.
- **Concorrência e lastro**: `lockForUpdate` + `lock_version` conferido na transação (página velha
  → **409**, nada gravado); o índice único da variante cobre a corrida de criação; duas gravações
  mantêm **uma** linha e avançam a versão. A trilha de auditoria recebe `home.created`/
  `home.updated` (área `cms`, ator snapshot — ADR-035) e a Action emite span + `log_event`
  `home.save_draft` (ADR-024).
- **i18n**: `home::cms.*` nas 4 locales (chrome do editor) e `layout::blocks.fields.*`/
  `layout::blocks.values.*` (rótulos de campo/valores do catálogo, reutilizáveis por Páginas sem
  duplicar tradução). Nenhuma string visível fixa; navegação só por `route()`.
- **Contrato de shell**: um `<x-layout::cms>`, só o `<main>`, `<h1>` do shell (o módulo usa
  `<h2>/<h3>/<h4>`), sem `<style>`/`style=`/`<script>` inline; o JS entra pelo slot `scripts` via
  Vite.

### Arquivos

- `src/modules/Portal/Home/ModuleProvider.php` — permissions + rota do painel.
- `src/modules/Portal/Home/Routes/cms.php` — `home.cms.index`/`home.cms.update`.
- `src/modules/Portal/Home/Logic/Controllers/Cms/HomeEditorController.php` — leitura + seção nova.
- `src/modules/Portal/Home/Logic/Controllers/Cms/HomeDraftSaveController.php` — 403 → validação → Action → redirect/409.
- `src/modules/Portal/Home/Logic/Requests/SaveHomeDraftRequest.php` — forma do formulário.
- `src/modules/Portal/Home/Logic/Actions/SaveHomeDraft.php` — schema por bloco + transação + versão.
- `src/modules/Portal/Home/Logic/Support/HomeDraftSections.php` — normalização + ações de estrutura + resumos.
- `src/modules/Portal/Home/Logic/Support/HomeDraftReader.php` — rascunho salvo / default.
- `src/modules/Portal/Home/Logic/Exceptions/HomeDraftConflict.php` — conflito (409).
- `src/modules/Portal/Home/Database/Migrations/2026_10_25_000000_create_home_documents_table.php`.
- `src/modules/Portal/Home/Database/Models/HomeDocument.php`.
- `src/modules/Portal/Home/Layout/Views/cms/index.blade.php` + `cms/partials/home-block-fields.blade.php` + `cms/partials/home-block-field.blade.php`.
- `src/modules/Portal/Home/Lang/{pt_BR,en,es,fr}/cms.php`.
- `src/modules/Portal/Home/Assets/js/home-editor.js` — diálogo (melhoria progressiva).
- `vite.config.js` — entrada do JS do editor; `tests/Feature/PerformanceBudgetTest.php` — orçamento (2 kB).
- `src/layout/Cms/Assets/css/shell.css` — `@media (scripting: none)` do `<dialog>` de módulo.
- `src/layout/Blocks/BlockDefinition.php` — `invalidFieldNames()` (mesma regra de `sanitize`).
- `src/layout/Lang/{pt_BR,en,es,fr}/blocks.php` — rótulos de campo e de valores do catálogo.
- `src/modules/Portal/Home/MODULE.md` — rotas/permissões/dados/eventos do editor.
- `src/modules/Portal/Home/Test/HomeEditorCmsTest.php` — contrato do editor.
- `src/layout/Test/BlocksEditorValidationTest.php` — validação por campo × `sanitize`.
- `src/core/Access/Test/PermissionCatalogTest.php` — Home passa a declarar ações (regressão atualizada).
- `src/tests-e2e/histories/25-editor-home.md` · `src/tests-e2e/tests/25-cms-editor-home.spec.ts` ·
  `src/tests-e2e/README.md` · `wiki/specs/04-acessibilidade-ux.md` · `playwright.config.ts` (projetos
  `cms-desktop`/`cms-mobile`).

### Provas

- `vendor/bin/phpunit --filter='HomeEditorCmsTest'` → **20 testes, 192 asserções, OK** (403 sem
  permissão; `home.ver` lê; `home.editar` grava; 1 linha/versão; `hidden` preserva posição;
  mover/remover; seção nova só no `save`; documento inválido → erro por campo sem 500; chave
  proibida; mídia UUID + `alt`; campo opcional vazio = ausência; 409 de página velha; auditoria
  `home.created`/`home.updated` com ator; home pública intocada; 4 locales).
- `vendor/bin/phpunit --filter='BlocksEditorValidationTest'` → **7 testes, 21 asserções, OK**.
- `vendor/bin/phpunit --filter='PermissionCatalogTest|ModuleI18nTest|DocsReferencesTest|E2eDocsTest|LayoutContractTest'` → OK.
- `vendor/bin/phpstan analyse` → **0 erros**; `vendor/bin/pint --test` limpo.
- `tools/e2e.sh test tests/25-cms-editor-home.spec.ts --project=cms-desktop` → **13 testes verdes**
  (12 do lote serial + os 2 novos), incluindo axe na tela do editor e o caminho **sem JS**.
- Suíte completa (`make check` → `coverage`): **1 482 testes / 24 917 asserções, OK**; cobertura
  `src/` **93,87%** (18 623/19 840; piso 90%); PHPStan max **0 erros**; Pint limpo;
  `arch-check`/`name-check`/`migration-check`/`vendor-check`/`docs-check` OK.
- `make check E2E=1` → **26 OK · 0 FAIL · 7 SKIP** (346 s): `e2e` **135 testes Playwright verdes**
  (2,5 min; inclui `25-cms-editor-home` em `cms-desktop` **e** `cms-mobile`, com axe e o bloco sem
  JS), `a11y` sniffCSS desktop+mobile OK, `perf` OK (orçamento do bundle e CLS da home).
- Correções incidentais exigidas pelo gate de E2E (documentadas em Riscos): `x-cms::table` ganhou
  `tabindex="0"` no contêiner rolável (axe `scrollable-region-focusable` em `/estatisticas` com
  dados acumulados) e o corpo do diálogo de edição ficou rolável em telas pequenas
  (`.cms-dialog__body`), com o botão de fechar em largura total no mobile.

### Riscos e observações

- **AST em `<textarea>` nesta fatia**: o campo `ast` (texto/FAQ) é editado como JSON estruturado —
  o editor rico (TipTap do service `RichText`, ADR-028) entra quando a superfície de edição rica
  for acoplada (S2.2/S4). O contrato de dado e a validação já são os finais.
- **Mídia por id (UUID)**: o campo `media` pede o id do catálogo — não há seletor/upload aqui
  (ADR-026/S4). O valor continua sendo validado como UUID e o bloco degrada sem imagem.
- **Prévia estrutural**: a prévia na tela lista o documento **salvo** (tipo/resumo/oculta) em vez de
  renderizar os blocos, porque o `hero` emite o `<h1>` e o CMS já tem o `<h1>` do shell; a versão
  visual no shell real é o preview autenticado da S2.2.
- **Rascunho vazio é válido**: `{version:1,sections:[]}` é aceito pelo schema (o operador pode
  remover tudo). A S2.2 deve decidir o comportamento da **publicação** de um documento vazio
  (recusar ou publicar o default) — a home pública continua no default até publicar.
- **O `PUT` persiste o formulário inteiro**: mover/ocultar/remover grava também os campos enviados
  (o formulário real sempre envia todas as seções). Página velha é barrada por `lock_version`
  (409); a seção “nova” só entra na ação `save`.
- **`demo_stale()` (`tools/check.sh`) não observa a declaração de permissions de um módulo**: num
  banco demo já semeado, o perfil `administrador` só recebe `home.*` depois de um seed (CI/clone
  limpo já nascem certos; localmente use `CHECK_FORCE_PREP=1`). Não afeta os testes PHPUnit, que
  criam o próprio perfil.
- **Correção incidental de a11y**: `x-cms::table` ganhou `tabindex="0"` no contêiner
  `overflow-x-auto` — sem isso, `/estatisticas` com dados acumulados falha no axe
  (`scrollable-region-focusable`), o que apareceu no `make check E2E=1` desta fatia (a tela não é
  desta fatia; o defeito é latente e depende de a tabela transbordar). O corpo do diálogo de edição
  ganhou `.cms-dialog__body` (rolagem interna) para o rodapé de ações continuar alcançável no
  mobile.

### Contrato que a S2.2 (publicação/preview) deve reutilizar

- **Leitura do rascunho:** `HomeDraftReader::stored()` (variante `draft`) e
  `HomeDraftReader::document()` (sempre um `BlocksDocument`; inválido → default). A variante
  `published` usa a **mesma tabela**, o índice único já existe e `lockForUpdate`/`lock_version` é o
  padrão de escrita.
- **Validação/gravação:** `SaveHomeDraft` é o caminho único de escrita do documento (tudo ou nada;
  erro por campo com `BlockDefinition::invalidFieldNames()`; 409 em página velha). Publicar deve
  reaproveitar a validação e **acrescentar** `home.aprovar` + auditoria `home.published` — nunca um
  segundo validador.
- **Preview autenticado:** renderizar o documento com o **mesmo** `<x-layout::blocks>` (a prévia da
  S2.1 é só estrutural); a mídia entra resolvida por caminho (`image`, `items.0.image`,
  `thumbnail`), sem consulta na view. Nenhuma rota pública de draft.
- **Home pública:** trocar `Layout\Blocks\ParseHomepage::parse(config('layout.home.document'))`
  pela versão publicada em `jsonb` (o parser e o renderizador não mudam). Não fazer a home pública
  ler o rascunho.
- **Locales do conteúdo:** `props` seguem strings do schema; tradução de conteúdo (mapa por locale +
  `LocaleCast`) é decisão da S2.2 e exige nova regra no catálogo/schema — não improvisar no editor.

## S2.2 — Publicação, prévia autenticada, auditoria e locales da home (`task_01694hou`)

### O que foi implementado

- **Publicação versionada** (`POST /cms/home/publicar`, `home.cms.publish`, `home.aprovar`): a Action
  `PublishHome` lê o rascunho sob `lockForUpdate`, valida pelo **mesmo** `BlocksSchema` da S2.1 e
  grava a variante `published` na **mesma** tabela/estrutura (`home.home_documents`), com
  `lock_version` = número da publicação. Rascunho ausente/inválido ou **sem seção visível** é
  recusado (422) — o default continua sendo o fallback de “não publicado”, nunca de “publicado
  vazio”. Conflito de criação concorrente → **409** (nada sobrescrito).
- **Home pública lê só o publicado** (`PublishedHomepage`): sem linha publicada (ou documento
  inválido) cai no default (`HomepageDefault` via `ParseHomepage`). O rascunho nunca é público; a
  fonte transitória `layout.home.document` foi **removida** (cutover limpo).
- **Cache versionado + invalidação imediata**: chave `home.published.{lock_version}` (TTL
  `layout.home.published_ttl`), com `forget()` síncrono da versão substituída ao publicar. A leitura
  da linha (uma, índice único) é o token de versão determinístico; a chave nova só nasce após o
  commit. Teste de dois requests prova que a publicação aparece na requisição seguinte.
- **Auditoria** `home.published` via `AuditWriter::record(AuditEntry::event(...))` (área CMS, ator
  snapshot, `before`/`after` com variante/versão/nº de seções visíveis) + span/log
  `home.publish`; a mutação de `HomeDocument` continua na trilha ambiente (ADR-035).
- **Prévia autenticada** (`GET /cms/home/preview`, `home.preview`, `home.ver`): rota no domínio/sessão
  do CMS (anônimo → login, sem permissão → 403), sem token público; renderiza o **rascunho salvo** no
  shell real do portal (`<x-layout::portal>` + o **mesmo** `<x-layout::blocks>`), com `<h1>` único (o
  do `hero`), `noindex, nofollow` no `<head>` e `X-Robots-Tag` da área restrita. Cache aplicacional
  curto (`layout.home.preview_ttl`) com chave `home.preview.{user}.{fingerprint de permissões}.{lock_version}`;
  nunca entra em cache público e não contamina a home cacheada.
- **Locales do conteúdo**: `BlockField` ganhou `translatable` (catálogo continua a **única** fonte) e
  aceita mapa `{pt_BR: …, en: …}` ou valor simples (forma histórica, válida como default). O valor de
  cada locale passa pelo **mesmo** tipo/limite/marcação proibida do campo. `BlockLocalizer` resolve
  no renderizador: locale vigente → default → primeiro preenchido; `list` percorre os campos do item.
  O editor mostra os 4 locales por campo (grupo `fieldset` + rótulo endônimo, sem JS), grava o mapa e
  colapsa para a forma simples quando só o default está preenchido. A home publicada responde em
  `/`, `/en`, `/es`, `/fr` com fallback determinístico; o preview aceita `?locale=`.
- **Editor**: ação “Publicar” em formulário próprio (sem `<form>` aninhado; publica o rascunho
  **salvo**) e links de prévia autenticada por locale; o botão de publicar só aparece com
  `home.aprovar`.

### Arquivos

- `src/modules/Portal/Home/Logic/Actions/PublishHome.php`
- `src/modules/Portal/Home/Logic/Controllers/Cms/HomePublishController.php`
- `src/modules/Portal/Home/Logic/Controllers/Cms/HomePreviewController.php`
- `src/modules/Portal/Home/Logic/Support/PublishedHomepage.php`
- `src/modules/Portal/Home/Logic/Support/HomePreviewDocument.php`
- `src/modules/Portal/Home/Layout/Views/home-preview.blade.php`
- `src/modules/Portal/Home/Logic/Controllers/HomepageController.php` — lê o publicado
- `src/modules/Portal/Home/Logic/Controllers/Cms/HomeEditorController.php` — `canPublish`
- `src/modules/Portal/Home/Routes/cms.php` — `home.cms.publish`/`home.preview`
- `src/modules/Portal/Home/Database/Models/HomeDocument.php` — `PUBLISHED`
- `src/modules/Portal/Home/Layout/Views/cms/index.blade.php` + `cms/partials/home-block-field.blade.php`
- `src/modules/Portal/Home/Logic/Support/HomeDraftSections.php` — normalização por locale + resumos
- `src/modules/Portal/Home/Lang/{pt_BR,en,es,fr}/cms.php`
- `config/layout.php` — `home.published_ttl`/`home.preview_ttl` (remove `home.document`)
- `src/layout/Blocks/BlockField.php` (`translatable`, mapa por locale, `translations()`)
- `src/layout/Blocks/BlockCatalog.php` — campos traduzíveis declarados nos 12 tipos
- `src/layout/Blocks/BlockLocalizer.php` — resolução por locale + fallback
- `src/layout/Blocks/BlocksDocument.php` — `toArray()` (publicação canônica)
- `src/layout/Components/blocks.blade.php` — `locale` + localização das props
- `src/modules/Portal/Home/Test/HomePublishPreviewCmsTest.php` — contrato da fatia
- `tests/Feature/HomeModuleTest.php` — home pública lê a variante publicada
- `src/modules/Portal/Home/Test/HomeEditorCmsTest.php` — campos por locale (asserts ajustados)
- `src/tests-e2e/histories/26-publicacao-previa-home.md` · `tests/26-cms-publicacao-home.spec.ts` ·
  `playwright.config.ts` (`26` no projeto `cms-serial`; `cms-mobile` passa a rodar depois do
  `cms-desktop` porque o editor `25` muta o mesmo rascunho) · `README.md` ·
  `wiki/specs/04-acessibilidade-ux.md` ·
  `AGENTS.md` (contagem de histórias) · `tests/01-explorar-portal.spec.ts` (âncora do `<h1>` do
  documento, não de um id fixo) · `tests/25-cms-editor-home.spec.ts` (grupos por locale)
- `wiki/adr/ADR-016-tema-layout-como-dado.md` — emenda S2.2 (shape por locale, publicação, preview)
- `src/modules/Portal/Home/MODULE.md` — rotas/permissões/dados/eventos/cache/locales

### Provas

- `vendor/bin/phpunit --filter='HomePublishPreviewCmsTest'` → **13 testes, 178 asserções, OK**
  (403 sem `home.aprovar`; anônimo no preview; publicar sem rascunho/vazio/só oculta recusado;
  cópia canônica + auditoria `home.published` com ator; home muda só ao publicar; cache versionado
  invalidado na hora; preview no shell real com `X-Robots-Tag`, sem canonical e sem chrome do painel;
  cache por usuário/permissão/versão; 4 locales com fallback — inclusive no preview; schema fechado
  dentro dos locales; ação estrutural aceita os campos por locale do formulário; SEO/canonical/hreflang).
- `vendor/bin/phpunit --filter='HomeModuleTest|HomeEditorCmsTest|HomePublishPreviewCmsTest|BlocksRendererTest|BlocksComponentsTest|ParseHomepageTest|BlocksSchemaTest|BlocksCatalogTest|BlocksEditorValidationTest'`
  → **134 testes, 1 315 asserções, OK**.
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**; `pint --test` limpo.
- `make check` → **24 OK · 0 FAIL · 9 SKIP** (132 s); suíte **1 495 testes / 25 246 asserções**
  verdes; cobertura `src/` **93,87%** (18 865/20 097; piso 90%).
- `make check E2E=1` → **24 OK · 0 FAIL · 9 SKIP** (393 s): `e2e` **137 testes Playwright**
  (136 passed + 1 skip condicional; 3,1 min), incluindo `26-cms-publicacao-home` em `cms-serial`
  (axe desktop e mobile no próprio spec, porque publicar muda estado global); `a11y` sniffCSS
  desktop+mobile OK; `perf` **18 testes** OK (CLS da home publicada dentro do orçamento).

### Riscos e observações

- **Publicar muda estado global** da home: o e2e do fluxo roda no projeto `cms-serial` (um worker,
  antes dos demais) como as histórias 18/23. O spec limpa as seções do rascunho antes de compor o
  `hero`, mas deixa a home publicada com o documento criado; a âncora `h1#home-hero-title` do spec 01
  passou a ser `main h1` (o id pertence ao documento, não ao shell). **Custo de ciclo:** o
  `cms-mobile` passou a depender do `cms-desktop` (o editor `25` disputava `lock_version` entre os
  dois projetos) — o e2e foi de ~2,5 para ~3,1 min; a cobertura mobile continua no mesmo lote.
- **Publicado vazio**: recusado. Documento com seções apenas ocultas também (nada visível) — a home
  pública nunca fica em branco por publicação; para voltar ao default não há “despublicar” nesta
  fatia (S3.2/S5 podem expor a transição se necessário).
- **Cache**: a linha publicada é lida a cada request (uma linha, índice único) para obter a versão —
  determinístico e imune a chave de cache perdida; o custo é um `SELECT` por home, não N+1.
- **Preview**: cache aplicacional por usuário/permissão/versão; o HTTP continua privado/no-store pela
  área restrita (ADR-022/043), então “cache curto” não é cache de borda. A prévia não tem token
  público — depende da sessão do CMS.
- **Tradução**: campos `url`/`route`/`media`/enums continuam únicos (não traduzíveis); CTA
  (`link`) segue com um rótulo único nesta fatia. Traduzir o rótulo do CTA exigiria mudar o tipo do
  campo (novo ADR/schema), não é configuração.
- **`jsonb` reordena chaves** do mapa por locale (PostgreSQL): a ordem não é contrato; a resolução é
  por chave e o teste ordena antes de comparar.

### Contrato que a S3.1/S5 deve reutilizar

- **Publicar** = validar com `BlocksSchema` → copiar o canônico (`BlocksDocument::toArray()`) para a
  variante publicada da mesma tabela, versionar (`lock_version`), esquecer a chave de cache anterior
  e auditar `{module}.published` com ator snapshot. Nunca um segundo validador.
- **Ler o público** = `PublishedHomepage::document()` (cache versionado + fallback default); nunca
  ler `draft` no portal.
- **Preview** = `home.preview` na sessão do CMS, `home.ver`, shell real do portal, mesmo
  `<x-layout::blocks :locale>`, `noindex`, cache aplicacional escopado por usuário/permissão/versão.
- **Locales** = `BlockField(translatable: true)` + mapa `{locale: valor}` (valor simples = default);
  renderizar com `BlockLocalizer`; editor com um controle por locale; `Locales::all()` é a lista
  canônica e `Locales::primary()` o fallback. Nada de HTML/CSS no `jsonb`.

## S3.1 — Módulo `Paginas`: tabela, CMS e regra de slug (`task_00nz5zd2`)

### O que foi implementado

- **Módulo `Portal/Paginas`** (token `paginas`, grupo `Portal`), um único `ModuleProvider` sem
  bindings: ícone `lucide-file-text`, `position: 20`, rota do painel `paginas.cms.index` e
  `permissions` `[ver, editar, arquivar]` (ADR-014/ADR-033). `aprovar` fica de fora porque publicar
  não existe nesta fatia; `arquivar` já nasce declarada para o catálogo da S3.2.
- **Tabela `paginas.pages`** (migração idempotente, `SET lock_timeout`, schema próprio):
  `id` (`uuidv7()`), `slug`, `title` (jsonb), `content` (jsonb, AST), `seo` (jsonb),
  `layout_scope` (nulo), `status`, `published_at`, `timestampsTz`. Contratos no banco:
  índice único sobre `lower(slug)` (**único sempre**, inclusive arquivada — URL estável), CHECK de
  formato/comprimento do slug, `title` objeto **não vazio**, `content`/`seo` objetos, `status` no
  vocabulário, `publicado` exige `published_at` e `layout_scope` no vocabulário de escopo (`*` ou
  segmentos). Índice parcial das publicadas (`published_at DESC WHERE status = 'publicado'`) para a
  S3.2. `down()` renomeia tabela **e** índices explícitos para `backup_*`; nunca dropa (ADR-017).
- **Regra de slug** (`PaginaSlug` + `PaginaReservedSlugs`): normalização (`Str::slug`, ≤ 120),
  unicidade case-insensitive (query + índice do banco) e **colisão com rota viva recusada** — a lista
  de primeiros segmentos reservados vem do **router** em tempo real (`cms`, `api`, `home`, `up`, os
  prefixos de idioma e tudo o que os módulos registram: `noticias`, `dados-abertos`, `lgpd`,
  `estatisticas`, …), ignorando as rotas do próprio módulo (`paginas.*`, que na S3.2 são o fallback).
  Slug em branco é derivado do título do locale default; colisão derivada recebe sufixo
  determinístico (`-2`, `-3`…). Toda recusa é **422 no campo** (`slug_taken`/`slug_reserved`), nunca
  500; a violação de unicidade sob concorrência é traduzida para 422.
- **AST validado** (`PaginaContent`): raiz `doc`, `content` sempre lista, `type` textual, nós
  proibidos (`html`, `iframe`, `script`, `style`, `template`, `blade`, `raw`) e **chave proibida em
  qualquer nível** reusando `BlocksSchema::isForbiddenKey` (mesma fonte da emenda ADR-016), tetos de
  profundidade (48) e de nós (5000). Locale em branco (`null`/`''`, o que o formulário do editor
  envia) é **ausência**, não erro. AST inválido = 422 por locale (`invalid_ast`/`forbidden_ast`/
  `too_large_ast`), nenhuma gravação parcial. Nada de HTML/CSS/Blade no `jsonb` (ADR-017/ADR-028).
- **`title` traduzível** com um **cast novo** `Core\Localization\LocalizedTextCast` (par do
  `LocaleCast` para texto plano): mapa `{locale: texto}`, fallback locale vigente → default,
  gravação em ordem canônica e **remoção** de um locale apagado; o `LocaleCast` continua exclusivo do
  AST. O DTO/documento não mudou: o `jsonb` guarda o objeto completo (ADR-012).
- **SEO básico** (`PaginaSeo`): `{title: {locale}, description: {locale}, robots: "noindex"|null}` —
  o shape do `metadata['seo']` do service Seo (ADR-027); locale não suportado/robots fora do
  vocabulário não entram (robots inválido responde 422 no request).
- **Texto é texto**: título e SEO recusam marcação HTML/Blade (mesma regra do catálogo de blocos),
  e o conteúdo rico só entra por AST.
- **CMS** (`paginas.cms.*`): lista com filtro por estado e busca por slug/título, criar, editar —
  controllers finos + `SavePaginaRequest` + Action `SavePagina` (span `paginas.save` + `log_event`;
  mutação auditada pelo `GlobalAuditSubscriber`: `paginas.created`/`paginas.updated`, área CMS).
  `paginas.ver` lê a lista; `paginas.editar` lê os formulários e grava; o request também responde
  **403 antes de 422** (`Permission::allows`). Escritas com `throttle:60,1`; binding por UUID.
- **Tela**: um `<x-layout::cms>` só com o `<main>`, título/SEO com um campo por locale (4), conteúdo
  pelo editor único do projeto (`<x-richtext::editor>`, ADR-028) e slug com ajuda; resumo de erros
  `role="alert"` + erro por campo (`aria-invalid`/`aria-describedby`); **sem JS obrigatório** (o
  editor degrada para o `<textarea>` do AST e as abas são rótulos). i18n nas 4 locales.
- **E2E**: história 27 + spec `27-cms-paginas` nos projetos `cms-desktop` e `cms-mobile` — cria a
  própria página (slug único por execução), prova o 422 de rota viva na UI e o caminho sem JS; axe no
  fluxo com JS.

### Arquivos

- `src/modules/Portal/Paginas/ModuleProvider.php`, `Routes/cms.php`, `MODULE.md`
- `src/modules/Portal/Paginas/Database/Migrations/2026_10_25_000001_create_paginas_pages_table.php`
- `src/modules/Portal/Paginas/Database/Models/Pagina.php`
- `src/modules/Portal/Paginas/Logic/Enums/PaginaStatus.php`
- `src/modules/Portal/Paginas/Logic/Actions/SavePagina.php`
- `src/modules/Portal/Paginas/Logic/Controllers/Cms/{PaginaIndex,PaginaCreate,PaginaEdit,PaginaStore,PaginaUpdate}Controller.php`
- `src/modules/Portal/Paginas/Logic/Requests/SavePaginaRequest.php`
- `src/modules/Portal/Paginas/Logic/DTOs/PaginaData.php`
- `src/modules/Portal/Paginas/Logic/Support/{PaginaSlug,PaginaReservedSlugs,PaginaContent,PaginaSeo,PaginaFormState}.php`
- `src/modules/Portal/Paginas/Layout/Views/cms/{index,form}.blade.php`
- `src/modules/Portal/Paginas/Lang/{pt_BR,en,es,fr}/{index,cms}.php`
- `src/modules/Portal/Paginas/Test/{PaginaCmsTest,PaginaSchemaTest,PaginaSlugTest}.php`
- `src/core/Localization/LocalizedTextCast.php` · `tests/Feature/LocalizedTextCastTest.php`
- `src/tests-e2e/histories/27-cms-paginas.md` · `src/tests-e2e/tests/27-cms-paginas.spec.ts`
- `src/tests-e2e/playwright.config.ts` (27 em `cms-desktop` e `cms-mobile`) · `src/tests-e2e/README.md`
- `wiki/specs/04-acessibilidade-ux.md` · `AGENTS.md` (contagem de histórias) · `phpstan.neon`
  (ignore obsoleto de `Locales.php` removido — o próprio PHPStan acusava a supressão sem uso)

### Provas

- `vendor/bin/phpunit --filter='PaginaCmsTest|PaginaSchemaTest|PaginaSlugTest|LocalizedTextCastTest'`
  → **33 testes, 226 asserções, OK** (403 sem permissão; CRUD; 4 locales de título/conteúdo/SEO;
  slug duplicado/caixa/rota viva (`noticias`, `cms`, `en`) e sufixo derivado; AST inválido/proibido/
  profundo como 422 por campo; marcação recusada; `robots` fechado; `status`/`published_at`
  preservados no `PUT`; binding UUID; filtros/busca; auditoria com ator snapshot; CHECKs e índice
  único; `down()` preservando `backup_*`).
- `vendor/bin/phpunit --filter='ModuleI18nTest|LayoutContractTest|PermissionCatalogTest|CmsNavigationTest|Adr004GrepFriendlyTest|IconTest|DocsReferencesTest|LocaleCastTest|HomeModuleTest'`
  → **63 testes, 6 727 asserções, OK**.
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**; `pint --test` limpo;
  `tools/name-check.sh`, `tools/arch-check`, `tools/migration-check` → OK.
- `tools/e2e.sh test tests/27-cms-paginas.spec.ts --project=cms-desktop` → **14 verdes** (inclui
  `cms-serial` 26 e o spec novo) e `--project=cms-mobile` → **41 verdes** (dependências + spec novo).
- `make check` → **22 OK · 0 FAIL · 11 SKIP** (123 s); suíte **1 528 testes / 25 964 asserções**;
  cobertura `src/` **93,96%** (19 554/20 812; piso 90%).
- `make check E2E=1` → **24 OK · 0 FAIL · 9 SKIP** (352 s): `e2e` **140 Playwright** (1 skip
  condicional; 2,5 min) com `27-cms-paginas` em **`cms-desktop` e `cms-mobile`**, `a11y` sniffCSS
  desktop+mobile OK e `perf` **18 testes** OK.
- Numa execução intermediária o passo `coverage` estourou o `memory_limit` de 256 MiB do pool
  (PHP-FPM) **depois** de rodar os 1 528 testes; a reexecução do ciclo fechou verde nos dois modos
  (não alterei o limite — ele é deliberado, ADR-020).

### Riscos e observações

- **`LocalizedTextCast` é um cast novo no Core** (texto plano × AST do `LocaleCast`): os dois
  resolvem mapas por locale com a mesma semântica (fallback e ordem canônica), mas tipos e merge
  diferentes. Se um terceiro campo precisar das duas formas, a decisão é unificar — não duplicar.
- **`layout_scope` sem campo na tela**: o contrato de dado existe (CHECK incluso) e permanece `null`
  nesta fatia; a S3.2 decide como o override de perfil de layout é oferecido.
- **`status` imutável aqui**: `PUT` não muda estado; publicar/despublicar/arquivar (e o 404/noindex
  de arquivada) são da S3.2. Página nasce `rascunho` e nunca é pública ainda.
- **`seo` grava mapas por locale** (title/description) + `robots`: a S3.2 deve resolvê-los com o
  locale da URL e cair no título/descrição do conteúdo quando vazios.
- **E2E deixa rascunhos** `e2e-pagina-*` no banco de dev (não há arquivar nesta fatia); a limpeza
  entra com a S3.2/S6.1. O endereço é único por execução/projeto, então o spec não colide.
- **`demo_stale()` não observa o manifesto novo**: num banco demo já semeado, o perfil
  `administrador` só recebe `paginas.*` depois de um seed (`CHECK_FORCE_PREP=1` localmente).
- **Submit no viewport móvel**: no `cms-mobile` o clique no botão “Salvar página” era interceptado
  pelo fieldset de SEO depois do formulário longo do editor rico (Playwright recusou o clique). O
  spec passou a submeter por **teclado** (Enter no campo de endereço — submissão implícita nativa),
  que é o mesmo caminho nos dois projetos; o possível sobreposição de camada no mobile fica como
  ponto de verificação de UI para a S3.2/S6.1 (não afeta o teclado nem o caminho sem JS).
- **Ignore de PHPStan removido**: `src/core/Localization/Locales.php` estava na lista de
  `shipmonk.deadMethod` sem nenhum achado — o `make check` acusava a supressão obsoleta; a linha saiu
  (a análise do arquivo continua ativa, agora sem supressão).

### Contrato que a S3.2 (fallback/render/SEO) deve reutilizar

- **Fallback**: a rota pública é `paginas.portal.show` (`GET /{slug}`) registrada como **fallback**
  da área Portal **antes** do `NotFoundController`; o slug nunca sombreia rota viva porque
  `PaginaReservedSlugs` já exclui `paginas.*` e é a mesma regra usada na gravação.
- **Leitura**: `Pagina::query()->where(status, Publicado)` + `slug` (índice único sobre `lower(slug)`
  cobre a busca); `title`/`content` já resolvem o locale via casts e `seo` é o mapa bruto — use
  `PaginaSeo::fromArray($pagina->seo)` + `LocalizedTextCast::resolve()` por campo.
- **Render**: AST → `RichTextRenderer::toHtml()` (service, ADR-028) no `<x-layout::portal>`;
  arquivada → 404 + `X-Robots-Tag: noindex`; `canonical`/`hreflang` por locale; sitemap via
  `SitemapRegistry` (só publicadas, `loc` do portal — ADR-027/043).
- **Publicar/despublicar/arquivar**: transição de `status` + `published_at` (o CHECK exige data no
  publicado), `paginas.arquivar`/`paginas.editar`, auditoria explícita `paginas.published` e
  invalidação de cache — sem um segundo validador de AST (reusar `PaginaContent`).

## S3.2 — Portal de páginas: fallback, render, SEO, sitemap, prévia e transições (`task_01lsnppn`)

### O que foi implementado

- **Fallback real da área Portal** (`Routes/portal-fallback.php`): `GET /{slug}` e `GET
  /{locale}/{slug}` como rota **nomeada** `paginas.portal.show`, carregada pelo `AreaRegistrar`
  **depois** de todas as rotas vivas do grupo e **antes** do 404 da área (nova convenção
  `Routes/{area}-fallback.php` + `PlatformArea::fallbackRouteFile()`). Um curinga que abre com
  parâmetro nunca sombreia `/noticias`, `/dados-abertos` ou qualquer rota de módulo/service — a
  ordem é do Core, não a alfabética da descoberta. O slug constraint/filtro e a regra
  `PaginaReservedSlugs` (S3.1) continuam sendo a única fonte da colisão.
- **Render público** (`PaginaShowController` + `PaginaRenderer` + `paginas::show`): só página
  **publicada** responde 200; inexistente, rascunho e arquivada são **404 real** com
  `X-Robots-Tag: noindex` (renderer global — ADR-027). O AST vai ao `RichTextRenderer` (ADR-028) e
  o HTML sai no `<x-layout::portal>` com `title`/`description` (SEO por locale com fallback para o
  conteúdo), `canonical` da rota nomeada (prefixo de idioma), `robots`, `breadcrumbs` e a imagem
  social resolvida pelo service Media (primeira mídia do AST). O `<h1>` é único: o título da página
  e **os headings do conteúdo descem um nível** (`demoteHeadings`), nunca dois `<h1>`.
- **Locale e SEO**: `hreflang`/`x-default` por locale saem do shell (`<x-service-seo::tags>`), que
  só emite alternate de URL que existe no roteador; página `noindex` não declara canônica. O
  `@graph` governamental inclui `GovernmentPage` com `inLanguage` do locale.
- **Sitemap** (`Seo/PaginaSitemapSource`, registrado no `ModuleProvider::register()`): fonte `paginas`
  (`/sitemap-paginas.xml`) só com `status = publicado`, `lastmod`, URLs localizadas e `alternates`;
  ordenação/`ETag`/304 e a exclusão de `/cms`/`/api` continuam no service Seo (ADR-027).
- **Cache de render**: `paginas.page.{id}.{locale}` (TTL `config('paginas.cache_ttl')`), esquecido de
  forma **síncrona** ao salvar/publicar/despublicar/arquivar; duas requests iguais servem o corpo
  cacheado e a primeira request depois da escrita já mostra o novo estado (ADR-022).
- **Prévia autenticada** (`GET /cms/paginas/{id}/preview`, `paginas.preview`, `paginas.ver`): sessão
  do CMS (anônimo → login, sem permissão → 403), locale por `?locale=`, mesmo shell/render do portal,
  `noindex, nofollow` nas duas camadas, **sem** canônica, **sem** cache e **sem** token público.
- **Transições** (`TransitionPagina` + `POST` `paginas.cms.publish`/`paginas.cms.unpublish`/
  `paginas.cms.archive`): `rascunho → publicado` (grava `published_at` com o `Clock` injetado),
  `publicado → rascunho` (limpa a data), `rascunho|publicado → arquivado` (preserva a data
  histórica); qualquer outra combinação é **409** (`PaginaTransitionConflict`) com `lockForUpdate`.
  Publicar/despublicar exigem `paginas.editar`; arquivar exige `paginas.arquivar`; **403 antes** de
  409/422. Cada transição grava evidência `paginas.published`/`paginas.unpublished`/
  `paginas.archived` (ator snapshot — ADR-035), emite span + `log_event` (`paginas.publish`/
  `paginas.unpublish`/`paginas.archive`) e esquece o cache. Nunca `GET` mutando: o formulário e a
  tela usam `POST` + CSRF.
- **CMS**: o formulário de edição ganhou o painel **Visibilidade** (estado atual + só a transição
  válida + permissão), a prévia autenticada por locale e o link “Ver no site” quando publicado —
  tudo em formulários próprios (sem `<form>` aninhado) e sem depender de JS.
- **Redirect legado**: `RedirectLegacy` passou a exigir **primeiro segmento estático** para
  considerar a rota “viva” (a raiz inclusive). O curinga `{locale?}/{slug}` atende qualquer caminho
  e não prova que o endereço é conteúdo; sem isso um 301 de `/antigo` viraria 404 do fallback.
- **Correções incidentais de gate**: `SeoTagsTest`/`I18nTest` usam rotas de teste de **dois**
  segmentos (uma rota registrada depois do boot seria sombreada pelo curinga — artefato de teste,
  nunca do runtime) e `MetricsIngestionTest` mede o path não casado com dois segmentos (o de um
  segmento agora é 404 **do módulo** `paginas`). O ignore obsoleto de `RichTextRenderer` no
  `phpstan.neon` saiu: o renderer passou a ter consumidor em módulo.

### Arquivos

- `src/core/Internal/Routing/AreaRegistrar.php` — fallback da área depois das rotas vivas.
- `src/core/Enums/PlatformArea.php` — `fallbackRouteFile()`.
- `src/core/Area/Portal/README.md` — contrato do fallback/redirect.
- `src/services/Seo/Http/Middleware/RedirectLegacy.php` — rota viva = primeiro segmento estático.
- `src/services/Seo/README.md` — fonte `paginas` e precedência do redirect.
- `src/modules/Portal/Paginas/Routes/portal-fallback.php` — `paginas.portal.show`.
- `src/modules/Portal/Paginas/Routes/cms.php` — `paginas.cms.publish|unpublish|archive` + `paginas.preview`.
- `src/modules/Portal/Paginas/ModuleProvider.php` — registra `PaginaSitemapSource`.
- `src/modules/Portal/Paginas/Logic/Controllers/PaginaShowController.php`.
- `src/modules/Portal/Paginas/Logic/Controllers/Cms/{PaginaPublish,PaginaUnpublish,PaginaArchive,PaginaPreview}Controller.php`.
- `src/modules/Portal/Paginas/Logic/Actions/TransitionPagina.php`.
- `src/modules/Portal/Paginas/Logic/Exceptions/PaginaTransitionConflict.php`.
- `src/modules/Portal/Paginas/Logic/Support/{PaginaRenderer,PaginaCache}.php`.
- `src/modules/Portal/Paginas/Logic/DTOs/PaginaRenderData.php`.
- `src/modules/Portal/Paginas/Seo/PaginaSitemapSource.php`.
- `src/modules/Portal/Paginas/Layout/Views/show.blade.php` + `cms/form.blade.php` (visibilidade/prévia).
- `src/modules/Portal/Paginas/Lang/{pt_BR,en,es,fr}/cms.php`.
- `src/modules/Portal/Paginas/Logic/Actions/SavePagina.php` — esquece o cache na escrita.
- `src/modules/Portal/Paginas/Logic/Controllers/Cms/PaginaEditController.php` — flags de permissão.
- `src/modules/Portal/Paginas/Test/PaginaPortalTest.php` — contrato da fatia.
- `src/modules/Portal/Paginas/MODULE.md` — rotas/permissões/eventos/portal/cache.
- `config/paginas.php` — TTL do cache de render.
- `phpstan.neon` — removido o ignore obsoleto de `RichTextRenderer`.
- `src/services/Seo/Test/SeoTagsTest.php` · `tests/Feature/I18nTest.php` ·
  `src/core/Internal/Metrics/Test/MetricsIngestionTest.php` — paths de teste com dois segmentos.
- `src/tests-e2e/histories/28-portal-paginas.md` · `tests/28-cms-paginas-portal.spec.ts` ·
  `playwright.config.ts` (`28` em `cms-desktop` e `cms-mobile`) · `README.md`.
- `AGENTS.md` — contagem de histórias e2e.

### Provas

- `vendor/bin/phpunit --filter='PaginaPortalTest'` → **9 testes, 117 asserções, OK** (fallback
  registrado nos dois formatos de locale; `/noticias` não sombreada mesmo com página de mesmo slug
  no banco; publicado 200 com `<h1>` único, AST, canônica e `hreflang`/`x-default`; rascunho e
  arquivada 404 + `X-Robots-Tag: noindex`; sufixo `/en/…` com título/SEO do locale; imagem social
  pela Media; sitemap só publicadas, ordenado, com `ETag`/304; prévia anônimo→login, sem permissão
  403 e render no shell real `noindex` sem canônica; publicar 403/405/CSRF/409, `published_at`,
  auditoria com ator, cache invalidado em dois requests; despublicar limpa a data e derruba a URL;
  arquivar exige `paginas.arquivar`, grava evidência e fecha a URL).
- `vendor/bin/phpunit --filter='PaginaCmsTest|PaginaSchemaTest|PaginaSlugTest|PaginaPortalTest|HomeModuleTest|HomePublishPreviewCmsTest|HomeEditorCmsTest|SeoServiceTest|SeoTagsTest|SeoPublishTest|SeoRedirectTest|SeoRedirectCommandTest|ModulesTelemetryTest|MetricsIngestionTest|MaintenanceModeTest|DocsReferencesTest|ModuleI18nTest|LayoutContractTest|PermissionCatalogTest|CmsNavigationTest|Adr004GrepFriendlyTest|E2eDocsTest|LocaleUrlInvariantTest'`
  → verde (nenhuma regressão de SEO/rotas/métricas/manutenção/estruturais).
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**; `pint --test` limpo;
  `tools/arch-check`/`name-check`/`migration-check`/`vendor-check` OK via `make check`.
- `make check` → **22 OK · 0 FAIL · 11 SKIP** (128 s); suíte **1 537 testes / 26 299 asserções**
  verdes; cobertura `src/` **94,03%** (19 903/21 167; piso 90%).
- `make check E2E=1` → **26 OK · 0 FAIL · 7 SKIP** (359 s): `e2e` **143 testes Playwright**
  (142 passed + 1 skip condicional; 2,6 min), com `28-cms-paginas-portal` em **`cms-desktop` e
  `cms-mobile`** (axe na tela do CMS e na página pública), `a11y` sniffCSS desktop+mobile OK e
  `perf` **18 testes** OK. A primeira execução do `coverage` estourou o `memory_limit` de 256 MiB
  na mesclagem **depois** de os 1 537 testes passarem; a reexecução do ciclo fechou verde (mesmo
  comportamento registrado na S3.1 — o limite é deliberado, ADR-020).

### Riscos e observações

- **Ordem de rotas é contrato**: uma rota registrada **depois** do boot (só acontece em teste) é
  sombreada pelo curinga — por isso três testes usam paths de dois segmentos. No runtime toda rota
  nasce no boot e o fallback fica por último no grupo.
- **`LocaleRoutes` vê o curinga como “existe”**: um path de **um** segmento passa a ser considerado
  existente em todos os locales (para `hreflang` e validação de links). Para páginas isso é
  correto (a variante por locale responde com fallback de conteúdo); para um menu apontando a um
  slug inexistente o link só falha no clique. Um slug de página nunca colide com rota viva, então o
  efeito é restrito ao espaço de páginas.
- **Redirect × página**: um `seo_redirects` cujo path é um slug de página vence a página (o
  redirect roda antes do fallback). O slug da página não consulta `seo_redirects` — mesma decisão
  do `RedirectLegacy` (redirect explícito do operador).
- **`published_at` ao arquivar**: preservado como histórico (o CHECK só exige data no publicado);
  não há restauração nesta fatia — arquivar é terminal e a URL fica reservada (slug único sempre).
- **Cache sem versionamento**: chave estável + invalidação síncrona na escrita; uma corrida de
  leitura concorrente pode repovoar a chave com o corpo antigo por até o TTL (rede de segurança),
  como na home. O cache **de aplicação** é invalidado na hora, mas o `Cache-Control` público
  (`max-age`/`s-maxage`, ADR-022) do navegador/borda continua valendo pelo TTL: uma URL que acabou
  de ser arquivada pode ser servida do cache HTTP do cliente até expirar (o e2e usa `?e2e=` para
  provar o estado do servidor). Não há FPC/invalidação de borda nesta fase — mesmo comportamento da
  home publicada.
- **`config('paginas.cache_ttl')`** é a única configuração nova; `0` desliga o cache de render.
- **`hreflang` de página não traduzida**: a variante `/en/<slug>` responde 200 com fallback de
  conteúdo (comportamento do `LocaleCast`), então o alternate é válido — não é conteúdo inventado.
- **E2E**: o spec cria a própria página e a arquiva no fim; publicar/arquivar é por página (não é
  estado global como a home), então roda em `cms-desktop` e `cms-mobile` sem lote serial.
  - **Clique × viewport móvel**: no `cms-mobile` o clique por ponteiro nos botões da seção de
    visibilidade e no link de prévia era interceptado por elementos que mudam de posição (o editor
    rico acima cresce ao inicializar). O spec usa **foco + Enter** (submit nativo, o mesmo caminho
    de teclado) e navega para a prévia pelo `href` verificado — sem `waitForTimeout` e sem `force`.
    A sobreposição visual no mobile fica como ponto de verificação de UI (mesma suspeita registrada
    na S3.1), não como bloqueio funcional.
  - **429 no rerun local**: `throttle` é chaveado por **domínio + IP** (não por rota) no Laravel;
    rodadas manuais repetidas do e2e em menos de um minuto esgotam a cota compartilhada do painel.
    Os freios das transições seguem o padrão das escritas do módulo (`throttle:60,1`). Numa execução
    de CI/`make check` isolada não ocorre; se acontecer em rerun manual, aguardar a janela de 1 min.

### Contrato para S4/S5

- **Fallback**: qualquer superfície pública de um segmento deve ser um módulo com
  `Routes/portal-fallback.php` nomeado `{token}.portal.show` (uma por área) — nunca `Route::get
  '/{slug}'` em `Routes/portal.php` (seria registrada antes das rotas vivas e as sombrearia).
- **404 × conteúdo**: `status` decide a visibilidade no controller; o 404 real já emite
  `X-Robots-Tag` — não criar página de “não encontrado” no módulo.
- **SEO**: dados por locale no `seo` jsonb; canônica pela **rota nomeada** (nunca `url()`),
  `schemas` só com nós extras (o `GovernmentPage`/`@graph` é do service); imagem por Media.
- **Sitemap**: `SitemapSource` só com URLs públicas e `alternates`; o service ordena e assina
  `ETag` — não montar XML no módulo.
- **Preview**: sessão do CMS + permissão `ver`, mesmo shell/render do portal, `noindex` nas duas
  camadas, sem cache e sem token público.
- **Transições**: Action única com matriz fechada (`409` fora dela), `lockForUpdate`, `Clock` para
  `published_at`, auditoria explícita `{token}.published|unpublished|archived` e invalidação de
  cache na escrita — nunca `GET` mutando.

## S4.1 — Módulo `Destaques`: tabela e validação de conteúdo (`task_00gqf6mq`)

### O que foi implementado

- **Módulo `Portal/Destaques`** (token `destaques`, grupo `Portal`) com **um** `ModuleProvider` e
  sem bindings: ícone `lucide-highlighter`, `position: 30`, rota do painel `destaques.cms.index` e
  `permissions` `[ver, editar, arquivar]` (ADR-014/ADR-033). `aprovar` fica fora porque entrar/sair
  do site é `active` + vigência, não uma aprovação editorial separada. Gerado por
  `make create:module MODULE=Portal/Destaques`; os stubs genéricos foram descartados (`.pi/trash`).
- **Tabela `destaques.destaques`** (migração idempotente, `SET lock_timeout`, schema próprio):
  `id` (`uuidv7()`), `title` (jsonb localizado), `image_id` (uuid, **FK** para
  `gallery.gallery_media` com `ON DELETE RESTRICT`), `alt` (texto), `link` (texto nulo),
  `slot` (texto), `position` (inteiro), `starts_at`/`ends_at` (timestamptz nulos), `active`
  (booleano), `archived_at` (timestamptz nulo), `timestampsTz`. Contratos no banco: CHECK de
  `title` objeto não vazio, `alt` não vazio e ≤ 200, `slot` no vocabulário fechado
  (`main.before|main.after|main.header|main.footer`), `position >= 0`, janela coerente
  (`ends_at > starts_at`) e `link` apenas caminho same-origin ou `https://`. Índices da pilha
  (`slot, position, id` parcial em não arquivadas) e da vigência (`slot, starts_at, ends_at` parcial
  em ativas). `down()` renomeia tabela **e** índices para `backup_*` (ADR-017).
- **Conteúdo validado na Action, forma no request** (`SaveDestaque` + `SaveDestaqueRequest`):
  - a imagem é o **id do catálogo** resolvido pelo contrato `GalleryStore::view()` (ADR-015/026) —
    id inexistente ou arquivado → **422 `image_id`**;
  - `alt` obrigatório, ≤ 200 e sem marcação HTML/Blade (texto é texto, ADR-017/028);
  - destino pelo `DestaqueLink`: `/…` (nunca `//host`, `..`, espaço, `\`) ou `https://` absoluto sem
    credenciais; `javascript:`, `data:`, `http:` e esquema em caixa alta são recusados com **422
    `link`** — a regra casa exatamente com o CHECK (`https://` literal), então o banco nunca
    transforma um aceite da aplicação em 500;
  - janela incoerente (fim ≤ início) → **422 `ends_at`**; `position` em branco vira a **próxima**
    do slot (`max + 1`, uma query, determinística);
  - título obrigatório no locale default e sem marcação (4 locales, `LocalizedTextCast`);
  - mensagens **do módulo** (`destaques::cms.*`, 4 locales) — a aplicação não publica
    `lang/validation.php`, então nenhum erro chega ao operador como chave crua.
- **Vigência com `Clock` injetado** (`DestaqueWindow`): coerência na escrita e estado efetivo na
  leitura (`arquivado → inativo → agendado → expirado → vigente`, janela meio-aberta
  `[starts_at, ends_at)`). Nada de coluna “vigente”; a S4.2 reusa a mesma regra no widget.
- **CMS** (`destaques.cms.*`): lista com filtro por slot/situação e busca por título/`alt`, criar,
  editar, **ativar/desativar** e **arquivar**. Controllers finos + FormRequest; `403 antes de
  422/409` (`Permission::authorize` no controller e `allows` no request); binding por UUID;
  escritas com `throttle:60,1`. `active=false` **preserva** o registro (ADR-019); arquivar é
  terminal nesta fatia (registro preservado e imutável — editar/ativar/desativar respondem **409**).
- **Auditoria/observabilidade**: mutações na trilha ambiente (`destaques.created`/`updated`);
  evidência explícita `destaques.activated`/`deactivated`/`archived` com ator snapshot (ADR-035);
  span + `log_event` `destaques.save|activate|deactivate|archive` (ADR-024). Ativar o estado que já
  vale é idempotente e não gera evidência duplicada.
- **Tela**: um `<x-layout::cms>` só com o `<main>`, sem `<h1>` próprio; título por locale, id da
  mídia, `alt`, destino, slot, ordem, janela (`datetime-local`) e estado (`checkbox` + campo oculto
  `0`, **sem JS obrigatório**); resumo de erros `role="alert"` + erro por campo
  (`aria-invalid`/`aria-describedby`); ações de linha `POST` + CSRF, arquivamento com confirmação
  nativa (Popover, sem `<form>` aninhado); peça arquivada mostra os valores em `<dl>`.
- **E2E**: história 29 + spec `29-cms-destaques` em `cms-desktop` e `cms-mobile` — sobe a própria
  imagem no catálogo (upload único), lê o id da mídia do formulário de arquivamento do card, cria a
  peça, desativa/ativa e arquiva com confirmação nativa; axe na lista, no formulário e no estado
  arquivado; o bloco **sem JS** prova a validação server-side (imagem inexistente + `javascript:`).

### Arquivos

- `src/modules/Portal/Destaques/ModuleProvider.php`, `Routes/cms.php`, `MODULE.md`
- `src/modules/Portal/Destaques/Database/Migrations/2026_10_26_043244_create_destaques_destaques_table.php`
- `src/modules/Portal/Destaques/Database/Models/Destaque.php`
- `src/modules/Portal/Destaques/Logic/Enums/{DestaqueSlot,DestaqueState}.php`
- `src/modules/Portal/Destaques/Logic/Actions/{SaveDestaque,SetDestaqueActive,ArchiveDestaque}.php`
- `src/modules/Portal/Destaques/Logic/Controllers/Cms/{DestaquesIndex,DestaqueCreate,DestaqueEdit,DestaqueStore,DestaqueUpdate,DestaqueActivate,DestaqueDeactivate,DestaqueArchive}Controller.php`
- `src/modules/Portal/Destaques/Logic/Requests/SaveDestaqueRequest.php`
- `src/modules/Portal/Destaques/Logic/DTOs/DestaqueData.php`
- `src/modules/Portal/Destaques/Logic/Exceptions/DestaqueTransitionConflict.php`
- `src/modules/Portal/Destaques/Logic/Support/{DestaqueLink,DestaqueWindow,DestaqueFormState}.php`
- `src/modules/Portal/Destaques/Layout/Views/cms/{index,form}.blade.php`
- `src/modules/Portal/Destaques/Lang/{pt_BR,en,es,fr}/{index,menu,cms}.php`
- `src/modules/Portal/Destaques/Test/{DestaquesCmsTest,DestaquesSchemaTest,DestaqueLinkTest,DestaqueWindowTest}.php`
- `src/tests-e2e/histories/29-cms-destaques.md` · `src/tests-e2e/tests/29-cms-destaques.spec.ts`
- `src/tests-e2e/playwright.config.ts` (29 em `cms-desktop` e `cms-mobile`) · `src/tests-e2e/README.md`
- `wiki/specs/04-acessibilidade-ux.md` · `AGENTS.md` (contagem de histórias e2e)

### Provas

- `vendor/bin/phpunit --filter='DestaquesCmsTest|DestaquesSchemaTest|DestaqueLinkTest|DestaqueWindowTest'`
  → **33 testes, 241 asserções, OK** (403 sem permissão; criar/editar/ativar/desativar exigem
  `editar` e arquivar exige `arquivar`; CRUD com posição determinística por slot; título nas 4
  locales; `alt` vazio/espaços/marcação/limite; 8 destinos inseguros recusados e 2 seguros gravados;
  slot fora do vocabulário; janela invertida e de duração zero; mídia inexistente/arquivada e UUID
  malformado; desativar preserva o registro e audita com ator; ativar idempotente sem evidência
  duplicada; arquivar é final (409 em editar/ativar/desativar); filtros por slot/situação/busca;
  estados de vigência com relógio fixo; FK/CHECKs/índices e `down()` preservando `backup_*`).
- `vendor/bin/phpunit --filter='ModuleI18nTest|LayoutContractTest|PermissionCatalogTest|CmsNavigationTest|Adr004GrepFriendlyTest|IconTest|DocsReferencesTest|E2eDocsTest'`
  → **OK** (4 locales, um shell por view, painel navegável, ícone vendorizado, rotas/eventos no
  `MODULE.md`).
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**; `pint --test` limpo;
  `tools/arch-check`, `tools/name-check.sh`, `tools/migration-check` → OK.
- `make check` → **24 OK · 0 FAIL · 9 SKIP** (130 s); suíte **1 570 testes / 27 400 asserções**
  verdes; cobertura `src/` **94,17%** (20 774/22 061; piso 90%); `e2e`/`perf` são opt-in.
- `tools/e2e.sh test tests/29-cms-destaques.spec.ts --project=cms-desktop` → **2 verdes** (12,4 s) e
  `--project=cms-mobile` → **2 verdes** (12,5 s), incluindo o bloco sem JS.
- `make check E2E=1` → **25 OK · 1 FAIL · 7 SKIP** (378 s): `e2e` **146 passed + 1 skip
  condicional** (2,9 min, com `29-cms-destaques` em **`cms-desktop` e `cms-mobile`**), `a11y`
  sniffCSS desktop+mobile (`strict=1`) e `perf` **18 testes** OK; a suíte PHPUnit passou inteira
  (1 570/27 400) e o **único** FAIL foi a mesclagem da cobertura estourando o `memory_limit` de
  256 MiB do pool **depois** dos testes — mesmo comportamento ambiental registrado nas S3.1/S3.2
  (o `make check` sem e2e fechou `coverage` verde em 94,17% duas vezes). Nenhum teste ficou vermelho.
- Correção incidental exigida pelo E2E: `gallery.upload`/`gallery.batch.commit` usavam
  `throttle:30,1` **anônimo** — a chave do `ThrottleRequests` não inclui a rota, então todas as
  escritas do painel do mesmo usuário dividiam **um** contador e o upload legítimo do spec recebeu
  **429** (`POST /galeria`) no lote paralelo. As duas rotas passaram ao limiter **nomeado**
  `media-upload` (120/min por usuário, ADR-026 §2/ADR-021) — política que o ADR já declarava.

### Riscos e observações

- **Seletor de mídia**: o formulário pede o **id** do catálogo (texto), sem picker/upload — mesma
  decisão da S2.1 para os blocos. Quem não sabe o id precisa abrir `/galeria`; o e2e lê o id do
  formulário de arquivamento do card (o catálogo não expõe outro identificador). Um seletor é
  melhoria de UX para uma fatia própria, não requisito desta.
- **FK cross-schema**: `destaques.destaques.image_id → gallery.gallery_media(id)` é a forma mais
  forte de “imagem existe”. O Gallery usa soft delete/`archived_at`, então o `RESTRICT` só pesa num
  `DELETE` físico (que não existe hoje). O preço é a ordem de migração: o service Gallery precisa
  existir antes (é sempre disponível — ADR-015).
- **Arquivar é terminal** nesta fatia (como em Páginas até a S3.2): não há “desarquivar”. O
  registro e a trilha permanecem; a S4.2 pode expor a restauração se o produto pedir.
- **`position` calculada** (`max + 1`) roda numa query por gravação e não é única por slot: duas
  gravações simultâneas podem empatar na mesma posição — a leitura desempata por `id` (ordem
  determinística), então o pior caso é uma ordem fixa diferente da intenção, nunca duplicidade
  invisível. Um índice único por slot/posição exigiria reordenar no servidor (escopo de uma fatia
  de ordenação).
- **`datetime-local`** grava no fuso da aplicação (UTC) e a tela mostra o mesmo instante; converter
  a exibição para o fuso do usuário é decisão de produto (hoje o painel é single-tenant e o site é
  UTC).
- **E2E depende do upload do catálogo** (`storage` + fila no ar), como a história 15: no `make check
  E2E=1` o ambiente já provê. A imagem criada fica no catálogo de dev (soft delete) e a peça fica
  arquivada — nenhum spec depende de dado de outro.

### Contrato que a S4.2 (widget/cache/vigência) deve reutilizar

- **Leitura**: `Destaque::query()->whereNull('archived_at')->where('active', true)->orderBy('slot')
  ->orderBy('position')->orderBy('id')` + `DestaqueWindow::state()` por peça — só
  `DestaqueState::Vigente` aparece. **Uma query** por slot (sem N+1); a janela é filtrada em PHP
  porque o estado depende de um único instante.
- **Render**: `WidgetSource` `destaques.banner` (embeddable) no `WidgetRegistry` do Core;
  `<x-media::picture>` com as variantes resolvidas pelo service Media e o `alt` da peça; sem
  carrossel/autoplay e sem JS (ADR-016/025).
- **Cache**: chave por slot, invalidada **sincronamente** em `SaveDestaque`, `SetDestaqueActive` e
  `ArchiveDestaque` (ADR-022). Nenhum desses caminhos escreve cache hoje — a S4.2 acrescenta o
  `forget` sem mudar o validador.
- **Limite por slot**: configuração (`config/destaques.php`), nunca coluna; o schema e a validação
  de conteúdo continuam sendo os únicos validadores (não duplicar `DestaqueLink`/`DestaqueWindow`).
- **Sem tocar no dado**: HTML/CSS/classes continuam fora do banco; o widget não importa outro
  módulo e lê só o próprio token.

## S4.2 — Vigência, widget `destaques.banner` e cache por slot (`task_01rdz39n`)

### O que foi implementado

- **Fonte de widget no módulo dono** (`Widgets/DestaqueBannerWidget`): `id = destaques.banner`,
  `title = destaques::widget.title` (4 locales), ícone Lucide `lucide-image`, `embeddable = true` e
  slots `main.before`, `main.after`, `main.header`, `main.footer` (o vocabulário de `DestaqueSlot`,
  subconjunto canônico de `LayoutSlot`). Registrada em `ModuleProvider::register()` via
  `WidgetRegistry` (embed permitido, import proibido — ADR-013/ADR-016).
- **Slot do perfil decide a pilha:** `<x-layout::slot>` passou a repassar o nome do slot como
  `layoutRegion` ao `<x-layout::widget>`; o componente renderiza o fragmento **uma vez** e só emite
  o wrapper `layout-widget` quando há conteúdo (slot vazio sem `<div>` órfã). O shell do portal
  passou a renderizar `main.before`/`main.after` dentro do `<main>` (eram slots declarados sem
  superfície); `main.header`/`main.footer` já existiam.
- **Leitura sem N+1** (`Logic/Support/DestaqueSlotFeed`): **uma** query de peças
  (`active`, `archived_at IS NULL`, slot; `ORDER BY slot, position, id`) + **uma** `whereIn` de
  mídia pelo novo `GalleryStore::views()` (uma consulta por lote, indexada por id) — a mídia
  arquivada/ausente faz a peça degradar (omitida), nunca meio bloco. Estado efetivo pelo
  `DestaqueWindow` (Clock injetado); só `Vigente` aparece, sem rotação automática.
- **Vigência clock-aware no cache:** o payload (`DestaqueBannerItem`) guarda as **janelas** e a
  leitura reaplica a regra meio-aberta `[starts_at, ends_at)`. Expirada não entra no cache e uma
  peça vencida **nunca** é servida mesmo com o cache quente; uma peça agendada entra no ar no
  instante certo, sem depender do TTL. Essa é a justificativa determinística (o TTL deixa de ser a
  garantia de vigência).
- **Cache por slot** (`Logic/Support/DestaqueSlotCache`): chave `destaques.slot.{slot}.{locale}`
  (o locale é o eixo de versão — título traduzível), TTL `config('destaques.cache_ttl')` (default
  300 s, `0` desliga) como rede de segurança, e invalidação **síncrona** em `SaveDestaque` (slot
  antigo **e** novo), `SetDestaqueActive` e `ArchiveDestaque` (ADR-022). Payload corrompido força
  releitura do banco.
- **Limite por slot** (`Logic/Support/DestaqueSlotLimit` + `config/destaques.php`): default 3;
  valor inválido (`< 1`, não numérico) é ignorado; o recorte é o prefixo determinístico da pilha
  vigente (`position`, `id`). É **teto de leitura** — a redação pode agendar além dele; o site
  mostra no máximo N. Nunca coluna (ADR-017).
- **Render** (`Layout/Views/widgets/destaques-banner.blade.php`): `<section aria-label>` (sem `id` fixo — o
  widget pode ocupar dois slots), `<ul>`/`<li>`/`<article>` por peça, `<h2>` com o título e
  `<x-media::picture>` (AVIF/WebP, `width`/`height`, `alt` obrigatório da peça, `decoding="async"`;
  `loading="eager"` em `main.before`/`main.header`, `lazy` em `main.after`/`main.footer`). Caminho
  interno passa por `localized_url`; link externo `https` leva `rel="external noopener"`. Sem JS,
  sem carrossel/autoplay, sem `style=`/inline (ADR-010/ADR-016/ADR-025).
- **Contrato de mídia ampliado no service Gallery:** `GalleryMediaView` ganhou `sources` (chaves de
  objeto no shape do `<x-media::picture>`) e `GalleryStore::views(array $ids)` (uma consulta em
  lote). Os consumidores existentes (`view()`/manifest do SEO) não mudaram de comportamento.
- **Módulo ativo/inativo:** com o módulo ativo o registry resolve a fonte; inativo (ADR-034) o
  registry devolve `null`, o slot fica vazio e o request continua 200 (sem 500).

### Arquivos

- `config/destaques.php` — `slot_limit`/`cache_ttl`.
- `src/modules/Portal/Destaques/ModuleProvider.php` — registra a fonte.
- `src/modules/Portal/Destaques/Widgets/DestaqueBannerWidget.php`
- `src/modules/Portal/Destaques/Logic/Support/{DestaqueSlotFeed,DestaqueSlotCache,DestaqueSlotLimit}.php`
- `src/modules/Portal/Destaques/Logic/DTOs/DestaqueBannerItem.php`
- `src/modules/Portal/Destaques/Logic/Actions/{SaveDestaque,SetDestaqueActive,ArchiveDestaque}.php` — invalidação síncrona.
- `src/modules/Portal/Destaques/Layout/Views/widgets/destaques-banner.blade.php`
- `src/modules/Portal/Destaques/Lang/{pt_BR,en,es,fr}/widget.php`
- `src/modules/Portal/Destaques/Test/DestaqueBannerWidgetTest.php`
- `src/layout/Components/{widget,slot,portal}.blade.php` — `layoutRegion`, wrapper condicional e slots `main.before`/`main.after`.
- `src/services/Gallery/Contracts/GalleryStore.php` · `src/services/Gallery/DTOs/GalleryMediaView.php` · `src/services/Gallery/GalleryService.php` — `views()` + `sources`.
- `phpstan.neon` — exceção estreita para o feed consumido só por Blade.
- `tests/Feature/WidgetRegistryTest.php` · `src/services/Layout/Test/LayoutAreaTest.php` — expectativas com a nova fonte.
- `src/modules/Portal/Destaques/MODULE.md`.

### Provas

- `vendor/bin/phpunit --filter='DestaqueBannerWidgetTest'` → **12 testes / 131 asserções, OK**
  (registro/id/slots/ícone/embeddable; vigente renderizado com picture/alt/dimensões/link
  localizado; futuro/expirado/exatamente-no-fim/inativo/arquivado ausentes e slot vazio sem
  wrapper; limite 2→3 e config inválida→default; cache invalidado ao salvar/ativar/desativar/
  arquivar e ao mover de slot; cache clock-aware sem limpar a chave; **1** query de peças + **1**
  de mídia e **0/0** no segundo request; mídia arquivada degrada; módulo inativo → slot vazio;
  fragmento sem script/style inline com `rel` externo).
- `vendor/bin/phpunit --filter='WidgetRegistryTest|LayoutAreaTest|LayoutProfileTest|DestaqueBannerWidgetTest'`
  → **42 testes / 265 asserções, OK**.
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**; `pint --test` limpo.
- Suíte completa: **1 582 testes / 27 594 asserções** verdes; cobertura `src/` **94,13%**
  (20 958/22 264; piso 90%); `arch-check`/`name-check`/`migration-check` OK.
- `make check` → **22 OK · 0 FAIL · 11 SKIP** (128,74 s). Na execução anterior (mesmo código) a
  suíte passou e o **único** FAIL foi a mesclagem do clover estourando o `memory_limit` de 256 MiB
  do pool (`HitMap`/`Xml`), comportamento ambiental já registrado nas S3.1/S3.2/S4.1 — nenhum teste
  vermelho.
- `make check E2E=1` → **23 OK · 1 FAIL · 9 SKIP** (373,54 s): `e2e` **146 passed + 1 skip**
  (2,9 min), `a11y` sniffCSS desktop+mobile (`strict=1`) **OK**, `perf` **18 testes** OK; o **único**
  FAIL foi o mesmo estouro de memória da mesclagem da cobertura **depois** dos testes (1 582/27 594
  verdes).

### Riscos e observações

- **`layoutRegion` é um canal novo do componente genérico:** o `WidgetSource::render()` continua sem
  argumentos (contrato do Core intacto); o nome do slot chega como dado de view. Widgets que não
  usam o slot ignoram a chave. Quem criar widget dependente de slot deve seguir esse canal (S5.1).
- **Fonte construída no boot:** `render()` devolve a view e o **fragmento** resolve
  `DestaqueSlotFeed`/`Clock`/locale no render — injetar `Clock` no construtor da fonte congelaria o
  instante do boot (e quebraria os testes de vigência). O padrão está documentado no `MODULE.md`.
- **Cache de mídia:** arquivar/restaurar uma mídia **não** invalida o cache do slot (fora do
  escopo desta fatia). A peça com mídia já cacheada continua renderizando até o TTL/invalidação da
  peça; restaurar mídia de peça ativa pode levar até o TTL para aparecer. Candidato a fatia de
  invalidação de mídia quando o Gallery expuser eventos.
- **Limite é de leitura:** peças além do teto permanecem no banco e entram na pilha quando as
  anteriores saem (por vigência ou edição). Não há rejeição na escrita.
- **Slots `main.before`/`main.after` agora renderizam no shell do portal:** qualquer perfil futuro
  que os use passa a ter efeito (antes eram declaração morta). Slot vazio não altera o HTML.

### Contrato que a S5.1/S5.2 deve reutilizar

- **Registro:** `app(WidgetRegistry::class)->register(X::class)` no `ModuleProvider::register()`;
  `id` namespaceado, `title` i18n, ícone Lucide vendorizado, `slots` ⊆ `LayoutSlot`, `embeddable`.
- **Render por slot:** `render()` devolve `view(...)`; dados resolvidos no render (Clock/locale da
  requisição) e o slot chega como `layoutRegion`. Sem HTML/CSS no banco.
- **Cache:** chave por slot+locale, invalidação síncrona na escrita, estado reavaliado com o Clock
  na leitura; TTL só como rede de segurança.
- **Mídia em lote:** use `GalleryStore::views($ids)` (uma consulta) com
  `GalleryMediaView::sources` — nunca `view()` dentro de loop.

## S5.1 — Fontes reais de widget nos módulos donos (`task_00ihuggp`)

### O que foi implementado

- **Quatro fontes `WidgetSource` nos módulos donos**, registradas em `ModuleProvider::register()`
  via `Core\Contracts\WidgetRegistry` (o perfil de layout referencia o `id`; import entre módulos
  proibido — ADR-013/ADR-016). `destaques.banner` (S4.2) **não** foi duplicado.

  | `id` | Dono | Título (i18n) | Ícone | Slots | Leitura |
  |---|---|---|---|---|---|
  | `noticias.ultimas` | `Publicacoes/Noticias` | `noticias::widget.ultimas` | `lucide-newspaper` | `main.left`, `main.right`, `main.after` | publicadas, `published_at DESC, id DESC` |
  | `noticias.mais-lidas` | `Publicacoes/Noticias` | `noticias::widget.mais_lidas` | `lucide-trending-up` | idem | fallback determinístico por publicação (ver risco) |
  | `dadosabertos.ultimos` | `Portal/DadosAbertos` | `dados_abertos::widget.title` | `lucide-database` | idem | `publicados()`, `published_at DESC, id DESC` |
  | `home.quick-links` | `Portal/Home` | `home::widget.title` | `lucide-link` | idem | porta `Core\Contracts\Navigation` (menu `principal`) |

- **Contrato comum:** `id` namespaceado, `title` i18n (4 locales, `Lang/{locale}/widget.php`),
  ícone Lucide vendorizado, `slots` ⊆ `LayoutSlot` (só as regiões de conteúdo — nunca header/
  footer), `embeddable = true` e `render()` **sem argumentos** devolvendo o fragmento; o feed é
  resolvido no render (locale da requisição), como no banner de destaques (S4.2).
- **`noticias.ultimas`** — `NoticiasWidgetFeed::latest()`: **uma** query pelo escopo único
  `Noticia::publicadas()` (status `publicado`, `published_at` não nulo e já alcançado, sem
  soft-delete), ordem `published_at DESC, id DESC`, recorte de `config('noticias.widget_limit')`
  (default 5, teto 12). A capa vem no próprio `media_data` (jsonb) e é montada pelo partial
  `partials/cover` → `<x-media::picture>` (AVIF/WebP, `width`/`height`, `alt`, `loading="lazy"`):
  **nenhuma** consulta de mídia, logo zero N+1.
- **`noticias.mais-lidas`** — mesma leitura com lista **ordenada** compacta (`<ol>`), recorte
  independente. O ranking empírico não existe: ver *Riscos*.
- **`dadosabertos.ultimos`** — `DadosAbertosWidgetFeed::latest()`: **uma** query por
  `DadosAbertosDataset::publicados()` (status + `published_at` alcançado), ordem determinística,
  recorte de `config('dados_abertos.widget_limit')` (default 5, teto 12). Sem mídia de catálogo.
- **`home.quick-links`** — `HomeQuickLinks` consome **só** a porta `Core\Contracts\Navigation`
  (`tree('principal')`), nunca o módulo `Menus` (ADR-013). Primeiro nível apenas (a árvore completa
  já é o header), item de grupo sem link descartado, limite de `config('home.quick_links_limit')`
  (default 6, teto 12). A árvore é cacheada **pelo dono** (`navigation.principal.{locale}`): um
  widget em N slots não multiplica consultas (2 queries no primeiro render, 0 nos seguintes).
- **Limites são configuração de leitura** (`config/{noticias,dados_abertos,home}.php`), nunca coluna
  (ADR-017); valor inválido (`< 1`, não numérico) cai no default — nunca “sem limite”.
- **HTML/a11y:** `<section>`/`<nav>` com `aria-label` (sem `id` fixo — o widget pode ocupar dois
  slots, WCAG 4.1.1), listas semânticas (`role="list"`), `<h2>` do widget e `<h3>` por item, datas
  por `LocalizedDate`, destino por `route()` (locale da URL), `rel="external noopener"` em link
  externo; sem `<script>`, `style=` ou carrossel.
- **Degradação:** fonte não registrada → slot vazio (o `<x-layout::widget>` não emite wrapper). O
  bloco `latest-news` do catálogo deixou de ser omitido por falta de fonte: agora renderiza
  `noticias.ultimas` e volta a não renderizar quando o registry não resolve a fonte.
- **Fronteiras:** `Home` não importa `Menus`; `Noticias`/`DadosAbertos` não importam outro módulo.
  `tools/arch-check` verde.

### Arquivos

- `config/noticias.php`, `config/dados_abertos.php`, `config/home.php` — limites de leitura.
- `src/modules/Publicacoes/Noticias/Widgets/{NoticiasUltimasWidget,NoticiasMaisLidasWidget}.php`
- `src/modules/Publicacoes/Noticias/Logic/Support/NoticiasWidgetFeed.php`
- `src/modules/Publicacoes/Noticias/Layout/Views/widgets/{noticias-ultimas,noticias-mais-lidas}.blade.php`
- `src/modules/Publicacoes/Noticias/Lang/{pt_BR,en,es,fr}/widget.php`
- `src/modules/Portal/DadosAbertos/Widgets/DadosAbertosUltimosWidget.php`
- `src/modules/Portal/DadosAbertos/Logic/Support/DadosAbertosWidgetFeed.php`
- `src/modules/Portal/DadosAbertos/Layout/Views/widgets/dadosabertos-ultimos.blade.php`
- `src/modules/Portal/DadosAbertos/Lang/{pt_BR,en,es,fr}/widget.php`
- `src/modules/Portal/Home/Widgets/HomeQuickLinksWidget.php`
- `src/modules/Portal/Home/Logic/Support/HomeQuickLinks.php`
- `src/modules/Portal/Home/Layout/Views/widgets/home-quick-links.blade.php`
- `src/modules/Portal/Home/Lang/{pt_BR,en,es,fr}/widget.php`
- `src/modules/{Publicacoes/Noticias,Portal/DadosAbertos,Portal/Home}/ModuleProvider.php` — registro.
- `src/modules/{Publicacoes/Noticias,Portal/DadosAbertos,Portal/Home}/MODULE.md` — contrato.
- `src/modules/Publicacoes/Noticias/Test/NoticiasWidgetsTest.php`
- `src/modules/Portal/DadosAbertos/Test/DadosAbertosWidgetTest.php`
- `src/modules/Portal/Home/Test/HomeQuickLinksWidgetTest.php`
- `tests/Feature/WidgetRegistryTest.php` — expectativas com as fontes novas.
- `tests/Feature/BlocksComponentsTest.php` — `latest-news` com fonte resolvida/não resolvida.
- `src/modules/Portal/Destaques/Test/DestaqueBannerWidgetTest.php` — slot lateral agora tem
  outros widgets (assertiva por `id`).
- `phpstan.neon` — exceção estreita de `deadMethod` para os três feeds consumidos só por Blade
  (mesmo padrão do feed do banner).

### Provas

- `vendor/bin/phpunit --filter='NoticiasWidgetsTest|DadosAbertosWidgetTest|HomeQuickLinksWidgetTest|WidgetRegistryTest'`
  → **25 testes / 241 asserções, OK** (registro/id/título/ícone/slots/embeddable por fonte; limite e
  ordem; só publicadas (rascunho/futura/arquivada/apagada fora); fallback determinístico do
  “mais lidas” em dois requests; **1** query por widget de Notícias (0 de mídia) e **2** renders = 2
  queries de datasets; menu em 2 queries no 1º render e 0 nos seguintes; picture/alt/dimensões;
  cabeçalhos nas 4 locales; fragmento sem script/inline; id não registrado → nada).
- `vendor/bin/phpunit --filter='DestaqueBannerWidgetTest|BlocksComponentsTest'` → **23 testes /
  286 asserções, OK** (banner preservado; `latest-news` com fonte resolvida e sem fonte).
- `vendor/bin/phpstan analyse` (nível max, sem baseline) → **0 erros**; `pint --test` limpo;
  `tools/arch-check`, `tools/name-check.sh`, `tools/migration-check` → OK.
- `make check` → **21 OK · 1 FAIL · 11 SKIP** (127 s): suíte **1 602 testes / 27 987 asserções**
  **verdes**. O único FAIL é o passo `coverage`: o ParaTest mescla o clover **depois** dos testes e
  estoura o `memory_limit` de 256 MiB (`HitMap`/`Unserializer`); uma execução serial com PCOV falha
  no mesmo ponto (`RichTextSchemaTest`), i.e. é o limite deliberado do pool (ADR-020), já registrado
  nas S3.1/S3.2/S4.1/S4.2 — não foi afrouxado. O `coverage-check` leu o clover completo anterior
  (**94,13%**, piso 90%); os arquivos novos desta fatia não entram nesse número enquanto a mesclagem
  não fechar.
- `make check E2E=1` **não** foi rodado: nenhum perfil publicado usa os widgets novos (nenhuma rota
  pública mudou por esta fatia); a prova de slot é integrada e o e2e/axe/perf dos widgets pertencem
  à S6.1, que publica perfis com eles.

### Riscos e observações

- **`noticias.mais-lidas` não tem métrica por artigo.** O agregado do P4 (`Core\Contracts\Metrics`,
  ADR-047) tem dimensão de **rota nomeada** (`noticias.show` agrega todos os artigos) e nunca grava
  path/slug (sem PII); não há sinal por conteúdo. O widget usa o **fallback determinístico por
  `published_at`** previsto no plano em vez de inventar ranking — hoje ele mostra a mesma ordem do
  `noticias.ultimas`, com apresentação própria. Um “mais lidas” empírico exige emenda à ADR-047
  (dimensão de conteúdo) ou um contador por peça com revisão de LGPD; registrado no `kd`.
- **Dono do widget × manifesto (para a S5.2):** o `InMemoryWidgetRegistry` deriva o módulo dono do
  prefixo do `id` por `Str::studly` — `dadosabertos` vira `Dadosabertos`, enquanto o manifesto chama
  o módulo de `DadosAbertos`. Com o módulo **ativo** a fonte resolve normalmente (default fail-open),
  mas o **desligamento** desse widget depende de resolver o prefixo pelo manifesto. O id é o do
  contrato da fase; a correção pertence à S5.2 (dona do comportamento de módulo inativo).
- **Duas fontes do mesmo módulo** (`noticias.ultimas` e `noticias.mais-lidas`) compartilham o mesmo
  feed e a mesma ordem; são dois `id` porque o `latest-news` do catálogo fecha a allowlist em
  `noticias.ultimas` e o posicionamento por slot é independente.
- **Slots de conteúdo:** as três fontes novas declaram `main.left`/`main.right`/`main.after` (nunca
  `main.header`/`main.footer`, reservados a banners editoriais). O CMS só oferece slots canônicos.
- **Limite é de leitura:** nada é gravado no banco; mudar `config('*.widget_limit')` vale no request
  seguinte (sem cache de payload — só o menu tem cache, no dono).
- **Sem E2E nesta fatia:** os widgets ainda não aparecem em rota pública por padrão (nenhum perfil
  publicado os usa); a prova é integrada no slot real do shell. `make check E2E=1` fica para a S6.1,
  que publica perfis com widgets e cobre axe/perf.

### Contrato que a S5.2 deve reutilizar

- **Registro:** `app(WidgetRegistry::class)->register(X::class)` no `ModuleProvider::register()`;
  `id` namespaceado, `title` i18n, ícone Lucide vendorizado, `slots` ⊆ `LayoutSlot`, `embeddable`.
- **Render:** `render()` devolve `view(...)`; o fragmento resolve o feed no render (locale da
  requisição) via `app(XFeed::class)`; nunca consultar no construtor da fonte (congelaria o boot).
- **Módulo inativo:** o filtro já existe no registry (`get()`/`all()`/`forSlot()`), com o dono
  derivado do prefixo do `id`. Para `dadosabertos.*` a derivação precisa considerar o nome do
  manifesto (`DadosAbertos`) — senão o widget sobrevive ao desligamento.
- **Superfície `/cms/layout`:** listar `forSlot()` e resolver por `id`; não criar segunda lista de
  widgets nem importar módulos.
- **Leitura:** uma query por widget, ordem com desempate único, limite por configuração; mídia de
  catálogo sempre em lote (`GalleryStore::views()`); nada de HTML/CSS no banco.


## S5.2 — `/cms/layout` real, módulo inativo e acessibilidade por widget (`task_00ph53v5`)

### O que foi implementado

- **Catálogo do `/cms/layout` derivado do registro** (`Services\Layout\Support\WidgetInventory`):
  percorre os slots canônicos (`LayoutSlot`) e monta uma linha por par `{slot, widget}` a partir de
  `WidgetRegistry::forSlot()` — **não existe segunda lista** de widgets mantida à mão. Cada linha
  traz o `id`, o **título i18n do módulo dono** (a chave é traduzida na view), o ícone Lucide
  vendorizado, o **dono** e `embeddable`. Slot sem widget disponível aparece como vazio (o editor
  não “esconde” o espaço). Um widget novo (ou um módulo desligado) muda o catálogo sem tocar no
  service.
- **Dono resolvido pelo manifesto** (`WidgetRegistry::owner()`): o prefixo namespaceado do `id` é
  confrontado com os módulos descobertos, **sem distinção de caixa** — `dadosabertos` casa com
  `DadosAbertos`, não com o `Str::studly` ingênuo que produzia `Dadosabertos` (dívida registrada na
  S5.1). O contrato do `id` **não mudou**. `owner()` é aditivo na porta do Core e serve só à
  apresentação; a autorização continua sendo do perfil/slot.
- **Módulo inativo não resolve fonte**: `get()`/`all()`/`forSlot()` filtram pelo dono do manifesto
  usando a ativação existente (ADR-034). A resposta de ativação é **memorizada por módulo na
  request** (dois widgets do mesmo dono = uma consulta; zero DB no caminho quente) e cada fonte
  descartada emite **um** `widget.module_inactive` (`{module: core, widget, owner}`) no log
  estruturado. Slot fica vazio, sem 500.
- **Região só abre com colocação renderizável** (`ResolveLayoutPlacements::hasPlacements()`): o
  shell consulta o registry antes de emitir o `<aside>`; perfil com `id` inexistente ou de módulo
  desligado **não** mantém a região aberta — nada de wrapper vazio (o `<x-layout::widget>` já
  ignorava o id; agora o contêiner também). `placements()` continua devolvendo o dado do `jsonb`
  (outros consumidores não mudam).
- **Salvar perfil valida contra o registry** (já existia e segue coberto): `id` precisa estar em
  `all()` (`Rule::in`), o slot precisa ser declarado pela fonte (`LayoutSlots::allows`) e a linha em
  branco é ignorada. Referência por `id`, sem import entre módulos.
- **A11y por widget**: o e2e novo (`30-cms-widgets-slots`) coloca `noticias.ultimas`,
  `noticias.mais-lidas` e `dadosabertos.ultimos` na coluna de uma página e roda **axe** em desktop
  e num contexto `Pixel 7` (mesmo conteúdo público, sem repetir login): um `<h1>`, `<h2>` por
  widget, listas semânticas com itens, links com nome acessível, contraste composto pelo axe. O
  spec pegou um defeito real: **notícia sem capa** deixava o `<a>` do contêiner de capa **sem nome
  acessível** (placeholder decorativo) — corrigido (o `<a>` só existe com `hasCover()`) e travado
  por `NoticiasWidgetsTest::a_coverless_news_never_renders_an_unnamed_link`.
- **Saúde do gate (OOM da cobertura)**: o `coverage` não falhava nos testes — o **runner do
  ParaTest** estourava `memory_limit` ao **mesclar os clovers depois** de todos os workers
  terminarem. Medido em 2026-10-26 com `memory_get_peak_usage(true)` no processo de mesclagem
  (auto_prepend): **286 412 800 B (273 MiB)** de pico para 1 613 testes. O `php.ini` CLI de 256 MiB
  ficava ~17 MiB abaixo e abortava em `HitMap.php` com a suíte verde. Correção:
  `-d memory_limit=512M` **na invocação do paratest** em `tools/coverage-parallel.sh` (~1,9× de
  folga), sem desligar cobertura nem mexer em testes. O **pool FPM continua travado em 256 MiB**
  (`php_admin_value`, ADR-020): é request servido, não ferramenta de build. `CheckPipelineTest`
  cobra o valor explícito.
- **Freio de escrita do CMS por família de rota** (causa real dos 429 intermitentes do `E2E=1`):
  `throttle:{max},{decay}` do Laravel usa a **assinatura do usuário autenticado** como chave, então
  *todas* as rotas do painel dividiam um único balde — o polling de status de mídia
  (`throttle:120,1`, que conta no mesmo balde) consumia a cota e um `POST` legítimo de
  `paginas`/`destaques` recebia **429** (medido: pico de **108 escritas/60 s** para o mesmo admin e
  3 respostas 429 em rotas diferentes). Cada família passou a declarar o **prefixo** do balde
  (`throttle:60,1,cms.paginas`, `cms.destaques`, `cms.home.save`, `cms.home.publish`,
  `cms.media.batch`, `cms.media.status`, `cms.richtext`): o teto de cada rota **não mudou** (nada foi
  afrouxado), só deixou de ser global-per-usuário. `CmsThrottleIsolationTest` é o gate estrutural:
  rota do CMS com `throttle:{max},{decay}` sem prefixo (ou prefixo com orçamentos diferentes)
  quebra o build.
- **Higiene de gate descoberta ao fechar**: o spec 29 acumulava uma peça de destaque **arquivada por
  execução**; a lista de arquivados é ordenada por (`slot`, `position`, `id`) e pagina de 15 em 15,
  então a partir da 16ª execução a peça nova caía na página 2 e a asserção falhava. O spec passou a
  consultar `?state=arquivados&q=<título>` (busca por título já existente), isolando o registro da
  execução sem mudar produto/ordenação.

### Arquivos

- `src/core/Contracts/WidgetRegistry.php` — método `owner()` (dono pelo manifesto).
- `src/core/Internal/Widgets/InMemoryWidgetRegistry.php` — índice de manifestos, ativação memorizada
  por módulo, log `widget.module_inactive`, filtro de inativo sem `Str::studly`.
- `src/services/Layout/Support/WidgetInventory.php` — catálogo por slot derivado de `forSlot()`.
- `src/services/Layout/Http/Controllers/Cms/LayoutProfileIndexController.php` — entrega o catálogo.
- `src/services/Layout/View/cms/index.blade.php` — tabela “Widgets disponíveis” (slot/widget/dono/
  embutível) com placeholder de slot vazio.
- `src/services/Layout/Lang/{pt_BR,en,es,fr}/cms.php` — rótulos do catálogo (paridade de chaves).
- `src/services/Layout/Actions/ResolveLayoutPlacements.php` — `hasPlacements` só com fonte resolvida.
- `src/services/Layout/README.md` — catálogo, dono e colocação renderizável.
- `src/modules/{Portal/DadosAbertos,Portal/Destaques,Publicacoes/Noticias}/MODULE.md` — contrato de
  ativação/catálogo resolvido.
- `src/modules/Publicacoes/Noticias/Layout/Views/widgets/noticias-ultimas.blade.php` — link de capa
  só com capa.
- `tools/coverage-parallel.sh` — `memory_limit` do processo de mesclagem (com a medição no comentário).
- `tests/Feature/CheckPipelineTest.php` — trava o limite explícito da mesclagem.
- `src/modules/Portal/{Home,Paginas,Destaques}/Routes/cms.php`, `src/services/Media/Routes/cms.php`,
  `src/services/RichText/Routes/cms.php` — prefixo do balde de throttle por família (mesmos tetos).
- `tests/Feature/CmsThrottleIsolationTest.php` — nenhuma rota do CMS com balde compartilhado.
- `tests/Feature/WidgetRegistryTest.php` — dono pelo manifesto, módulo multi-palavra inativo, log
  estruturado, sem N+1 de ativação.
- `src/services/Layout/Test/LayoutWidgetsTest.php` — catálogo real, slot vazio, slot/id recusados.
- `tests/Feature/WidgetSlotCompositionTest.php` — notícias na coluna de uma página por `id`, módulo
  desligado → slot vazio sem `<aside>`, `id` inválido ignorado no render.
- `src/modules/Publicacoes/Noticias/Test/NoticiasWidgetsTest.php` — notícia sem capa sem link órfão.
- `src/tests-e2e/tests/30-cms-widgets-slots.spec.ts` + `histories/30-widgets-slots.md` — fluxo real
  no painel e axe desktop/mobile.
- `src/tests-e2e/tests/29-cms-destaques.spec.ts` — busca por título na lista de arquivados.
- `src/tests-e2e/playwright.config.ts` / `README.md`, `wiki/specs/04-acessibilidade-ux.md`,
  `AGENTS.md` — registro do spec 30 e contagem de histórias (30).
- `phpstan.neon` — removidas duas isenções `shipmonk.deadMethod` que deixaram de casar (os métodos de
  `FakeWidgetSource`/`DemoStatusWidget` voltaram a ser vistos como usados); análise fria sem erros.

### Provas

- `vendor/bin/phpunit --filter='WidgetRegistryTest|LayoutWidgetsTest|WidgetSlotCompositionTest|LayoutProfileTest|LayoutAdminTest'`
  → **34 testes / 157 asserções, OK** (dono pelo manifesto, `dadosabertos` inativo, log estruturado,
  catálogo por slot, slot vazio, id/slot recusados, notícia sem capa sem link órfão e composição da
  página por `id`).
- `vendor/bin/phpunit --filter='NoticiasWidgetsTest'` → **8 testes / 108 asserções, OK**.
- Suíte completa: **1 615 testes / 28 127 asserções** verdes; cobertura `src/` **94,17 %**
  (21 148/22 458; piso 90 %), PHPStan max **0 erros** (análise fria), `arch-check`/`name-check`/
  `migration-check` OK, Pint/Prettier limpos.
- `make check` → **22 OK · 0 FAIL · 11 SKIP** (133,9 s), `coverage` **OK** (mesclagem com
  `memory_limit=512M`; antes falhava com 256 MiB).
- `make check E2E=1` → **26 OK · 0 FAIL · 7 SKIP** (392 s): `e2e` **147 passed · 1 skipped**
  (183 s), `a11y` 11 s, `perf` **18 passed** (72 s). O spec novo `30` passa (axe desktop + contexto
  `Pixel 7`), o `29` passa nos dois projetos e **nenhum 429** aparece no log após o isolamento do
  freio. `coverage` 60 s, `coverage-check` **94,17 %**.

### Riscos e observações

- **`owner()` é aditivo na porta do Core**, não um segundo registro: não existe lista paralela e o
  `id` do contrato da fase permanece. O dono é só rótulo de CMS; qualquer decisão de autorização
  continua em `Permission`/perfil.
- **`hasPlacements` mudou de semântica** (dado → disponibilidade): é a correção do wrapper vazio. O
  resolver não conhece módulos — só pergunta ao registry por `id`; `placements()` segue puro para
  quem precisa do dado bruto.
- **Superfície `/cms/layout` não ganhou rota nova**: o catálogo vive na listagem que já exige
  `layout.ver`; salvar continua em `layout.editar`.
- **i18n dos títulos dos widgets**: o catálogo exibe a chave traduzida do dono (`noticias::widget.*`,
  `dados_abertos::widget.title`, …), então uma nova fonte já nasce traduzida nas 4 locales do módulo.
- **E2E 30 usa um escopo exclusivo** (`paginas.portal.show`) e o **desativa** no fim; a página é
  arquivada. A ordenação/paginação de páginas não é afetada.
- **O limite de 512 MiB é do processo de build**; se a suíte crescer ~1,9×, o pico medido precisa ser
  revisto (o comentário do script traz o número e o método).
- **Freio por família de rota** (não global-per-usuário): cada endpoint mantém o **mesmo**
  `max,decay`, mas o teto deixa de ser somado entre formulários diferentes — era o que transformava
  trabalho legítimo (polling de mídia + edição) em 429. Um operador continua limitado a 60/min por
  família; o gate `CmsThrottleIsolationTest` impede a reincidência do balde compartilhado.
- **Acúmulo de dados no e2e** continua sendo um risco sistêmico (peças arquivadas são terminais);
  o spec 29 agora isola a asserção por busca. Uma limpeza periódica do banco demo é backlog de
  operação, não desta fase.
### Contrato que a S6.1 deve reutilizar

- **Perfis publicados** referenciam apenas `id` de fonte registrada; o registry (e o manifesto) é a
  autoridade de disponibilidade — módulo inativo some do catálogo, do formulário e do slot.
- **Catálogo**: `WidgetRegistry::forSlot()` + `Services\Layout\Support\WidgetInventory`; nunca criar
  uma lista de widgets na UI/seed.
- **Região**: o shell colapsa `<aside>`/slots sem colocação renderizável; publicar perfil com
  widgets exige que a fonte esteja ativa no manifesto.
- **A11y**: cada fragmento declara heading próprio (`h2`/`h3`), listas semânticas, links nomeados e
  imagens com `alt`/dimensões; o e2e 30 é a base para cobrir os perfis da home na S6.1.

## S6.1 — E2E, axe desktop/mobile, sniffCSS e performance (`task_0090qw6e`)

### O que foi implementado

- **Auditoria da matriz DoD × prova** e cobertura do que faltava no e2e real (abaixo). Dois specs
  novos no lote **`cms-serial`** (estado global, um worker, antes dos demais via `dependencies`):
  - **31 — composição da home**: `hero` + três seções de texto, **reordenação por teclado**
    (“Subir” com foco + `Enter`, WCAG 2.2 SC 2.5.7), **ocultar sem perder a posição** (a lista
    continua `[hero, Alfa, Gama, Beta]` e o portal publica `[Alfa, Beta]`), **mostrar** devolve na
    mesma posição e a republicação confirma a ordem. Bloco **sem JS** (adiciona e sobe uma seção
    pelo caminho nativo) e bloco **`Pixel 7` com ponteiro real** (abre/fecha o `<dialog>`, salva,
    move e oculta com `.click()` — que só conclui quando nada intercepta o alvo).
  - **32 — banner com vigência, widget no slot e módulo inativo**: três peças no slot
    `main.after` (vigente, `starts_at` em 2030, `ends_at` em 2020) via perfil
    `paginas.portal.show`; só a vigente no portal, `alt` + `width`/`height` da peça, **CLS ≤ 0,1**
    (webfont + dois frames, sem sleep), axe desktop e `Pixel 7`; o painel de suporte **desativa
    `Destaques`** (ADR-034) e a página continua 200 com o slot vazio, sem wrapper órfão; reativar
    devolve o widget. Estado global restaurado no `finally`.
- **Spec 28** ganhou a prova de **sitemap** (índice `/sitemap.xml` anuncia `sitemap-paginas.xml`;
  a URL publicada entra no `urlset` e sai no request seguinte ao arquivamento, com `?e2e=` para
  não ler cache HTTP) e a prova de **rascunho sem URL pública** (404 real + `X-Robots-Tag`).
- **Specs 27/28** passaram a acionar criação/publicação/arquivamento por **ponteiro** (não mais
  foco+Enter): é a prova direta do risco registrado na S3.1/S3.2 (clique interceptado no mobile).
- **Spec 16** passou a medir overflow horizontal pelo **`clientWidth`** (o `innerWidth` “cresce”
  junto com o zoom-out do Chromium móvel e mascarava o defeito) em quatro telas do painel.
- **Helpers compartilhados** `tests/support/home.ts` (documento/edição/reordenação) e
  `tests/support/media.ts` (upload do catálogo); specs 26 e 29 refatorados para eles — sem cópia
  de fluxo entre histórias.
- **Defeito real corrigido (vermelho→verde)**: o cabeçalho do CMS não quebrava linha
  (`min-content ≈ 575 px` em 390–412 px) e a listagem de `/layout` era uma tabela crua sem
  contêiner rolável. O Chromium móvel ampliava o **layout viewport** (zoom-out) e o ponteiro caía
  no fieldset de SEO em vez do botão de salvar. Correção: `flex-wrap` no `.cms-header` e no bloco
  de usuário; as duas tabelas do `/layout` no `x-cms::table` (rolagem + `tabindex`). Gate de
  regressão: spec 16 — **vermelho** com o CSS antigo (`clientWidth 412` × `scrollWidth 575`),
  **verde** com o novo. Nenhum comportamento de produto foi afrouxado.
- **Higiene de dados do e2e**: peças do spec 32 usam prefixo `E2E …`, resíduos **ativos** de
  execuções interrompidas são desativados no início (preservando o registro — ADR-019) e as peças
  criadas são arquivadas no `finally`; o spec 31 limpa o rascunho antes de compor e deixa a home
  publicada válida (um `<h1>` do `hero`).

### Matriz de cobertura (DoD/plano S6.1 → prova)

| Item | Prova |
|---|---|
| Reordenar seção da home → portal reflete | e2e **31** (teclado) + `HomeModuleTest`/`BlocksRendererTest` |
| Ocultar → some sem perder a posição | e2e **31** (`Ocultar`/`Mostrar` + ordem na lista e no portal) + `ParseHomepageTest` |
| Bloco inválido ignorado | `ParseHomepageTest` (props inválidas/marcação), `BlocksSchemaTest`, `HomeModuleTest` (documento publicado inválido não renderiza); **sem caminho de escrita pela UI** (schema fechado) |
| Versão errada → default | `HomeModuleTest::an_invalid_stored_document_falls_back_to_the_default_home` (HTTP real) + `ParseHomepageTest` |
| Página criada/publicada → URL + sitemap + canonical/hreflang | e2e **28** (200, canônica, `x-default`, `/sitemap-paginas.xml` e índice) |
| Rascunho/arquivada → 404 noindex | e2e **28** (rascunho e arquivada, `X-Robots-Tag`) + `PaginaPortalTest` |
| Rota viva não sombreada | e2e **28** (`/noticias` 200) + `PaginaPortalTest`/`PaginaSlugTest` |
| Preview autenticado (home e página) | e2e **26** e **28** (`noindex`/`nofollow`, sem canônica) |
| Banner com vigência (vigente/futuro/expirado) e `alt` | e2e **32** + `DestaqueWindowTest`/`DestaqueBannerWidgetTest` |
| Widget real no slot | e2e **30** (notícias/datasets) + e2e **32** (`destaques.banner`) |
| Módulo inativo → slot vazio | e2e **32** (toggle no painel de suporte) + `WidgetSlotCompositionTest`/`DestaqueBannerWidgetTest` |
| Editor da home/páginas no mobile sem sobreposição/interceptação | e2e **31** (Pixel 7, ponteiro), **27/28** (ponteiro) e regressão de overflow no **16** |
| Teclado/reorder e no-JS | e2e **31** (teclado e bloco sem JS) + **25/27** (formulários nativos) |
| axe zero A/AA | specs 04/05/07/08/16–32; novos: **31** e **32** em desktop e `Pixel 7` |
| CLS ≤ 0,1 e sem recurso bloqueante | e2e **11** (home/notícias) + **32** (página com banner) |
| Budget de bytes | `PerformanceBudgetTest` (`.br`/`.gz`, MTIME 0, entradas) |
| Custo de banco/N+1 | `PerformanceBudgetTest` (home/listagem) + `DestaqueBannerWidgetTest` (1 query de peças + 1 de mídia; 0/0 no request quente) + `WidgetSlotCompositionTest` |

### Arquivos

- `src/tests-e2e/tests/31-cms-home-composicao.spec.ts` · `histories/31-home-composicao.md`
- `src/tests-e2e/tests/32-cms-banner-vigencia.spec.ts` · `histories/32-banner-vigencia-modulo-inativo.md`
- `src/tests-e2e/tests/support/home.ts` · `src/tests-e2e/tests/support/media.ts`
- `src/tests-e2e/tests/26-cms-publicacao-home.spec.ts` · `29-cms-destaques.spec.ts` (helpers)
- `src/tests-e2e/tests/27-cms-paginas.spec.ts` · `28-cms-paginas-portal.spec.ts` (ponteiro + sitemap/rascunho)
- `src/tests-e2e/tests/16-cms-mobile.spec.ts` (overflow por `clientWidth` em 4 telas)
- `src/tests-e2e/playwright.config.ts` (31/32 no `cms-serial`) · `src/tests-e2e/README.md`
- `src/layout/Cms/Assets/css/shell.css` (cabeçalho quebra linha)
- `src/services/Layout/View/cms/index.blade.php` (tabelas no `x-cms::table`)
- `wiki/specs/04-acessibilidade-ux.md` (histórias 31/32; projetos e lote serial)
- `AGENTS.md` (contagem de histórias e2e)

### Provas

- `make check` → **22 OK · 0 FAIL · 11 SKIP** (133,6 s; `e2e`/`perf` são os SKIPs do opt-in);
  suíte **1 615 testes / 28 149 asserções**; cobertura `src/` **94,17 %** (21 148/22 458; piso
  90 %); `a11y` sniffCSS **estrito** desktop + mobile + login do painel **OK** (`fail=0`; `warn`
  isentos pelo ledger §6 do spec 04 apenas).
- `make check E2E=1` → **24 OK · 0 FAIL · 9 SKIP** (495 s): `e2e` **151 passed + 1 skip**
  condicional (241 s), `a11y` sniffCSS estrito 12 s, `perf` **22 passed** (119 s) — **errata
  P5.FIX:** só 6 são `11-performance`; 16 eram o lote `cms-serial` reexecutado pelas `dependencies`
  do Playwright. O `perf` agora roda com `--no-deps` (medido: ver `## P5.FIX`),
  `coverage` 63 s e `coverage-check` **94,17 %**. A primeira rodada da fatia pegou uma falha real de
  isolamento entre specs (`32` deixava `destaques.banner` na linha 0 do perfil, escondendo
  `main.right` do formulário da `30`): o perfil agora é devolvido **inativo e sem colocações** no
  `finally` e a `30` volta a passar.
- Execuções dirigidas: `31` **3 verdes** (25,4 s), `32` **verde** (22,1 s), `27/28` no `cms-mobile`
  por ponteiro **3 verdes**, `16` **vermelho** com o CSS antigo e **verde** depois (4,3 s).

### Riscos e observações

- **`innerWidth` engana no mobile**: quando o conteúdo estoura, o Chromium móvel amplia o layout
  viewport e `window.innerWidth` cresce junto; a medida de overflow tem de ser
  `scrollWidth × clientWidth` (foi o que escondeu o defeito do cabeçalho até esta fatia).
- **Cache HTTP de 300 s** (`StripPublicCookies`, ADR-022): depois de desativar módulo/arquivar, o
  navegador pode servir o corpo anterior sem revalidar. O e2e usa URL nova (`?e2e=…-off`); a
  invalidação de borda continua fora desta fase (mesmo risco registrado na S3.2).
- **Cache de render de página × ativação de módulo**: o corpo público é cacheado por página+locale
  (dados, não HTML) e a ativação de módulo não o invalida; o widget deixa de renderizar no request
  novo (registry consultado no render), mas uma página já cacheada por TTL pode manter a marcação
  até 300 s. Mesma classe do risco de cache da S3.2/S4.2; sem FPC nesta fase.
- **Lote serial cresceu** (~+45 s: dois arquivos e um teste sem JS). O e2e completo ficou em
  **4,0 min**; `cms-desktop`/`cms-mobile` continuam dependentes do serial. O teste móvel da home roda
  no próprio arquivo (`test.use` sem `defaultBrowserType`) para não criar um worker novo.
- **Peças arquivadas são terminais**: o spec 32 desativa resíduos ativos (não os apaga) e arquiva
  as próprias peças; a limpeza periódica do banco demo segue como backlog de operação.
- **`x-cms::table` no `/layout`** muda a aparência da listagem (listras/`w-full`), mantendo
  `caption`/`scope`; é a forma canônica do painel para tabela que rola no mobile.

## S6.2 — Fechamento: documentação, 2× check, E2E=1 e ficha de revisão (`task_0083fxz4`)

### O que foi entregue

- **Ficha de revisão da fase** (este arquivo): cabeçalho com fase/épico/12 tasks/âncoras/status,
  resumo consolidado S1.1→S6.2, superfícies a revisar, checklist **D1–D14** com evidência, riscos
  residuais e `Notas da revisão` vazia (a revisão profunda preenche). Não houve mudança de código
  nesta fatia — só documentação e a execução dos gates.
- **Fila e inventário**: linha `86 | P5` em `_to_revision/README.md` apontando
  `P5-composicao-site.md` com status `🟡`, e a seção “P5 — composição do site” em
  `_to_revision/_inventario.md` com os arquivos por fatia.
- **Estado da fase**: `wiki/implementation-phases/P5-composicao-site.md` e `README.md` da pasta
  passam a `em revisão`; `wiki/implementation-phases/INVENTARIO.md` move landing/páginas/banners/
  widgets e a capacidade “Estrutura geral do site e do CMS” para **FEITO (P5, em revisão)**.
- **ADR-016 conferida**: as duas emendas de 2026-10-25 (catálogo de 12 blocos; locales/publicação/
  prévia) estão registradas no arquivo, com as mesmas regras que o código executa (schema fechado,
  `hidden` preserva posição, `version` desconhecida → default, proibições de HTML/Blade/CSS,
  publicação só pelo canônico validado). `docs-check` verde → nenhuma doc de `wiki/specs` stale.
- **Épico mantido aberto**: `epic_00xjhe75` **não** foi fechado e a fase **não** foi marcada
  `revisado` — a revisão profunda ainda vai rodar.

### Provas — `make check` 2× + `make check E2E=1` (2026-10-26)

| Execução | Resultado | Tempo | Suíte | Cobertura `src/` |
|---|---|---|---|---|
| `make check` **#1** | **22 OK · 0 FAIL · 11 SKIP** | **130,29 s** | 1 615 testes / 28 149 asserções | **94,17 %** (21 148/22 458; piso 90 %) |
| `make check` **#2** | **22 OK · 0 FAIL · 11 SKIP** | **132,78 s** | 1 615 testes / 28 149 asserções | **94,17 %** (21 148/22 458) |
| `make check E2E=1` | **24 OK · 0 FAIL · 9 SKIP** | **492,01 s** | 1 615 testes + **151 passed · 1 skipped** de Playwright | **94,17 %** |

- **SKIPs** são os passos caros já frescos (`prep:*`, `upload-chain`, `optimize-verify`) e o
  `e2e`/`perf` opt-in na execução sem `E2E=1` — nenhum gate foi desligado.
- **Sem flake**: as duas execuções de `make check` deram exatamente o mesmo resultado e a mesma
  contagem; `coverage` fechou OK nas duas (mesclagem com `memory_limit=512M` do processo de build,
  ver S5.2) e `a11y` sniffCSS estrito passou em ambas.
- **`make check E2E=1` por passo**: `e2e` **238 s** (151 passed · 1 skipped — o skip é o
  condicional já conhecido; nenhum 429 e nenhuma retentativa), `a11y` sniffCSS estrito **12 s**,
  `perf` **22 passed** (**122 s**), `coverage` 62 s, `coverage-check` 94,17 %. `perf` cresceu de 18
  para 22 testes com os cenários de composição/vigência da S6.1 — custo declarado e medido, coberto
  pela decisão de medir antes de otimizar.
- **Passos dentro do alvo do contrato de ciclo** (ADR-001/ADR-038): `coverage` 60–62 s,
  `check-gates` 27–28 s, `a11y` 12 s, `network-check` 8 s; o resto ≤ 6 s.
- `docs-check` OK nas duas execuções (0–1 s) — emendas da ADR-016, `MODULE.md`, specs e índices
  consistentes.

### Arquivos

- `_to_revision/P5-composicao-site.md` — ficha de revisão da fase (status, resumo, D1–D14, riscos).
- `_to_revision/README.md` — linha **86** da fila F7+ (`P5`, `epic_00xjhe75`, `🟡`).
- `_to_revision/_inventario.md` — seção “P5 — composição do site (fase S1.1→S6.2)”.
- `wiki/implementation-phases/P5-composicao-site.md` — `Status: em revisão`.
- `wiki/implementation-phases/README.md` — §8: `P5 = em revisão` com a ficha.
- `wiki/implementation-phases/INVENTARIO.md` — linhas de landing/páginas/banners/widgets e a
  capacidade “Estrutura geral do site e do CMS” → **FEITO (P5)**; §3.3 com as entregas da fase.

### Riscos e observações

- **Documentação não é gate de código:** `docs-check` cobre apenas `wiki/specs`; os links internos
  desta ficha e da fila não são verificados automaticamente — a revisão deve abrir os caminhos
  listados em “Superfícies a revisar”.
- **Contagem de histórias e2e = 32** (`AGENTS.md`): os specs 31/32 entraram na S6.1; qualquer
  spec novo exige atualizar o `AGENTS.md`, o `README.md` do e2e e a spec 04.
- **Números são de uma máquina/um dia** (2026-10-26): o pico de memória da mesclagem da cobertura
  (273 MiB medidos na S5.2) é o que justifica o `512M`; se a suíte crescer ~1,9×, revisar.

## Ficha de revisão — resumo consolidado (S1.1 → S6.2)

> Esta é a **ficha de revisão da fase P5**. As 12 sub-fatias formam um bloco único: o catálogo de
> blocos e o renderizador só fazem sentido com Home + Páginas + Destaques + widgets no mesmo
> review. O épico `epic_00xjhe75` continua **aberto** até a revisão profunda fechar.

| Fatia | Entrega em uma linha | `kd` | Prova principal |
|---|---|---|---|
| **S1.1** | Emenda ADR-016 + catálogo fechado de 12 blocos (`Layout\Blocks`), schema de `props`, 12 componentes Blade, i18n | `task_00ect94a` | `BlocksCatalogTest` / `BlocksSchemaTest` (53 testes) |
| **S1.2** | `ParseHomepage` (puro), default da home, renderizador único `<x-layout::blocks>`, cutover da rota | `task_00aua4t1` | `ParseHomepageTest` (21) · `BlocksRendererTest` (3) |
| **S2.1** | Editor da home (`/cms/home`): rascunho `jsonb`, permissões, `<dialog>` nativo, validação por campo, 409 | `task_00foju6c` | `HomeEditorCmsTest` (20) · `BlocksEditorValidationTest` (7) |
| **S2.2** | Publicação versionada, prévia autenticada, cache versionado, auditoria e locales do conteúdo | `task_01694hou` | `HomePublishPreviewCmsTest` (13) |
| **S3.1** | Módulo `Portal/Paginas`: tabela `paginas.pages`, slug sem colisão com rota viva, AST validado, CMS | `task_00nz5zd2` | `PaginaCmsTest`/`PaginaSchemaTest`/`PaginaSlugTest` (33) |
| **S3.2** | Fallback de área, render público, SEO/sitemap, cache, prévia e transições (`409` fora da matriz) | `task_01lsnppn` | `PaginaPortalTest` (9) |
| **S4.1** | Módulo `Portal/Destaques`: schema, `DestaqueLink`/`DestaqueWindow` (Clock), CMS com ativar/arquivar | `task_00gqf6mq` | `DestaquesCmsTest` + 3 (33) |
| **S4.2** | Fonte `destaques.banner`, leitura sem N+1, cache por slot clock-aware, limite de leitura | `task_01rdz39n` | `DestaqueBannerWidgetTest` (12) |
| **S5.1** | Quatro fontes de widget nos módulos donos (`noticias.ultimas`, `noticias.mais-lidas`, `dadosabertos.ultimos`, `home.quick-links`) | `task_00ihuggp` | `NoticiasWidgetsTest`/`DadosAbertosWidgetTest`/`HomeQuickLinksWidgetTest` (25) |
| **S5.2** | `/cms/layout` derivado do registry, dono pelo manifesto, módulo inativo sem fonte, freio por família, OOM da cobertura | `task_00ph53v5` | `WidgetRegistryTest`/`LayoutWidgetsTest`/`WidgetSlotCompositionTest` · e2e **30** |
| **S6.1** | E2E de composição/vigência/módulo inativo, axe desktop+mobile, sniffCSS estrito, perf; correção real do overflow do painel | `task_0090qw6e` | e2e **31**/**32** · spec **16** vermelho→verde |
| **S6.2** | Documentação, `make check` 2× + `E2E=1`, ficha de revisão e fila | `task_0083fxz4` | seção **S6.2** (fechamento) |

**Resultado observável da fase (o que o operador faz hoje):** reordena/oculta uma seção da home e
vê a home publicada na ordem certa em `/`, `/en`, `/es`, `/fr`; cria uma página “Sobre”, publica e
recebe URL + sitemap + canônica, com rascunho/arquivada em 404 `noindex`; agenda um destaque com
vigência e o vê no slot `main.after`; liga/desliga um módulo dono de widget e o slot some sem 500.
Um bloco inválido é **ignorado**, nunca renderizado meio bloco.

## Superfícies a revisar (o que abrir)

> Leitura obrigatória: `PROTOCOLO.md` §7 (D1–D14) e §10 (gates). Cada caminho abaixo existe no
> repositório; a lista por arquivo está em `_to_revision/_inventario.md` §"P5 — composição do site".

| Superfície | Caminho | Por que revisar |
|---|---|---|
| **Schema/catálogo de blocos** | `src/layout/Blocks/**` | fronteira entre “dado” e “apresentação”: proibição de HTML/CSS, `hidden`, `version`, `props` fechadas, localização |
| **Componentes de bloco** | `src/layout/Components/blocks.blade.php`, `src/layout/Components/blocks/*` | um `<h1>`, headings em ordem, degradação sem JS, mídia por `<x-media::picture>` |
| **Home (módulo)** | `src/modules/Portal/Home/**` | rascunho × publicado, `lock_version`, permissões, cache versionado, prévia, auditoria |
| **Páginas (módulo)** | `src/modules/Portal/Paginas/**` | slug × rota viva, fallback de área, transições, sitemap, cache, 404 `noindex` |
| **Destaques (módulo)** | `src/modules/Portal/Destaques/**` | vigência com `Clock`, `alt`, link seguro, cache por slot, limite de leitura |
| **Widgets** | `src/core/Contracts/WidgetRegistry.php`, `src/core/Internal/Widgets/**`, `src/services/Layout/**`, `src/modules/*/**/Widgets/**` | dono pelo manifesto, módulo inativo, N+1, catálogo derivado, região vazia |
| **Roteamento/SEO** | `src/core/Internal/Routing/AreaRegistrar.php`, `src/core/Enums/PlatformArea.php`, `src/services/Seo/**` | fallback depois das rotas vivas, redirect legado, sitemap só portal |
| **Gateway de escrita do CMS** | `src/modules/Portal/*/Routes/cms.php`, `src/services/{Media,RichText}/Routes/cms.php` | `throttle` por família, `POST`+CSRF, `403` antes de `422/409` |
| **Mídia** | `src/services/Gallery/{Contracts/GalleryStore.php,GalleryService.php,DTOs/GalleryMediaView.php}` | `views()` em lote, `sources`, mídia ausente degrada |
| **E2E/axe/perf** | `src/tests-e2e/tests/{16,25,26,27,28,29,30,31,32}-*.spec.ts`, `tests/support/*` | isolamento entre specs, estado global em `cms-serial`, axe desktop/`Pixel 7`, CLS |
| **Docs/normativo** | `wiki/adr/ADR-016-tema-layout-como-dado.md` (2 emendas de 2026-10-25), `src/modules/*/MODULE.md`, `src/services/Layout/README.md`, `src/services/Seo/README.md`, `wiki/specs/04-acessibilidade-ux.md` | contrato documentado = contrato executado |

## Checklist D1–D14 aplicável (com evidência)

> Só as dimensões que a fase **toca**; cada linha traz o que abrir e o gate/teste que sustenta.

- **D1 — Simplicidade & PHP 8.4.** `declare(strict_types=1)` em todo arquivo novo; classes
  `final` (grep: nenhum `trait`/`abstract class` em `src/layout/Blocks`, `Portal/{Home,Paginas,
  Destaques}`); DI no construtor (Actions `PublishHome`, `SavePagina`, `TransitionPagina`,
  `SaveDestaque`); enums fechados (`BlockType`, `PaginaStatus`, `DestaqueSlot`, `DestaqueState`);
  `match` nas transições/estado; DTOs `final readonly` (`PaginaData`, `DestaqueData`,
  `DestaqueBannerItem`). Evidência: `static` (PHPStan `max` 0) e `lint-php`.
- **D2 — Laravel 12.** Rotas em `Routes/{area}.php` sem closure (todas apontam para controller
  invocável); `FormRequest` + `validated()`; `bootstrap/app.php` intocado; migrações idempotentes
  (`Schema::hasTable`/`IF NOT EXISTS`) com `down()` renomeando para `backup_*`; `lockForUpdate`
  dentro da transação + `lock_version`; `throttle:{max},{decay},{bucket}`. Evidência:
  `check-migrations`, `optimize-check` (route/config cache), `CheckPipelineTest`.
- **D3 — Determinismo.** `Clock` injetado em `DestaqueWindow`/`TransitionPagina`
  (`published_at`); ordem explícita com desempate único (`position, id`; `published_at DESC, id
  DESC`; `slot, position, id`); cache com chave de versão (`home.published.{lock_version}`) e
  invalidação síncrona; vigência reavaliada na leitura (TTL não é garantia); testes injetam
  `FixedClock` em `Clock` (não existe `freezeTime` no projeto). Evidência: `make check` **2×**
  verde; `DestaqueWindowTest`, `DestaqueBannerWidgetTest`, `DestaquesCmsTest`.
- **D4 — Fronteiras (DDD-lite).** `Layout\Blocks`/`src/layout` não importa módulo;
  `Portal/Home`, `Portal/Paginas`, `Portal/Destaques`, `Publicacoes/Noticias`, `Portal/DadosAbertos`
  não se importam (widget por `id` no `WidgetRegistry`, menu pela porta `Navigation`, mídia por
  `GalleryStore`). Evidência: `check-boundaries` (`tools/arch-check`) verde.
- **D5 — Dependências/licenças/CDN.** Nenhuma dependência nova na fase; nenhum asset externo
  (zero CDN); ícones Lucide vendorizados; sem `iframe`. Evidência: `check-vendor`, `audit`, `sbom`.
- **D6 — CSS/Tailwind/daisyUI.** Componentes usam tokens/classes existentes (`text-civic-*`,
  `bg-surface`, `border-civic-border`, `prose`), sem `style=`, sem `!important`, sem `px`; o JS de
  progressão (`home-editor.js`, 0,6 kB) e o `@media (scripting: none)` vivem no bundle/`@layer`.
  Evidência: `check-gates` (`DesignTokensTest`), `lint-js`, `PerformanceBudgetTest`.
- **D7 — HTML semântico & a11y (eMAG/WCAG 2.2 AA).** Um `<h1>` por página (só o `hero`);
  `aria-labelledby` por seção; `<details>` no FAQ; `<dialog>` nativo sem `<form>` aninhado;
  reordenar/ocultar por **botão** (SC 2.5.7, sem arrastar); tudo funciona **sem JS**; `alt`
  obrigatório e `width`/`height` em toda mídia. Evidência: axe nos specs **25–32** (desktop e
  `Pixel 7`), `a11y` sniffCSS **estrito** verde, `BlocksComponentsTest`.
- **D8 — i18n.** 4 locales com paridade (`layout::blocks`, `home::cms`, `paginas::{index,cms}`,
  `destaques::{index,menu,cms,widget}`, `*::widget`); locale pela URL; conteúdo por locale em
  `jsonb` (`LocaleCast` para AST, `LocalizedTextCast` para texto, `BlockLocalizer` no render);
  `route()`/`localized_url()` na navegação; datas por `LocalizedDate`. Evidência:
  `ModuleI18nTest`, `LocaleUrlInvariantTest`, testes de 4 locales por fatia.
- **D9 — Grep-friendly (ADR-004).** Tokens `home`, `paginas`, `destaques`, `blocks` derivam
  arquivos/rotas/tabelas/eventos; rotas seguem `{token}.{area}.{action}`. Evidência:
  `check-boundaries` → `tools/name-check` + `Adr004GrepFriendlyTest`.
- **D10 — Dados (PG 18).** `uuidv7()`, `jsonb` com `CHECK (jsonb_typeof = 'object')`, `timestamptz`,
  `text`; índices únicos como contrato (`home.home_documents(variant)`, `lower(slug)`), CHECKs de
  estado/janela/slug; `down()` preserva em `backup_*`; **nada de HTML/CSS no banco** (o schema
  fechado recusa chave proibida no documento armazenado). Evidência: `check-migrations`,
  `PaginaSchemaTest`/`DestaquesSchemaTest` (CHECKs, `down()`).
- **D11 — Segurança/LGPD.** Deny-by-default por manifesto (`home.{ver,editar,aprovar}`,
  `paginas.{ver,editar,arquivar}`, `destaques.{ver,editar,arquivar}`); `403` antes de `422/409`;
  mutação só `POST`/`PUT` com CSRF; freio por família de rota; área restrita invisível
  (`X-Robots-Tag`, sitemap só portal, prévia `noindex`); link de destaque limitado a caminho
  same-origin/`https`; sem PII em log/auditoria. Evidência: `CmsThrottleIsolationTest`,
  `DestaqueLinkTest`, `PaginaSlugTest` (rota viva), `security-check`, `check-gates`.
- **D12 — Observabilidade/auditoria.** Span + `log_event` por operação (`home.save_draft`,
  `home.publish`, `paginas.save|publish|unpublish|archive`, `destaques.save|activate|deactivate|
  archive`, `widget.module_inactive`), atributos escalares sem PII; evidência persistida via
  `AuditWriter` (`home.published`, `paginas.published|unpublished|archived`,
  `destaques.activated|deactivated|archived`) com ator snapshot. Evidência: testes de cada fatia
  (auditoria + `log_event`).
- **D13 — Testes/evidência.** 1 615 testes / 28 149 asserções, cobertura `src/` **94,17 %**
  (21 148/22 458; piso 90 %); E2E **151 passed + 1 skip**; `perf` **22 passed**; `make check`
  2× + `E2E=1` verdes. Evidência: seção **S6.2**.
- **D14 — Documentação.** Emendas ADR-016 (2), `MODULE.md` de Home/Paginas/Destaques,
  `README.md` de Layout/Seo/Área Portal, `wiki/specs/04-acessibilidade-ux.md`, `AGENTS.md`
  (histórias = 32), `INVENTARIO.md`/`README.md` da fase, esta ficha + fila + inventário.
  Evidência: `docs-check` verde — **escopo real:** cabeçalhos e índice de `wiki/specs`; emendas da
  ADR-016, `MODULE.md` e os caminhos desta ficha foram conferidos por leitura, não pelo gate.

## P5.FIX — correções da 1ª rodada (commit `e8a7311`, `task_010zonqz`)

> Rodada paralela por recortes; as correções ficaram no commit do mantenedor. Cada item tem teste
> de contrato no arquivo indicado (vermelho antes da correção).

| # | Sev. | Achado | Correção | Teste |
|---|---|---|---|---|
| 1 | alto | publicação/prévia aceitavam composição com **0 ou 2+ `hero`** (0/2 `<h1>`) | `BlocksDocument::visibleHeroCount()`; publicar exige exatamente 1, rascunho aceita ≤1, leitura/prévia caem no default | `HomePublishPreviewCmsTest` |
| 2 | alto | `route` de bloco só validava o **nome**: `noticias.show` (parâmetro obrigatório) dava **500** e `home.cms.index` vazava o domínio do painel | `BlockRoute` (rota viva do portal, gerável sem parâmetro, fora de `/cms`/`/api`), usada na gravação e no render | `BlocksSchemaTest`, `BlocksComponentsTest` |
| 3 | alto | cache de render da página (chave por locale) era preenchido pelo locale da request | `LocaleCast::resolve($value, $locale)` + `PaginaRenderer` resolvendo pelo locale da chave | `PaginaPortalTest` |
| 4 | alto | cache do banner resolvia o título pelo locale da request, não da chave | `LocalizedTextCast::resolve(..., $locale)` no feed | `DestaqueBannerWidgetTest` |
| 5 | alto | limpar um locale no CMS **preservava** a tradução antiga (título/conteúdo/SEO/destaques) | locale esvaziado permanece no mapa como sinal de remoção (request/DTO/cast) | `PaginaCmsTest`, `DestaquesCmsTest`, `LocalizedTextCastTest` |
| 6 | alto | qualquer link TipTap (`href`/`target`/`class`) invadia a varredura de chave proibida → AST recusado | recorte de `marks` no walk (mantendo profundidade/nós/chaves proibidas) | `PaginaSchemaTest` |
| 7 | médio | página arquivada podia ser **renomeada** (reciclagem de URL) | slug de arquivada imutável (422 no campo) | `PaginaSlugTest` |
| 8 | médio | `heading` com nível inválido virava `<h1>` na página | demotion com piso 2 | `PaginaPortalTest` |
| 9 | médio | `position` inválida em destaque caía em “próxima do slot” | 422 no campo (a normalização preserva o valor para a regra) | `DestaquesCmsTest` |
| 10 | médio | campos de título por locale não recuperavam `old()`/erro (`title[pt_BR]` literal) | ids/nomes por locale, erro e valor no campo certo, `required` só no primário | `DestaquesCmsTest` |
| 11 | médio | cabeçalho da lista de destaques não batia com as células (WCAG 1.3.1) | tabela com uma célula por cabeçalho | `DestaquesCmsTest` |
| 12 | médio | `SetDestaqueActive` com model velho duplicava evidência | decisão por `wasChanged('active')` sob trava | `DestaquesCmsTest` |
| 13 | médio | escrita do `/cms/layout` respondia **422 antes de 403** | autorização no `FormRequest` | `LayoutAdminTest` |
| 14 | médio | `og:url` era emitido sem canônica (prévia/404 da área restrita) | sem canônica não há `og:url` | `HomePublishPreviewCmsTest` |
| 15 | médio | `RedirectLegacy` tratava `{locale?}` como curinga e sombreava `/` e `/en/noticias` | só o primeiro segmento **obrigatório** decide rota viva | `SeoRedirectTest` |
| 16 | baixo | `perf` reexecutava o lote serial via `dependencies` | `--no-deps` | `CheckPipelineTest` |
| 17 | baixo | `PlatformArea::shouldIndex()` morto e ignores de PHPStan obsoletos | removidos | `static` |
| 18 | baixo | balde de `throttle` compartilhado por usuário entre rotas do CMS | prefixo por família (`cms.{paginas,destaques,…}`), tetos inalterados | `CmsThrottleIsolationTest` |

## P5.FIX2 — correções da 2ª rodada (revisão profunda, 2026-10-26)

> Fechamento da revisão interrompida na 1ª rodada. Recortes: blocos/Home/Páginas/Destaques/
> widgets/e2e/docs. Cada achado tem teste vermelho→verde no arquivo indicado.

| # | Sev. | Achado | Correção | Teste |
|---|---|---|---|---|
| A | **alto** | **mídia dos blocos nunca era resolvida**: o `jsonb` guarda id Media, mas Home/Páginas renderizavam com `media=[]` — `banner` (imagem obrigatória) **desaparecia** e `text-image`/`highlights`/`video` saíam sem imagem, sem aviso | `BlockMediaResolver` resolve o documento em **uma** consulta (`GalleryStore::views()`) e entrega mapa **por seção → caminho**; Home pública e prévia passam a mídia; o renderizador indexa por `id` da seção (duas seções com `image` não colidem) | `BlockMediaTest` (render, seção oculta, 1 query, prévia) |
| B | **alto** | `heading` com `level: 1` no **AST de um bloco** emitia um segundo `<h1>` (a guarda contava só blocos `hero`) | `BlockField` satura o nível em `[2,6]` na gravação/leitura; `ContentASTParser` trata heading sem `level` como `<h2>` (nunca `<h1>` acidental) | `BlocksSchemaTest`, `BlocksRendererTest` |
| C | médio | página com `heading` **sem `attrs`** também emitia `<h1>`; o `+1` cego gerava salto h1→h3 | demotion cobre `heading` sem `attrs` e **não sobe** nível (h2 continua h2; só satura abaixo de 2) | `PaginaPortalTest` |
| D | médio | **Enter** num campo do editor da home disparava `move-down`/`hide` (o primeiro submit era a ação estrutural) | a barra de **Salvar** é o primeiro submit do formulário (repetida no rodapé) | `HomeEditorCmsTest` |
| E | médio | erro de seção não dizia **qual seção**; o foco ia para `aria-invalid` dentro de `<dialog>` fechado (no-op) | mensagens prefixadas com “Seção N (Tipo)” nas 4 locales; o JS do painel abre o diálogo ancestral antes do foco | `HomeEditorCmsTest` |
| F | médio | `Destaques` aceitava **PDF/documento** do catálogo como imagem → `<img>` quebrado sem medidas no portal | 422 `image_id` para `mime` fora de `image/*`; o feed também descarta mídia não-imagem (dado antigo) | `DestaquesCmsTest` |
| G | médio | `<aside>`/coluna do grid abriam com widget cujo feed estava **vazio** (landmark vazio + buraco no grid) | o shell renderiza o slot **uma vez** e só emite a região com HTML; os parciais `main-left`/`main-right` (agora mortos) saíram | `WidgetSlotCompositionTest` |
| H | médio | axe nunca rodava com o `<dialog>` do editor **aberto** (história 25 prometia) | `expectNoA11yViolations` no estado aberto, antes de fechar | `25-cms-editor-home.spec.ts` |
| I | médio | spec 30 mutava o perfil **global** `paginas.portal.show` no lote paralelo (o 32 usa o mesmo escopo) | 30 passou ao `cms-serial` (um worker, antes dos demais); continua medindo desktop + `Pixel 7` no próprio spec | `playwright.config.ts`, README |
| J | baixo | teste anti-id-duplicado passava **vazio** para as fontes novas (nenhuma fixture) | fixture de notícia/dataset/destaque + mídia e asserção de HTML não vazio | `WidgetRegistryTest` |
| K | baixo | `SaveDestaque` decidia a imutabilidade de arquivada no model vinculado (corrida sobrescrevia) | gravação em transação com `lockForUpdate` (409) e slot anterior real | `DestaquesCmsTest` |
| L | baixo | `SetDestaqueActive` retornava cedo com model velho (302 sem transição) | decisão pelo estado travado; auditoria com o `before` real | `DestaquesCmsTest` |
| M | baixo | sitemap anunciava página com `seo.robots = noindex` | `noindex` fica fora do `urlset` | `PaginaPortalTest` |
| N | baixo | prévia cacheava documento inválido (0/2 hero) e o teste passava por cache velho | cache só depois da guarda; teste incrementa `lock_version` | `HomePublishPreviewCmsTest` |
| O | baixo | `MODULE.md` da Home omitia a regra do hero; emenda ADR-016 dizia que **Páginas** usa seções; história 27 citava contrato antigo | docs corrigidas (hero, AST de Páginas, transições/axe) | leitura + `docs-check` |
| P | baixo | docblock de `DadosAbertosUltimosWidget` descrevia o bug de dono já fechado | docblock alinhado ao manifesto | leitura |
| Q | obs | `main.left` é oferecido a widgets com `<h2>` próprio (aside antes do `<h1>`) | **registrado** no `kd`; landmark complementar e axe limpo — restringir slots é decisão de produto | — |
| R | obs | fontes do sitemap não filtram **módulo inativo** (URL 404 anunciada) | **registrado** no `kd` como dívida de desenho do `SitemapRegistry` (vale para Notícias/Home/DadosAbertos) | — |
| S | médio | prévia autenticada (home/página) apontava os links do shell para o **domínio do CMS** (footer/atalhos/CTAs); forçar a raiz global para corrigir mudava os assets do Vite de origem e a CSP `style-src 'self'` bloqueava o CSS (axe `target-size` na spec 26) | `Locales::localizedUrl` honra `portal_origin` marcado na request pela prévia — só links; `route()` e assets continuam na origem do request | `BlockMediaTest`, `PaginaPortalTest`, e2e **26** |
| T | médio | `media-backup` falhava com mídia no inventário (`UnableToCheckExistence`): o bucket `offsite-media` não era provisionado no dev (o stamp de storage não observa volume/DB recriados) | `storage:ensure-bucket --disk=offsite-media` no `prep:storage-offsite` **incondicional**; disco sem bucket é ignorado (check continua estrito) | `CheckPipelineTest` |

## Riscos residuais (aceitos para a revisão)

| # | Risco | Impacto | Mitigação / onde está registrado |
|---|---|---|---|
| 1 | **Cache HTTP de borda sem invalidação** (`Cache-Control`/`s-maxage`, ADR-022) | até o TTL (~300 s) o navegador/borda pode servir o corpo anterior após arquivar/desativar | e2e usa URL nova (`?e2e=…`); FPC/invalidação de borda está fora desta fase (S3.2/S4.2/S6.1) |
| 2 | **Cache de render de página × ativação de módulo / mídia** | página cacheada pode manter a marcação do widget até o TTL | registry é consultado no render; cache de mídia é repovoado no TTL; risco de mesma classe do #1 |
| 3 | **`noticias.mais-lidas` sem métrica por artigo** | o “mais lidas” mostra a ordem de `published_at` (fallback determinístico), não ranking empírico | ADR-047 tem dimensão de rota, não de conteúdo; exige emenda ou contador por peça (registrado na S5.1) |
| 4 | **Mídia por `id` (sem picker/upload)** em blocos e destaques | operador sem o UUID precisa abrir `/galeria` | resolução id→`<picture>` já é final (P5.FIX2/A); o seletor é decisão de UX de fatia própria (S2.1/S4.1) |
| 5 | **E2E acumula dados no banco demo** | peças arquivadas são terminais (uma por execução) | spec 29 isola a asserção por busca; spec 32 desativa resíduos; limpeza periódica é backlog de operação |
| 6 | **Sem “despublicar” a home / sem desarquivar página ou destaque** | voltar a home ao default ou reabrir uma página exige nova fatia | documentado em S2.2/S3.1/S4.1; o slug/URL permanecem reservados |
| 7 | **`hreflang` de página não traduzida** | `/en/<slug>` responde 200 com fallback de conteúdo | comportamento do `LocaleCast`, não conteúdo inventado (S3.2) |
| 8 | **`layout_scope` sem superfície de UI** | a coluna existe e permanece `null` | contrato de dado pronto; oferecer o override é decisão de produto (S3.1) |
| 9 | **Mesclagem da cobertura exige 512 MiB no build** | se a suíte crescer ~1,9× o limite volta a estourar | `tools/coverage-parallel.sh` com o pico medido (273 MiB) no comentário; `CheckPipelineTest` trava o valor |
| 10 | **Freio por família de rota** | reruns manuais do e2e em <1 min podem dar 429 na mesma família | tetos inalterados; `CmsThrottleIsolationTest` impede regressão do balde compartilhado |
| 11 | **`/cms/layout` mudou de aparência** (`x-cms::table`) | listagem com listras/`w-full` | é a forma canônica do painel para tabela que rola no mobile (S6.1) |
| 12 | **Ciclo `make check E2E=1` cresceu** (contrato: ≤ 3 min) | `e2e` ~4 min + `perf` ~2 min + `coverage`; o lote serial (`cms-serial`) agora inclui 30/31/32 (estado global) | medido no fechamento; `fullyParallel` **não** pode ser ligado (specs mutam o mesmo catálogo/rascunho/ativação). Otimização futura: reduzir `perf`/serial sem cortar verificação |
| 13 | **Sitemap não filtra módulo inativo** (`Paginas`/`Noticias`/`Home`/`DadosAbertos`) | URL de módulo desligado continua anunciada (o fallback/serviço responde 404) | dívida de desenho do `SitemapRegistry` (registrada no `kd`); não é regressão do P5 |
| 14 | **Widget de conteúdo em `main.left`** emite `<h2>` antes do `<h1>` da página | árvore de headings começa em nível 2 na coluna lateral; axe/contrato atuais não acusam | landmark complementar é convenção; restringir os slots é decisão de produto (registrado no `kd`) |
| 15 | **`prep:migrate`/`prep:demo-data` são gated por mtime, não pelo estado do banco** | banco dev recriado com stamp fresco: portal 500 e e2e no login (0 usuários) até `artisan migrate` + `CHECK_FORCE_PREP=1`; o `security-check` **falhou** (nunca falso verde) | risco operacional de ambiente (registrado no `kd`); endurecer o gate (migrações pendentes/contagem de usuários) é fatia própria |

### Evidência (P5.FIX2, 2026-10-26)

- **Testes alvo:** `BlockMediaTest` **4** (mídia por seção, seção oculta, 1 consulta, prévia) ·
  `BlocksSchemaTest`/`BlocksRendererTest` (AST com heading) · `HomeEditorCmsTest`/`HomePublishPreviewCmsTest`
  (submit padrão, contexto do erro, cache da prévia) · `PaginaPortalTest` (heading sem `attrs`, h2→h2,
  sitemap `noindex`, links da prévia no domínio do portal) · `DestaquesCmsTest` (mídia PDF, arquivada
  sob trava, ativação com model velho) · `WidgetRegistryTest` (id duplicado com fixtures) ·
  `WidgetSlotCompositionTest` (feed vazio não abre região) — **todos verdes**.
- **Suíte completa:** `OK (1648 tests, 28442 assertions)`; cobertura `src/` **94,21%**
  (21 336/22 647), piso 90%.
- **`make check` #1:** **26 OK · 0 FAIL · 8 SKIP** (110,39 s) — `a11y` sniffCSS estrito OK,
  `coverage` 52 s, nenhum teste vermelho.
- **`make check` #2:** **23 OK · 0 FAIL · 11 SKIP** (107,91 s) — mesmas contagens de teste e de
  cobertura; a diferença de SKIP é só o gate de staleness (`prep:cache`/`prep:storage` etc.),
  sem falso verde.
- **PHPStan `max`:** 0 erros (sem baseline); Pint limpo; `arch-check`/`name-check`/
  `migration-check`/`vendor-check`/`docs-check` OK.
- **`make check E2E=1`:** **25 OK · 0 FAIL · 9 SKIP** (346,12 s) — `e2e` **151 passed + 1 skip**
  (3,2 min; inclui `25` com axe no diálogo aberto e `30` no lote serial), `a11y` OK,
  `perf` OK. Rodado com `BASE_URL` derivado de `WEB_PORT` (50762) — **o primeiro E2E desta rodada
  falhou** em `28-cms-paginas-portal` (sitemap vazio) porque a stack ainda publicava a porta 8080
  enquanto `APP_URL`/`WEB_PORT` já eram 50762: `SeoSettings::baseUrl()` descarta as URLs do host
  divergente (ADR-043, fail-closed). Recriado o `web` na porta configurada, o spec passou sem
  mudança de código; o ciclo deve rodar com a stack e o `BASE_URL` coerentes (é o que
  `tools/check.sh` já faz por padrão).

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-26 | revisão paralela (1ª rodada) + `P5.FIX` | ✅ corrigido | 18 achados (6 altos, 9 médios, 3 baixos) com teste: hero único, `BlockRoute`, locale do cache (página/banner), limpar tradução, AST de link, slug de arquivada, `noindex`, throttle por família, `perf --no-deps`, código morto. Commit do mantenedor `e8a7311`. Detalhe na seção `P5.FIX`. |
| 2026-10-26 | revisão profunda (2ª rodada, agente) + `P5.FIX2` | ✅ revisado | Retomada após interrupção: 20 achados — **2 altos** (mídia dos blocos nunca resolvida; heading de AST emitindo segundo `<h1>`), 9 médios (submit implícito, erro sem seção/foco, mídia não-imagem, região vazia, axe no diálogo, spec 30 global, links da prévia × CSP, bucket offsite do gate), 7 baixos e 2 observações registradas no `kd`. Todos com teste vermelho→verde; gates 2× + `E2E=1`. Nada commitado pelo agente (regra §0). |
