# F4.I3.T2 — Banners e perfis por grupo determinísticos (service Layout)

| Campo | Valor |
|---|---|
| **Fase** | F4 — Produto & CMS |
| **Issue** | F4.I3 — Widgets, banners e builder (plano 16) |
| **kd task** | `task_005rcsxg` (`closed`) |
| **Âncora** | `wiki/implementation/04-produto-cms.md` |
| **Status da revisão** | 🔧 com correções |

## O que foi implementado

- Service `Layout` headless: `LayoutProfileReader` + `ResolveLayoutPlacements` (prefixo mais específico, fallback global `*`).
- Pilha ordenada por (`ordem`, `widget`); agendamento por janela `[inicio, fim]` (relógio injetado); cache do mapa invalidado ao salvar.
- `layout.layout_profiles` (jsonb); `<x-layout::slot>` renderiza; seeder global + E2E `09-widgets-banners`.

## Arquivos para revisar

- `src/services/Layout/Actions/ResolveLayoutPlacements.php`
- `src/services/Layout/Actions/SaveLayoutProfile.php`
- `src/services/Layout/Contracts/LayoutProfileReader.php`
- `src/services/Layout/DTOs/LayoutPlacement.php`
- `src/services/Layout/Enums/LayoutSlot.php`
- `src/services/Layout/Models/LayoutProfile.php`
- `src/services/Layout/Migrations/2026_10_19_000000_create_layout_profiles_table.php`
- `src/services/Layout/Seeders/LayoutProfileSeeder.php`
- `src/services/Layout/ServiceProvider.php`
- `src/layout/Components/slot.blade.php`
- `src/layout/Components/portal.blade.php` (colapso de região vazia)
- `src/services/Layout/Test/LayoutProfileTest.php`
- `src/tests-e2e/tests/09-widgets-banners.spec.ts`

## Pontos de revisão

### PHP 8.4
- [ ] `declare(strict_types=1)`; tipos nativos (union/enum/`readonly`); sem `mixed` desnecessário.
- [ ] Dependências **injetadas** (nenhum `new`/`app()`/`resolve()` no corpo); sem traits/abstract/generics.
- [ ] `match` em vez de cadeias de `if`; `?->`/`??`; sem código morto; PHPStan `max` limpo.

### Laravel 12
- [ ] Nativo primeiro (Eloquent casts/scopes, `FormRequest`, `Gate`, scheduler, events) — sem reimplementar.
- [ ] Sem closure em rota; resources/rotas nomeadas; `config()` só lê, `env()` só em `config/`.
- [ ] Migração **idempotente**; `down()` → `backup_*` (nunca `DROP`); sem `orderBy` cru quando `latest/oldest` serve.

### HTML semântico + a11y (eMAG/WCAG 2.2 AA)
- [ ] Um `<main>`, headings em nível correto, landmarks; só ARIA quando necessário.
- [ ] Alvos ≥ 24 px (≥ 44 px em ponteiro grosso); foco visível; rótulos em todos os campos; contraste (axe).
- [ ] Funciona **sem JS**; `sniffCSS` sem `fail`/`warn` fora do ledger.

### CSS moderno
- [ ] `oklch`, `clamp`, `rem`/`fr`/`dvh`/`%`; `@layer` (a11y por último); `@property`; subgrid com fallback.
- [ ] Sem `!important`, sem `style="…"` inline, sem hex/rgb (exceto justificado); sem CDN.

### Decisões do projeto (ADRs)
- [ ] Respeita a ADR do escopo (ver cabeçalho) e não escapa das demais (001, 002, 004, 013, 015…).
- [ ] Determinismo: relógio/id injetados, ordem explícita, dados idempotentes (ADR-001).

### i18n (ADR-012)
- [ ] Nenhuma string visível fixa; 4 locales com paridade de chaves; `route()`/`localized_url()` (nunca path literal).

### Observabilidade/segurança/testes
- [ ] Span + `log_event('{module}.{action}')` com atributos `scalar|null`, **sem PII** (ADR-024).
- [ ] Teste que falha sem a mudança (vermelho→verde) + evidência no `kd`; `make quality` 2×.

### Específicos desta task

- [ ] Resolução determinística (desempate explícito); janela por relógio injetado.
- [ ] Cache invalidado ao salvar; região vazia colapsa (sem espaço fantasma).

## Comandos úteis

```bash
make quality            # lint + PHPStan max + gates + testes
make e2e                # Playwright + axe (se a task toca UI)
make check-boundaries   # fronteiras core/services/layout/modules
```

---

> Revisar contra `AGENTS.md`, `wiki/adr/` e o plano citado. Registrar achados no `kd` (`kd write --anchor`) e, se houver correção, teste de regressão.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-18 | agente | 🔧 | **A** (**alto**) o **escopo por grupo de URL não valia no locale padrão**: as rotas sem prefixo de idioma recebem o sufixo **interno** `default.` (ADR-012) e o resolvedor comparava o nome cru → medido com o perfil `noticias`: `placements('noticias.index') = 1` e `placements('default.noticias.index') = 0` — o mesmo perfil sumia em `/noticias` e só aparecia em `/en/noticias`, e o e2e só exercitava o perfil **global** `*` (lacuna de gate) → normalização do prefixo interno no resolvedor + teste PHPUnit (**vermelho** antes) + **perfil de grupo na semente** + **e2e novo** que prova no browser (sem a correção: `data-right="true"` em vez de `false`, **vermelho**; com ela: verde) — `fact_00ozv3c4`; **B** (médio) o widget de exemplo declarava o slot `home.aside`, que **não existe** no enum canônico `LayoutSlot` — o CMS nunca poderia colocá-lo ali (declaração morta que confunde quem edita o perfil) → alinhado a `main.right` + **gate novo** em `LayoutProfileTest` (todo widget registrado só declara slot canônico; **vermelho** antes: `demo.status → home.aside`) — `fact_005bch7j`; **C** (baixo) o ponto específico “cache invalidado ao salvar” estava implementado mas **sem teste** (os testes criam perfis antes da primeira leitura) → teste novo que lê (popula o cache), salva e lê de novo — **vermelho** ao remover o hook `saved` do model — `fact_012px9rt`; **D** (baixo) o `LayoutProfileFormController` usava `app(WidgetRegistry::class)` no corpo onde **injeção é possível** → passou a injetar (o `SaveLayoutProfileRequest` mantém `app()` — FormRequest não recebe injeção) — `fact_012px9rt`; **E–H** (obs) **tensão de design**: widget **com cabeçalho** numa região **anterior** ao `<main>` (`main.left`/`main.before`/`main.header`) quebra a regra do e2e “o primeiro cabeçalho é o `<h1>`” (a coluna esquerda renderiza antes do main; a direita, depois) — descoberto ao semear o perfil de grupo na coluna esquerda e resolvido usando o perfil **vazio** como prova — `risk_00hi43ht`; `hasPlacements()` não emite span (chama `collect()` direto) enquanto `placements()` emite, e o TTL do cache de perfis é fixo em 300 s fora de `config` — `risk_00hi43ht`; `activeAt()` trata data malformada como “sem limite” (fail-open documentado; o CMS valida `date`). **Verificado OK:** ordem determinística (`usort` por `[ordem, widget]`) e janela `[inicio, fim]` com `Clock` injetado (testados); migração idempotente com `CHECK (jsonb_typeof = 'array') NOT VALID` + `VALIDATE` e `down()` → `backup_*`; seeder em `src/demonstration/` (ADR-041); `<x-layout::slot>` resolve perfil **e** provedores no mesmo lugar; o shell colapsa a região considerando **os dois**; o form do CMS filtra por `embeddable()` e oferece só slots canônicos; o `ServiceProvider` expõe contrato + catálogo (ADR-015). |

> Resultado: `✅ revisado` · `🔧 com correções` · `⏭️ delegado` (com justificativa).
