# P1 — Estrutura geral do site e do CMS (ficha de fase)

| Campo | Valor |
|---|---|
| **Fase** | P1 — Estrutura geral do site e do CMS |
| **Programa** | F7+ — operação, estrutura e conteúdo |
| **`kd` épico** | `epic_01vhakc0` |
| **Fatias / `kd` tasks** | S1 `task_00kqxzcw` · S2 `task_01lghjpq` · S3 `task_01lp1zcw` · S4 `task_01q7stm2` · S4b `task_000jfqx6` · S5 `task_00x1sawi` · S6 `task_01n7sbfw` |
| **Âncoras** | `wiki/implementation-phases/P1-estrutura-site-cms.md` §3/§5–§8 · `wiki/implementation-phases/TECNICO.md` §11/§14 · ADR-016, ADR-017, ADR-036, ADR-046, ADR-022, ADR-012, ADR-013, ADR-025, ADR-027, ADR-033, ADR-034, ADR-043 · `_to_revision/PROTOCOLO.md` |
| **Status da revisão** | ✅ revisado (achados cross-cutting do P1.FIX corrigidos: 2 Altos + 1 Médio, com teste) |

## Fatias

| Fatia | Entrega | Ficha |
|---|---|---|
| P1.S1 | seções `institution`/`theme`/`cms`/`seo` no schema + `ThemeResolver`/`CmsShellResolver`/`SeoSettings` lendo settings com fallback | [`P1-S1.md`](P1-S1.md) ✅ |
| P1.S2 | editor de tema/tokens (`Configurações → Aparência`), 422 e `ETag` de `/theme.css` | [`P1-S2.md`](P1-S2.md) 🟡 |
| P1.S3 | editor do shell (`Configurações → Painel`), posição/largura/regiões e DOM/foco | [`P1-S3.md`](P1-S3.md) 🟡 |
| P1.S4 | módulo `Infraestrutura/Menus`, porta `Core\Contracts\Navigation` e header do portal | [`P1-S4.md`](P1-S4.md) ✅ |
| P1.S4b | ação “Configurar layout da área” + fallback global `*` | [`P1-S4b.md`](P1-S4b.md) ✅ |
| P1.S5 | SEO de base publicado (`description`, imagem social do Media, verificação, analytics opt-in) | [`P1-S5.md`](P1-S5.md) ✅ |
| P1.S6 | E2E das jornadas, `make check` 2× + `E2E=1`, docs e fechamento | [`P1-S6.md`](P1-S6.md) 🟡 |

## Resultado observável

O operador com `configuracoes.editar`/`menus.editar` muda **estrutura como dado**, sem deploy:
identidade/tema do portal (tokens validados pelo `ThemeSchema`, publicados em `/theme.css` com
`ETag`), shell do painel (posição/regiões/largura com DOM acompanhando o foco), menus do site
(árvore de até 3 níveis, ciclo e link morto bloqueados, porta Core consumida pelo layout) e SEO de
base (descrição, imagem social do Media, verificação e analytics opt-in). O portal e o painel
refletem no request seguinte; tema/menus/shell continuam válidos quando um valor é inválido.

## O que foi implementado

### S1 — dados e leitura
- `SettingsSchema`: `institution.social`/`dpo_email`/`ombudsman_email`, `theme` (`portal`/`cms`/
  `widgets` como JSON de documento), `cms.regions` com shape fechado, `seo.default_description`/
  `default_image`/`analytics_id` (sem default)/`verification`. `normalizeJson`/`normalizeShape`
  dão uma única verdade entre leitura e escrita.
- `ThemeResolver` (DI de `Core\Contracts\Settings`) aplica default → config + settings → preset →
  URL → instância; token inválido é ignorado e logado (`core.settings.invalid`, sem valor).
- `CmsShellResolver` lê `cms` sobre `config/cms.php`; valor inválido não sombreia a camada
  anterior. `SeoSettings` lê `seo` com fallback de config e mantém `analyticsId()` opt-in.

### S2 — Aparência
- `ConfiguracoesThemeEditor` gera um controle por token do `ThemeSchema`; o controller valida com
  `ThemeSchema::accept()` (mesma verdade da leitura), responde 422 com rótulo e não grava.
  `ConfiguracoesThemeEditor::save()` substitui só `portal` e preserva `cms`/`widgets`.
- Prévia same-origin via `/theme.css` publicado; `ETag` muda após o save e o `If-None-Match` novo
  responde 304.

### S3 — Painel
- `CmsShellSchema` centraliza a forma fechada do shell; `CmsShellResolver` e o editor
  (`ConfiguracoesShellEditor`) usam a mesma validação.
- Formulário por campo (identidade, regiões com hidden+checkbox, posição e largura da escala);
  422 sem escrita parcial; `ver` → 403 antes da validação.
- `left`/`right` mudam a ordem do DOM; `orbital` usa popover nativo; região desligada remove o
  landmark.

### S4 — Menus
- Módulo `Infraestrutura/Menus` (token `menus`, permissões `ver`/`editar`, ativo via ADR-034) com
  `menus`/`menu_items` (uuidv7, unique `lower(slug)`, FK composta `(parent_id, menu_id)`,
  `CHECK` de tipo/posição/label/alvo, `down()` não destrutivo).
- Porta `Core\Contracts\Navigation` (`tree`/`flat`) com fallback `EmptyNavigation`; o `layout`
  resolve o contrato e não importa o módulo.
- CMS: lista/criação/edição de menu e árvore de itens com criação, edição, ativar, mover e
  excluir folha; 3 níveis, ciclo e pai de outro menu bloqueados; rota viva da área Portal; URL só
  `http(s)`; locale por URL com fallback `pt_BR`; cache invalidado na escrita.
- Portal: `<nav aria-label>`, `<ul>/<li>`, `aria-current`, `<details>/<summary>` sem JS,
  `target/rel="noopener"` só explícito, ícones Lucide, nenhum link morto.

### S4b — Layout por área
- Ação “Configurar layout da área” no shell do painel, derivada do nome da rota CMS e só quando
  existe rota de Portal; entrada `layout.cms.area` com allowlist (404 fora), `layout.editar`
  obrigatório.
- Fallback global `*`; slots restritos aos widgets disponíveis (módulo inativo não aparece);
  nenhuma migração nova.

### S5 — SEO publicado
- `SeoService` resolve descrição padrão e imagem social via `MediaRepository` (upload publicado,
  `media.stream`, same-origin); UUID inválido/documento/`staging/`/`..` são ignorados com log sem
  id/valor.
- `SeoSettings::verificationMetaTags()` traduz a allowlist de provedores; `content` escapado;
  provedor desconhecido não vira `<meta>`.
- Sem settings, o `<head>` é byte-idêntico ao anterior; `analytics_id` continua opt-in.

### S6 — fechamento
- Spec `18-estrutura-site` (4 jornadas, `cms-desktop`, serial) + história; `17-cms-configuracoes`
  deixou de exigir 404 para `/configuracoes/theme` (editor real) e mantém 404 desconhecido.
- Axe A/AA nas telas tocadas e no portal; `make check` 2× + `make check E2E=1` (números abaixo).

## Superfícies entregues (rotas/telas)

| Superfície | Rota / artefato | Permissão |
|---|---|---|
| Configurações (índice) | `GET /configuracoes` (`configuracoes.cms.index`) | `configuracoes.ver` |
| Identidade/instituição | `GET|PUT /configuracoes/institution` | `configuracoes.ver`/`editar` |
| Aparência/tema | `GET|PUT /configuracoes/theme` | `configuracoes.ver`/`editar` |
| Painel/shell | `GET|PUT /configuracoes/cms` | `configuracoes.ver`/`editar` |
| Menus | `GET|POST /menus`, `GET /menus/{menu}/editar`, `PUT /menus/{menu}` | `menus.ver`/`editar` |
| Itens do menu | `GET|POST /menus/{menu}/itens`, `GET|PUT …/{item}`, `POST …/ativo`, `POST …/mover/{direction}`, `DELETE …/{item}` | `menus.ver`/`editar` |
| Layout por área | `GET /layout/area/{scope}` (`layout.cms.area`) | `layout.editar` |
| Folhas de tema | `GET /theme.css`, `GET /cms-theme.css` | público |
| Header do portal | `<nav aria-label="Menu principal">` via `Core\Contracts\Navigation` | público |

## Pontos de revisão

### D1 — Simplicidade / PHP 8.4
- [ ] `strict_types`, tipos em parâmetros/retorno, DI por construtor; `match` fechado nos schemas.
- [ ] Sem trait/abstract/generic/`Result`; helpers concretos (`ThemeSchema`, `CmsShellSchema`,
      `MenusTree`/`MenusTarget`/`MenusCache`, editores) com consumidor real.
- [ ] Uma Action por arquivo nos menus; controller fino (autorização → request → action → view).

### D2 — Laravel 12
- [ ] Sem closure em rota; nomes canônicos; `scopeBindings`/`whereUuid`; mutação só `POST/PUT/DELETE`.
- [ ] `FormRequest` valida forma e normaliza (checkbox, JSON oculto); `validated()`/lista explícita,
      nunca `all()` em create/update.
- [ ] `env()` só em `config/`; providers sem registro manual além do contrato do Core.
- [ ] Migrações idempotentes; `down()` renomeia tabela **e** índices (rollback→up testado).

### D3 — Determinismo
- [ ] Sem `now()`/`rand()` em domínio; UUIDs do menu de referência fixos; ordem `position`+`id`.
- [ ] `ETag` de `/theme.css` é hash do corpo estável; nenhum cache-busting aleatório em tela.
- [ ] E2E sem espera fixa; estado restaurado; `make check` 2× verde.

### D4 — Fronteiras
- [ ] `core` não importa `layout`/módulos; `services` não importa módulos; `layout` não importa
      `Modules\` (teste estrutural).
- [ ] Módulos importam só Core/Layout; `Menus` registra a porta no `register()`.

### D5 — Dependências / licenças / CDN
- [ ] Nenhuma dependência nova; Lucide já vendorizado; zero CDN/requisição externa.

### D6 — CSS / Tailwind / daisyUI
- [ ] Tokens só `oklch()`/escala fechada; `/theme.css` em `@layer overrides` (antes de `a11y`).
- [ ] Sem `<style>`/`style=`/`!important`; posição do shell é dado + CSS, sem medição em JS.

### D7 — HTML semântico & acessibilidade
- [ ] Um shell por tela; `<label for>`, `fieldset/legend`, `aria-describedby`, `aria-invalid`,
      `role="alert"` e foco no primeiro erro.
- [ ] Ordem de foco = ordem visual nas três posições; menu do portal abre sem JS; `aria-current`.
- [ ] Axe A/AA nas telas e no portal (P1.S6); contraste com tema configurado coberto na jornada.

### D8 — i18n
- [ ] 4 locales em todas as chaves/telas; `ModuleI18nTest` e paridade de rótulos do menu.
- [ ] Navegação interna por `route()`/`localized_url()`; locale da URL; `<html lang>` do shell.

### D9 — Grep-friendly
- [ ] Token do módulo/seção nos arquivos (`configuracoes`, `menus`); `name-check` verde.

### D10 — Dados / banco
- [ ] `uuidv7`, `text`, `timestamptz`, `jsonb`, `NOT NULL`, `CHECK` nos shapes críticos.
- [ ] `down()` não destrutivo; seed de referência idempotente; nenhum HTML/CSS no banco.
- [ ] `cms.regions`/`theme.portal` com forma fechada; posição/largura em escala; default imperativo.

### D11 — Segurança / LGPD
- [ ] `Permission::authorize` antes de validar/gravar; `ver` → 403; inválido → 422; nunca 500.
- [ ] Cor só CSS segura (sem `;{}<>url()@`); logo só same-origin/`https`; URL de menu só
      `http(s)`; rota só da área Portal.
- [ ] Nenhum valor de settings/rótulo em log/auditoria além de `section`/contagem/`fields`.

### D12 — Observabilidade / auditoria
- [ ] Escrita passa pela porta (`core.settings.write`/`core.settings.updated`) e pelo subscriber
      global; menus emitem span + `log_event` com o mesmo nome.
- [ ] Leitura inválida loga só escalares; sem PII.

### D13 — Testes
- [ ] Feature/unit por fatia (schema, resolvers, editores, menus, SEO) verdes.
- [ ] Cobertura `src/` ≥90%; PHPStan max 0; `optimize` verde.
- [ ] E2E: histórias 17/18 no `cms-desktop`, axe A/AA, sem flake (2× + `E2E=1`).

### D14 — Documentação
- [ ] `MODULE.md` de Configurações/Menus, `src/services/Layout/README.md` e fichas S1–S6.
- [ ] `INVENTARIO.md`/estado da fase coerentes; esta ficha e a fila em `_to_revision/README.md`.

## Evidência

- **PHPUnit (após o P1.FIX)**: **901 testes, 14.686 asserções, OK**; cobertura de `src/` **92,93%**
  (11.995/12.908; piso 90%) — `coverage`/`coverage-check` verdes nas duas passagens.
- **`make check` 2×**: **24 OK · 0 FAIL · 9 SKIP** em **109,84 s** e **22 OK · 0 FAIL · 11 SKIP**
  em **102,93 s** (e2e/perf opt-in; a 2ª passagem pula stamps de prep). `static` (PHPStan max),
  `check-boundaries`, `check-migrations`, `check-vendor`, `check-gates`, `security-check`,
  `optimize-check`, `a11y` (sniff estrito) verdes.
- **`make check E2E=1`**: **24 OK · 0 FAIL · 9 SKIP**, **228,58 s**; `e2e` **OK** em 94 s com
  **117 passed · 1 skipped** (118 testes, 4 workers, `retries: 0`); o projeto **`cms-serial`**
  (spec 18) roda **primeiro** e os demais dependem dele. `perf` **10 passed**; `a11y` 13 s.
- **P1.S6 dirigido**: `tools/e2e.sh test 18-estrutura-site` → **4 passed**; o spec restaura o dado
  em `finally` (tema default, sidebar `left`, menu com os 3 itens de referência, sem item de teste).
- `git diff --check` limpo; `tools/docs-check.sh` **OK** (13 specs).
- Após o e2e: `core_settings` com `theme`/`cms`/`institution` iguais aos valores efetivos
  originais; `menus.menu_items` com 3 itens (`Início`, `Notícias`, `Dados Abertos`).

## Riscos residuais

| Risco | Mitigação / estado |
|---|---|
| Tema com cor livre quebra contraste | schema fechado; `/theme.css` em `@layer overrides` antes de `a11y`; axe cobre o estado exercitado; não há verificador de contraste em PHP (registrado em S2) |
| Salvar congela a camada de `config/` | formulários mostram valores efetivos; campos de identidade vazios herdam; posição/largura/regiões viram escolha explícita |
| `cms.logo` não resolve `media_id` | aceita caminho same-origin/`https`; catálogo no shell fica para fatia própria (S1/S3) |
| Cache de menu stale | invalidação em `DB::afterCommit` + TTL de segurança; escrita fora do CMS (SQL/ETL) não invalida (contrato documentado) |
| `page` indisponível | tipo desabilitado no editor até P5; schema já aceita |
| Uma leitura de upload por render com `default_image` | seção `seo` memorizada por request e miss cacheado (0 SELECT por página no estado default); upload só é lido quando há UUID configurado e não é cacheado (S5) |
| Ação de layout depende de rota de Portal viva | escopo derivado do nome da rota; `noticias.show` e telas sem contrapartida não exibem ação (S4b) |
| E2E muda settings globais por segundos | **resolvido**: spec 18 no projeto `cms-serial` (1 arquivo/worker) e todos os demais com `dependencies: ['cms-serial']`; a janela de mutação fica antes do lote (S6/P1.FIX) |

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-24 | revisor (cross-cutting P1, S1–S6) | 🔧 | **A (Alto) e2e `18` interfere em outro spec:** `18-estrutura-site.spec.ts:121-159` grava `cms.sidebar_position` (e `154-159` restaura) e `184-211` renomeia o 1º item do `principal`; `07-cms-shell.spec.ts:67` exige `data-cms-sidebar="left"` (mesmo projeto, pool de 4 workers) e `03-no-js.spec.ts:19` clica `link "Início"` — corrida real, viola D3/D13 e o critério 6 da história 18 ("não depende de dado de outro arquivo": ele **interfere**). Corrigir isolando a jornada em projeto serial (a própria S6 registra a saída). **B (Médio) menu sem itens "ressuscita" o config:** `MenusNavigation::build()` (`150-157`) devolve o fallback de `config('layout.portal.nav')` também quando o menu **existe e está vazio** — apagar o último item do `principal` pela UI não muda o portal (contradiz S6 #4 e "sem mascarar menu existente"); desativar todos os itens dá menu vazio. Teste vermelho: menu sem `menu_items` (linha preservada) → `tree()` deve ser `[]`. **C (Alto, aberto, já em S1/S5)** `SeoSettings::section()` (`249-258`) resolve a porta a cada acessor e `DatabaseSettings::raw()` não tem cache negativo → no estado default **cada acesso é um `SELECT core_settings`** (~12–18/página pública, +`theme`/`cms` no shell), contra a ficha do S1 ("porta cacheada, 1 roundtrip"); um único issue no `kd` para não duplicar. Observações: `theme.cms`/`theme.widgets` seguem JSON aberto sem validador nem consumidor (`risk_00nt4x9d` parcial); `institution.name` sem consumidor público (portal usa `config('app.name')`; herança do P0); `P1-S5` está `🔧` mas a fila o lista 🟡; `theme`/`cms`/`seo` refletem no request seguinte, mas o HTML do portal tem `max-age=300` (ADR-022), então o **primeiro** save de tema pode demorar a aparecer (o e2e contorna com chave de cache); `make check` 2× não está retido em `build/check` (só a passagem `E2E=1`, 117/1, perf 6, cobertura 92,87%, `static` 0) — números plausíveis, evidência de 2× não reexecutável. Verificado OK: wiring da porta (`Core`→`EmptyNavigation`, módulo sobrescreve após o Core), cache de menu invalidado em **todas** as escritas (inclusive troca de slug), `@layer overrides` + ETag do `theme.css`, 4 locales/paridade, DOM = ordem visual nas 3 posições, sem CSS/HTML no banco, `down()` não destrutivo, estado da fase "em revisão" (não "revisado") e árvore sem commit. |
| 2026-10-24 | P1.FIX (agente) | ✅ | **A (Alto) corrigido:** spec `18` movido para o projeto **`cms-serial`** (1 arquivo ⇒ um worker) com `dependencies: ['cms-serial']` nos outros cinco projetos; `18` saiu do `testMatch` do `cms-desktop`. E2E verde (117 passed · 1 skipped) com o lote global mutado/restaurado **antes** das demais specs. **B (Médio) corrigido:** menu `principal` existente e vazio devolve `[]` (fallback de config só quando a linha não existe) — teste `an_existing_empty_primary_menu_does_not_reintroduce_the_config_links`. **C (Médio) corrigido:** slug do principal imutável (`Menu::PRIMARY_SLUG`, 422/campo `readonly`, 4 locales) — teste `the_primary_menu_slug_is_immutable_and_keeps_the_header_attached`. **Robustez:** `MenusCache::forget` em `DB::afterCommit`; lock da linha do menu no primeiro item; `down()` com `finally { RESET search_path }`; rota de módulo inativo = link morto; regressões de SEO (`..`/URL absoluta, provedor desconhecido) e do formulário de Configurações com `old()` array. **Alto (SEO hot path) e Médio (S4b) corrigidos nas fichas S1/S5/S4b.** Dívida baixa restante: `task_00zk7ub0` (kill-switch/ramos defensivos do S4b) e `risk_00nt4x9d` (lost update de settings, `theme.cms`/`widgets` sem consumidor, seção `secret` dormente) — mantido aberto. |
