# P1.S4b — Ação “Configurar layout da área/módulo” + fallback global

| Campo | Valor |
|---|---|
| **Fase** | P1 — Estrutura geral do site e do CMS |
| **Issue** | P1.S4b — ação de layout por área/módulo (D6), escopada pela rota, com fallback global `*` |
| **`kd` task** | `task_000jfqx6` (épico `epic_01vhakc0`) |
| **Âncoras** | `wiki/implementation-phases/P1-estrutura-site-cms.md` §3/S4.1, §5–§7 · `wiki/implementation-phases/TECNICO.md` §14 · ADR-016 (emenda *area layout* de 2026-10-23), ADR-013, ADR-015, ADR-025, ADR-033, ADR-034 · `_to_revision/PROTOCOLO.md` · `_to_revision/P1-S4.md` |
| **Status da revisão** | ✅ revisado (correções do P1.FIX aplicadas; 1 dívida baixa em `task_00zk7ub0`) |

## O que foi implementado

- **Ação única no shell do painel** (`src/layout/Components/cms.blade.php`): botão-link
  “Configurar layout da área” (texto visível + ícone Lucide `layers`), renderizado só quando
  `layout.editar` está presente **e** a tela atual tem contrapartida viva no portal. Nenhum módulo
  foi editado: a ação é herdada por toda tela que usa `<x-layout::cms>`.
- **Derivação de escopo** (`Services\Layout\Contracts\LayoutAreaScopes` →
  `Actions\ResolveLayoutAreaScopes`): do nome canônico da rota CMS (`noticias.cms.index` →
  `noticias`) normalizando o sufixo interno `default.` (ADR-012), e só vale se existir rota da
  **área Portal** (`PlatformArea::PORTAL->domain()`) para o escopo com **todos** os parâmetros
  opcionais. `menus`, `configuracoes`, `layout` e `noticias.show` não passam → sem ação.
- **Entrada** `GET /layout/area/{scope}` (`layout.cms.area`): autoriza `layout.editar`, valida o
  escopo contra a mesma allowlist (404 fora dela, inclusive `%2A`), redireciona ao perfil do escopo
  (editar) ou ao formulário de criação com `?scope=` preenchido. A listagem `GET /layout` e o CRUD
  existentes não mudaram.
- **Fallback global**: nenhum armazenamento novo; o `*` continua sendo o perfil global e o
  resolvedor devolve o escopo mais específico (perfil de área vence mesmo vazio; sem perfil de
  área, o `*` vale). Teste HTTP prova o widget no portal no request seguinte.
- **Slots/widgets disponíveis** (`Support\LayoutSlots`): o editor e a validação só oferecem slots
  canônicos declarados pelos widgets **disponíveis** — widget de módulo inativo (ADR-034) não
  aparece nem é aceito; slot fora do que o widget declara é recusado (`slot_not_allowed`, 4
  locales). A ordem continua sendo a pilha do slot (sem posição absoluta/pixel).
- **4 locales** para `area_action`/`slot_not_allowed` em `Lang/{pt_BR,en,es,fr}/cms.php`.
- **Docs**: `src/services/Layout/README.md` (novo) com contratos, precedência, rotas e a nota de
  que a **UI da área de Suporte é P3**; nota equivalente em `P1-estrutura-site-cms.md` §3/S4.1.

## Fora desta fatia (não implementado de propósito)

- UI da área de Suporte para editar o perfil global `*` (**P3**).
- Conteúdo/blocos por área, posição absoluta/pixel, construtor livre — fora do escopo (ADR-016).
- SEO/identidade (P1.S5) e E2E completo da fase (P1.S6).

## Arquivos para revisar

- `src/layout/Components/cms.blade.php` (ação de área no shell)
- `src/services/Layout/Contracts/LayoutAreaScopes.php`
- `src/services/Layout/Actions/ResolveLayoutAreaScopes.php`
- `src/services/Layout/Support/LayoutSlots.php`
- `src/services/Layout/Http/Controllers/Cms/LayoutAreaController.php`
- `src/services/Layout/Http/Controllers/Cms/LayoutProfileFormController.php`
- `src/services/Layout/Http/Requests/SaveLayoutProfileRequest.php`
- `src/services/Layout/Routes/cms.php`
- `src/services/Layout/ServiceProvider.php`
- `src/services/Layout/View/cms/form.blade.php`
- `src/services/Layout/Lang/{pt_BR,en,es,fr}/cms.php`
- `src/services/Layout/README.md`
- `config/layout.php`
- `phpstan.neon` (isenção estreita do detector de código morto para `scopeFor`, consumido por Blade)
- `src/services/Layout/Test/LayoutAreaTest.php`
- `tests/Feature/CmsShellTest.php`
- `wiki/implementation-phases/P1-estrutura-site-cms.md` (§3/S4.1 — nota P3)

## Pontos de revisão

### D1 — Simplicidade / PHP 8.4
- [ ] `declare(strict_types=1)`; DI por construtor/assinatura (`LayoutAreaScopes`, `LayoutSlots`,
      `Request`); sem `new`/`resolve` no corpo; sem trait/abstract/generic.
- [ ] Uma Action por arquivo (`ResolveLayoutAreaScopes`); contrato na camada `Contracts`; suporte
      concreto (`LayoutSlots`) com dois consumidores reais (form + request).
- [ ] `match` não aplicável; regex fechada para o vocabulário de escopo.

### D2 — Laravel 12
- [ ] Sem closure em rota; rota nomeada `layout.cms.area` com constraint `[a-z0-9.-]+`; `GET` não
      muta; autorização no controller (`Permission::authorize`), validação de forma no request.
- [ ] Contrato registrado em `ServiceProvider::contracts()` (discovery do Core — nenhum `app()`
      escondido); `redirect()->route()` para navegação interna.
- [ ] Nenhuma migração nova; nenhum `env()` fora de `config/` (o novo é `config/layout.php`).

### D3 — Determinismo
- [ ] Sem `now()`/`rand()`; varredura de rotas na ordem de registro, resposta booleana;
      `sort`/ordem canônica dos slots (`LayoutSlot::values()`), sem ordem implícita de array.
- [ ] Testes sem relógio/rede/ordem dependente.

### D4 — Fronteiras
- [ ] `layout` consome `Services\Layout\Contracts` (permitido); `services` não importa `modules`;
      nenhum import cross-module; escopo derivado do **nome** da rota, não do módulo.
- [ ] `arch-check` verde.

### D5 — Dependências, licenças e vendorização
- [ ] Nenhuma dependência nova; ícone `layers` já vendorizado (Lucide); zero requisição externa.

### D6 — CSS/Lucide
- [ ] Sem `<style>`/`style=`/`<script>` novo; classe do design system (`btn btn-ghost` via
      `<x-cms::button>`); ícone só Lucide; nenhum CSS de layout por área.

### D7 — HTML/a11y
- [ ] Ação = link real (`<a href>`), texto visível, ícone `aria-hidden`; nenhum caminho só-JS; o
      formulário segue funcionando sem JS (selects e linhas em branco).
- [ ] Escopo negativo: sem ação onde não há rota de portal (nunca link morto) e 404 na entrada
      manipulada; erro de validação continua em `role="alert"` + `aria-invalid`.

### D8 — i18n
- [ ] `area_action`/`slot_not_allowed` nos quatro locales; nenhuma string visível fixa; navegação
      via `route()`.

### D9 — Grep-friendly
- [ ] Artefatos derivam de `layout` (`layout.cms.area`, `LayoutAreaScopes`, `LayoutSlots`);
      `name-check` verde.

### D10 — Dados
- [ ] Nenhuma migração/coluna nova; `placements` continua `jsonb` (array), sem HTML/CSS/posição
      absoluta; o escopo é validado contra rotas, não contra texto livre.

### D11 — Segurança/LGPD
- [ ] `layout.editar` obrigatório na ação e na entrada (403 medido); escopo da URL em allowlist
      (404); sem PII em log/URL; nenhuma permissão por módulo criada.

### D12 — Observabilidade/auditoria
- [ ] Sem novo evento de alta cardinalidade; a gravação do perfil segue a trilha existente
      (`GlobalAuditSubscriber` + span/log `layout.save_profile`); leitura do escopo não audita.

### D13 — Testes
- [ ] Ação visível com `layout.editar` em Notícias e DadosAbertos; oculta sem permissão; oculta em
      Configurações/Menus/superfície de layout; entrada com perfil existente (editar) e sem perfil
      (criar com `scope` preenchido); 404 para escopo desconhecido/parametrizado/`%2A`; 403 sem
      `layout.editar`; fallback global + escopo vence; widget de módulo inativo não é oferecido nem
      salvo; quatro locales.
- [ ] Shell: link real com texto, ausência sem permissão/rota de portal.
- [ ] Evidência da suíte e dos gates abaixo.

### D14 — Documentação
- [ ] `README` do service (novo) coerente com rotas/contratos (gate `DocsReferencesTest`); nota de
      P3 no plano da fase; esta ficha e a linha 65 do `README.md`; sem código morto.

## Evidência

- `./vendor/bin/phpunit src/services/Layout/Test` — **29 testes** (12 novos em `LayoutAreaTest`).
- `./vendor/bin/phpunit tests/Feature/CmsShellTest.php` — **29 testes** (2 novos).
- `./vendor/bin/phpunit tests/Feature/{ModuleI18nTest, DocsReferencesTest}` — OK.
- `php tools/arch-check` — OK; `bash tools/name-check.sh` — OK (8 módulos).
- `phpstan analyse --memory-limit=1G` — 0 erro **da fatia**; o erro **pré-existente** da árvore
  suja (`SeoSettings::verificationMetaTags` sem consumidor) foi resolvido na revisão do P1 com
  isenção **por mensagem** (Blade-only) em `phpstan.neon`.
- `make check` — **23 OK · 1 FAIL · 9 SKIP**, **119,20 s**. A única falha é `static`, em arquivos
  **fora desta fatia** da árvore suja de P1.S1/S5 (`src/services/Seo/SeoSettings.php:151`
  `verificationMetaTags` sem consumidor e `src/services/Seo/Test/SeoPublishTest.php:101`
  `offsetAccess`); `lint-php`/`lint-js`, `check-boundaries`, `check-migrations`, `check-vendor`,
  `check-gates`, `optimize-check`, `audit`, `coverage` (≥90%) e `a11y` verdes. Nenhum erro nos
  arquivos desta fatia (`phpstan` focado: 0).
- `git diff --check` — limpo (sem whitespace/quebra pendente).

## Riscos e observações

- **Allowlist por rotas vivas**: a ação depende de existir rota de portal para o escopo. Módulo que
  só tenha rotas parametrizadas (ex.: `noticias.show`) não ganha ação — é o contrato do ticket.
- **Escopo preenchido é editável no formulário**: quem chega pela ação vê `noticias` no campo e
  pode trocar; o `unique`/regex existentes continuam valendo e o escopo arbitrário não passa na
  entrada (mas ainda pode ser criado à mão no CRUD — comportamento herdado, não alterado aqui).
- **Slots por widget**: o editor oferece a união dos slots declarados pelos widgets disponíveis;
  uma linha salva de um widget que deixou de existir continua visível naquele slot (não some sem o
  operador ver) e o salvamento é recusado se o par widget/slot for inválido.
- **PHPStan/dead code**: `scopeFor` só é chamado por Blade; isenção estreita no `phpstan.neon`
  documentada (mesmo padrão de `LayoutProfileReader`).

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-24 | revisor (agente) | 🔧 | **A (Médio) `slot_not_allowed` sem teste vermelho→verde** — `SaveLayoutProfileRequest::slotAllowedByWidget` (`src/services/Layout/Http/Requests/SaveLayoutProfileRequest.php:50` na regra; `85-101` na closure) é o **único** guarda do par widget/slot: `available($this->currentSlots())` inclui o slot enviado, então `Rule::in($slots)` aceita um slot canônico que o widget **não** declara (ex.: `demo.status` + `main.left`). Nenhum teste envia esse par — o `$fail` da linha 100 só é atingido, de forma incidental, quando o **widget** está indisponível (módulo inativo) e o teste só afirma o erro de `placements.0.widget`. Remover `$this->slotAllowedByWidget()` mantém a suíte verde (D13/§8 "cobertura parcial"). Esperado: POST `scope=noticias` + `demo.status`/`main.left` → `placements.0.slot` com `slot_not_allowed` e nenhum perfil gravado; o `<select>` não oferece slot fora do que os widgets disponíveis declaram. Atual: caminho não coberto. Disposição: corrigir com teste (vermelho→verde) em `LayoutAreaTest`/`LayoutAdminTest`. **Obs.** (1) `candidate()` normaliza o prefixo `default.` (linhas 77-79), mas `scopeFor()` só recebe nomes da área CMS — que nunca usam o sufixo interno (ADR-012); ramo inalcançável (a normalização que importa está em `portalRoutes()`, linha 130+), sem impacto funcional. (2) O kill-switch `layout.area_action` e os ramos defensivos (`scopeFor(null)`, rota sem `.cms.`, `exists()` fora do `SCOPE_PATTERN`) não têm teste; o 404 de `%2A` é provado pelo constraint da rota, não pela allowlist. (3) Evidência S6 confere: `build/check/static.log` OK e gates/cobertura (92,87%) verdes; a falha de `make check` citada na ficha era do slice de Seo (S5), hoje resolvida. (4) Verificado sem achado: ação no shell (link real, texto, ícone `aria-hidden`, `layout.editar`), allowlist/404, prefill/redirect, fallback global `*`, widget de módulo inativo, 4 locales, sem CSS/JS inline, sem store/migração nova e sem import cross-layer. |
| 2026-10-24 | P1.FIX (agente) | ✅ | **A (Médio) corrigido com teste vermelho→verde:** `LayoutAreaTest::a_slot_the_widget_does_not_declare_is_rejected_and_nothing_is_saved` envia `demo.status`+`main.left` → erro em `placements.0.slot` (`slot_not_allowed`) e **nenhum** perfil gravado (remover `slotAllowedByWidget()` falha o teste). `the_form_offers_only_the_slots_declared_by_the_row_widget` prova o `<select>` **por linha**: `demo.status` só oferece `main.right`; linha em branco oferece a união dos widgets disponíveis (`LayoutProfileFormController` + `rowSlots`). Kill-switch `layout.area_action` e ramos defensivos de `ResolveLayoutAreaScopes` ficam como **dívida baixa registrada** em `task_00zk7ub0`. Integridade do `make check` atual: a falha de `static` citada na nota do revisor era da árvore suja de S1/S5 e está resolvida (0 erros, nível max). |
