# P1.S2 — editor de tema/tokens (Configurações → Aparência)

| Campo | Valor |
|---|---|
| **Fase** | P1 — Estrutura geral do site e do CMS |
| **Issue** | P1.S2 — editor de tema/tokens com formulário gerado do `ThemeSchema`, 422 e `ETag` de `/theme.css` |
| **`kd` task** | `task_01lghjpq` (épico `epic_01vhakc0`) |
| **Âncoras** | `wiki/implementation-phases/P1-estrutura-site-cms.md` §3/S2, §5–§8 · `wiki/implementation-phases/TECNICO.md` §14 · ADR-016, ADR-022, ADR-046, ADR-012, ADR-025 |
| **Status da revisão** | ✅ revisado |

## O que foi implementado

- **`Configurações → Aparência`** (`/configuracoes/theme`): `theme` entrou em
  `ConfiguracoesSections::EDITABLE`; o índice lista a seção com rótulo/descrição nos 4 locales.
  Nenhuma seção nova de rota, nenhum endpoint chave/valor.
- **Formulário gerado do schema fechado**: `ConfiguracoesThemeEditor::fields()` percorre
  `ThemeSchema::keys()` e `ThemeSchema::rule()` (`config/design.php`) e produz **um controle por
  token** — `color_primary`, `color_accent`, `color_link` (texto, aceita `oklch()`/hex como o
  schema), `radius` e `font_content` (`select` com os valores **exatos** da escala). O documento
  `theme` não é editado como JSON (sem textarea/`name="portal"`).
- **Validação com uma única verdade**: o controller valida os tokens com `ThemeSchema::accept()`
  (o mesmo validador da leitura), via `Validator` construído a partir de `ConfiguracoesThemeEditor::rules()`;
  não há regex de cor duplicada no módulo. Token inválido responde **422** com o rótulo do campo
  (`configuracoes::validation.invalid` + `:attribute` traduzido), reapresenta o formulário com o
  valor digitado e **não grava** nada.
- **Autorização antes de validar/mesclar**: `UpdateConfiguracoesRequest::authorize()` e o controller
  chamam `Permission::authorize('Configuracoes', EDIT)` antes de qualquer validação/merge; `ver`
  recebe 403 (nunca 422/302).
- **Merge no servidor, sem perda de dado**: `ConfiguracoesThemeEditor::save()` lê a seção atual pela
  porta `Settings`, substitui **somente** `portal` e grava a seção inteira; `theme.cms` e
  `theme.widgets` continuam intactos (inclusive chaves desconhecidas dentro deles). A escrita
  continua sendo só pela porta `Core\Contracts\Settings` (schema/auditoria/cache do Core).
- **Pré-visualização same-origin**: a tela carrega o `/theme.css` já publicado via `@push('styles')`
  (sem `<style>`/`style=` inline, sem requisição externa, sem `?v`/cache-busting aleatório) e desenha
  uma amostra real (`h2`/`h3`, parágrafo, link, ação `.btn-primary`) com `.theme-preview` em
  `src/layout/Cms/Assets/css/shell.css`, consumindo `--tenant-*`/`--font-content`. A lista de
  tokens mostra os valores **salvos/efetivos** (o que `/theme.css` publica); a nota diz que
  alterações do formulário só aparecem depois de salvar.
- **`ETag` revalidando**: salvar invalida `settings.theme` (porta do Core); o próximo `GET /theme.css`
  muda corpo/`ETag` e o `If-None-Match` do novo `ETag` responde `304` — provado em teste, sem esperar
  cache.
- **`ThemeSchema::rule()`/`ThemeTokens::toArray()`**: leitura tipada da regra (tipo + escala) e dos
  tokens por chave, para o módulo não duplicar a forma de `config/design.php` nem o mapeamento
  `ThemeTokens` → chaves do schema.

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

- Editor do shell do CMS (P1.S3), menus (P1.S4), ação de layout por área (P1.S4b) e publicação de
  SEO (P1.S5).
- `theme.cms`/`theme.widgets` continuam sem editor; o merge apenas os preserva.
- Reset explícito de um token para “herdar o default”: o formulário é preenchido com os valores
  efetivos e grava os cinco tokens; limpar/reverter fica para uma fatia com semântica própria.
- Cálculo de contraste em PHP: o schema aceita qualquer cor CSS segura; o gate de contraste é
  axe/sniff no P1.S6 (a ajuda do campo orienta).

## Arquivos para revisar

- `src/layout/Engine/ThemeSchema.php`
- `src/layout/Engine/ThemeTokens.php`
- `src/layout/Cms/Assets/css/shell.css`
- `src/modules/Infraestrutura/Configuracoes/Logic/ConfiguracoesSections.php`
- `src/modules/Infraestrutura/Configuracoes/Logic/ConfiguracoesThemeEditor.php`
- `src/modules/Infraestrutura/Configuracoes/Logic/Controllers/Cms/ConfiguracoesEditController.php`
- `src/modules/Infraestrutura/Configuracoes/Logic/Controllers/Cms/ConfiguracoesUpdateController.php`
- `src/modules/Infraestrutura/Configuracoes/Logic/Requests/UpdateConfiguracoesRequest.php`
- `src/modules/Infraestrutura/Configuracoes/Layout/Views/cms/configuracoes-theme.blade.php`
- `src/modules/Infraestrutura/Configuracoes/Lang/{pt_BR,en,es,fr}/cms.php`
- `src/modules/Infraestrutura/Configuracoes/MODULE.md`
- `src/modules/Infraestrutura/Configuracoes/Test/ConfiguracoesThemeTest.php`
- `src/modules/Infraestrutura/Configuracoes/Test/ConfiguracoesCmsTest.php`

## Pontos de revisão

### D1 — Simplicidade / PHP 8.4
- [ ] `declare(strict_types=1)`, tipos em parâmetros/retorno, `final readonly` no helper; DI por
      construtor (`ThemeResolver`, `Settings`), sem `new`/`resolve`/`app()` no corpo.
- [ ] Sem trait/abstract/generic/`Result`; `ThemeSchema::rule()` é função pura de configuração;
      `match` fechado em `accept()`.
- [ ] `Validator`/`View` no controller são borda HTTP; nenhuma abstração nova além do helper de
      formulário com dois consumidores (edit/update).

### D2 — Laravel 12
- [ ] Nenhum `env()` novo; `config()` só no código; nenhuma rota/closure nova.
- [ ] `FormRequest` continua dono da autorização; o caminho `theme` usa `$request->only()` (lista
      explícita), nunca `all()`.
- [ ] Controller: autorização → validação → merge/porta → 422/redirect; sem regra de negócio
      duplicada da porta.

### D3 — Determinismo
- [ ] Campos na ordem do schema; `save()` percorre `ThemeSchema::keys()`; sem `now()`/`rand()`.
- [ ] `ETag` é `sha256` do corpo estável; nenhum cache-busting aleatório na tela.
- [ ] `payload` é `jsonb`: o PostgreSQL não preserva a ordem das chaves — a ordem canônica é a do
      schema na leitura/UI (teste compara chave→valor, não a ordem física).

### D4 — Fronteiras
- [ ] `core` não importa `layout` (intocado); o módulo importa `Layout\Engine\ThemeSchema|ThemeResolver|ThemeTokens`,
      permitido pela matriz (`modules → core, layout, próprio módulo`); `arch-check` OK.

### D5 — Dependências / licenças / CDN
- [ ] Nenhuma dependência nova, nenhum asset/CDN; a pré-visualização usa CSS same-origin.

### D6 — CSS / Tailwind / daisyUI
- [ ] `.theme-preview` em `@layer components`, só tokens (`--tenant-*`, `--font-content`, `--surface`,
      `--ink`, `--line`, `--radius-*`), `rem`/`clamp`/`ch`, sem `px`/hex/`!important`.
- [ ] Nenhum CSS no banco; a amostra consome o `/theme.css` publicado (`@layer overrides`), então o
      tema do tenant não vence `@layer a11y`.

### D7 — HTML semântico & acessibilidade
- [ ] Um único shell `<x-layout::cms>`; só conteúdo no slot; um `<h1>` (shell), amostra em
      `h2`→`h3` sem pular nível.
- [ ] Cada controle tem `<label for>`; ajuda/erro em `aria-describedby`; erro com `aria-invalid` e
      `role="alert"` no campo + resumo; `autofocus` no primeiro erro; sem JS obrigatório.
- [ ] Amostra real: link de navegação e ação `.btn-primary` são `<a href>` (navegação), sem botão
      morto; alvos/contraste são medidos por axe no P1.S6.

### D8 — i18n
- [ ] 4 locales com as mesmas chaves (rótulos, ajuda, preview, seção); nenhuma string visível fixa;
      `route()` para navegação interna; `<html lang>` é do shell.

### D9 — Grep-friendly
- [ ] Arquivos novos com o token `configuracoes` no nome; `name-check.sh` OK (sem exceção nova).

### D10 — Dados / banco
- [ ] Nenhuma migração; `theme.portal` continua JSON de tokens (sem HTML/CSS/classe); `theme.cms`/
      `theme.widgets` preservados no merge.

### D11 — Segurança / LGPD
- [ ] `Permission::authorize` antes de validar/mesclar; 403 para `ver`; 422 para token inválido;
      sem 500; sem `!important`/inline; sem textarea JSON; sem endpoint chave/valor.
- [ ] Nenhum valor de token em log/auditoria (a porta do Core só registra `section`/contagem).

### D12 — Observabilidade / auditoria
- [ ] Nenhum span novo no módulo; a escrita usa `core.settings.write`/`core.settings.updated` da
      porta (auditoria before/after redigida pelo Core); sem PII.

### D13 — Testes
- [ ] Formulário renderiza um controle por token e os valores salvos; sem settings, mostra os
      defaults de `config/design.php` e o `/theme.css` continua o atual.
- [ ] Save válido → redirect + status; `theme.cms`/`theme.widgets` preservados; token inválido →
      422 com rótulo e sem escrita; escala fechada recusada; `ver` → 403 antes da validação.
- [ ] `ETag` muda após o save; `If-None-Match` novo → 304; ETag antigo → 200; o CSS não contém o
      valor rejeitado.
- [ ] Checagens estruturais: `<label for>`/`id` por campo, link do `/theme.css`, `aria-labelledby` da
      amostra, ausência de `<style>`/`style=`/`<script>` inline; 4 locales para todas as chaves.
- [ ] Suíte direcionada + `make check` (evidência abaixo).

### D14 — Documentação
- [ ] `MODULE.md` atualizado (seções editáveis, dependências de layout, view, 422/ETag, preview);
      esta ficha + linha 62 do `README.md`.

## Evidência

- `docker compose exec -T php-fpm ./vendor/bin/phpunit --filter='ConfiguracoesThemeTest|ConfiguracoesCmsTest'`
  → **19 testes, 218 asserções, OK**.
- Regressão das áreas tocadas (`DesignTokensTest|ModuleI18nTest|DocsReferencesTest|ThemeResolverTest|ThemeOverrideTest|SettingsDrivenSiteTest|SettingsSchemaTest|LayoutContractTest|Configuracoes*Test`)
  → **93 testes, 3.295 asserções, OK**.
- `phpstan analyse --memory-limit=1G` → **0 erros** (nível max).
- `pint --test` nos arquivos alterados → PASS.
- `tools/arch-check` → OK; `tools/name-check.sh` → OK; `tools/docs-check.sh` → OK.
- `git diff --check` → sem erros de whitespace.
- `make check` → **25 OK · 0 FAIL · 8 SKIP** (e2e/perf opt-in), **119,55 s**; `coverage` +
  `coverage-check` OK; `a11y` (sniff, strict) OK com 0 `fail` (28 `warn` de contraste
  “transparent background” no `/cms/login`, já ignorados pela ledger).

## Riscos e observações

- **422 com o formulário reapresentado** (em vez do 302 + flash do formulário genérico): é o
  contrato do ADR-046 (“escrever seção inválida → 422”) e evita perder o que foi digitado. O
  `View::share('errors', …)` vale só para o request; o próximo request reata o `ShareErrorsFromSession`.
- **`jsonb` não preserva ordem de chaves**: a ordem canônica é a do schema na leitura/UI; nenhum
  comportamento depende da ordem física do `payload` (o resolver lê por chave).
- **Pré-visualização carrega `/theme.css` na tela do painel**: só nessa tela; o restante do CMS
  continua sem a folha do portal. A amostra mostra o tema efetivamente publicado (defaults +
  settings + camadas de `config/`), não um rascunho.
- **Sem reset de token**: salvar grava os cinco tokens; voltar a “herdar” exige editar o JSON pela
  porta (fora do escopo). O formulário é preenchido com os valores efetivos para nunca começar vazio.
- **Contraste de cor livre**: o schema aceita qualquer cor CSS segura; o gate de contraste é
  axe/sniff no P1.S6. A ajuda do campo orienta, mas não bloqueia cor de baixo contraste (não há
  parser de cor no PHP).
- **Escala de fonte exibida como valor exato** no `select` (a stack CSS), sem rótulo amigável: é a
  mesma fonte de verdade do schema e evita um mapa de nomes que envelheça.
- **Amostra de “botão” é um `<a class="btn btn-primary">`**: as duas amostras navegam de verdade
  (folha publicada e volta às seções), então o elemento correto é link (TECNICO §7.1: ação =
  `button`, navegação = link); não há controle morto na tela.
- **Testes de 422 não cobrem o caminho com JS**: a tela funciona sem JS; o e2e/axe fecha no S6.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-24 | omp (S2) | ✅ | 0 bloqueador/alto/médio. Verificado no código: 422 direto com `View::share('errors')` + formulário reapresentado e nada gravado (ConfiguracoesUpdateController.php:83-96); autorização antes de validar (ver → 403); merge preserva `theme.cms`/`theme.widgets` inclusive chave desconhecida (ConfiguracoesThemeEditor.php:150-163); validação só por `ThemeSchema::accept()` (ConfiguracoesThemeEditor.php:109-131); `oklch()`/hex preservados; preview pelo `/theme.css` publicado via `@push('styles')`, sem inline/`?v`; `ETag` sha256 + `If-None-Match`→304 que muda após salvar (ThemeStylesheetController.php:22-31); `.theme-preview` em `@layer components` consumindo só tokens, sem `px`/`!important` (shell.css:419-505); 4 locales com paridade. **Obs. 1** `ThemeResolver::configuredLayers()` é público sem consumidor fora da classe e `phpstan.neon:55` isenta o arquivo inteiro de `shipmonk.deadMethod` (o falso-positivo real é `hasConfiguredOverrides()`, Blade-only); sugestão: ignore por método (como o de `SeoSettings::verificationMetaTags()`) e tornar `configuredLayers()` privado — o padrão do repo hoje é isenção por arquivo, então aceito. **Obs. 2** no 422 o `select` de `radius`/`font_content` cai na 1ª opção da escala em vez de reapresentar o valor enviado (configuracoes-theme.blade.php:60-63); só alcançável com requisição forjada (a UI não oferece valor fora da escala) — aceito/documentar. |
