# Spec 04 — Acessibilidade e UX como gate

> **Dono:** Layout/UX · **Status:** Vigente · **Plano:** 11
> **Relaciona:** ADR-025, ADR-010, ADR-016, ADR-002, ADR-009, ADR-020, ADR-022
> **Objetivo:** nenhuma tela entra sem **prova automatizada** de acessibilidade e UX. eMAG 3.1 e
> WCAG 2.2 AA são **critério de aceite**, não relatório opcional.

---

## 1. Três camadas de prova (todas bloqueiam merge)

| Camada | Ferramenta | O que prova | Comando |
|---|---|---|---|
| **Contrato estrutural** | PHPUnit (Blade isolado + leitura de CSS) | o módulo usa **um** shell, define só o `<main>`, a11y está na última camada, tokens em `@layer theme`, sem `px`/`hex`/`!important` | `make check` (PHPUnit) |
| **Jornada real** | Playwright + `@axe-core/playwright` | navegação, teclado, foco, sem-JS, preferências, **axe WCAG A/AA** | `make check E2E=1` |
| **Estilo computado** | sniffCSS (`sniff capture` → `check --rules --uniform`) | contraste **medido**, alvo mínimo, uniformidade, `transition: all` | `SNIFF_A11Y_STRICT=1 tools/sniff-gate.sh` |

**Divisão de trabalho é explícita** (evita verde falso nos dois sentidos):

- **axe é o árbitro de contraste** — ele compõe o fundo dos ancestrais.
- **sniffCSS mede o que o axe não vê**: caixa real do alvo, uniformidade entre irmãos, propriedades
  animadas, alvo mínimo por viewport.
- O sniff **só mede contraste quando o próprio elemento tem fundo opaco**; nos demais casos emite
  `contrast unmeasurable` (limitação da ferramenta, não da página). Isso é exceção **nomeada,
  contada e exibida** — nunca silenciada em massa (§6).

---

## 2. Checklist de DoD de tela (eMAG 3.1 + WCAG 2.2 AA)

| # | Requisito | Critério | Onde é provado |
|---|---|---|---|
| 1 | Semântica | `<header>`, `<nav>`, `<main id="main-content" tabindex="-1">`, `<aside>`, `<footer>`; **um** `<h1>` | PHPUnit estrutural + Playwright |
| 2 | Hierarquia de títulos | sem salto de nível (`h1 → h3`) | Playwright (`04-axe-wcag`) |
| 3 | Skip links | “Ir para o conteúdo”, menu, busca, rodapé; o primeiro `Tab` alcança o conteúdo | Playwright (`02-teclado-e-foco`) |
| 4 | Foco | visível (`:focus-visible`, ≥ 0,1875rem), lógico, sem armadilha; `main` recebe o foco | Playwright + sniff (`foco`) |
| 5 | Teclado | 100% operável por teclado; `Esc` fecha overlays | Playwright (`02-teclado-e-foco`) |
| 6 | Alvos | ≥ **24×24 px** sempre (SC 2.5.8); ≥ **44×44 px** em `pointer: coarse` (padrão da casa) | Playwright (`06-alvos-minimos`) + sniff (`target-size`, bloqueia `fail`) |
| 7 | Contraste | 4.5:1 texto normal; 3:1 texto grande/UI | axe (bloqueia `serious`/`critical`) |
| 8 | Imagens | `alt` obrigatório; decorativa `alt=""`; capa com `alt_text` no upload | Playwright + axe (`image-alt`) |
| 9 | Formulários | `<label for>`, erro com `aria-describedby`/`aria-invalid`, aviso em `aria-live` | Playwright (F4: CMS) |
| 10 | Tabelas | `<th scope>`, `<caption>`, paginação acessível | F4 |
| 11 | Movimento | `prefers-reduced-motion` e opt-in desligam animação; nada pisca > 3×/s | CSS (`@layer a11y`) + sniff |
| 12 | Nativos primeiro | `<dialog>`, `<details>`, popover; ARIA só quando o nativo não basta (APG) | revisão + axe |
| 13 | Preferências | contraste, P&B, espaçamento, dislexia, movimento reduzido e escala de fonte **persistem** (cookie → SSR, sem FOUC) e **não alteram a marcação** | Playwright (`05-preferencias-a11y`) |
| 14 | Sem JS | conteúdo público navega com JS desabilitado; baseline do sistema (`prefers-contrast`/`forced-colors`) | Playwright (`03-no-js`) |
| 15 | Zero CDN | nenhuma requisição fora da origem por padrão; **VLibras** só sob demanda (ADR-002) | Playwright (`03-no-js`) + `tools/vendor-check.sh` |
| 16 | Linguagem | mensagens humanas; erro informa o que fazer e traz o **código de rastreio** | PHPUnit (`ProblemDetailsTest`) |
| 17 | Tema/estrutura | preferências e tema **não mudam a marcação** (ADR-016) | Playwright (`05-preferencias-a11y`) |

**eMAG específico:** barra de acessibilidade sempre disponível (contraste, imagens, espaçamento,
fonte, dislexia, VLibras), atalhos de teclado (F3.I4) e recomendação de leitor de tela por página
(F4).

---

## 3. Alvos e foco (números da casa)

- **24×24 px** — mínimo legal (WCAG 2.2 AA, SC 2.5.8). `sniff check` **bloqueia** (`fail`) abaixo disso.
- **44×44 px** — exigência da casa em ponteiro grosso (`@media (pointer: coarse)`).
- Exceção legítima do SC: link **em linha** dentro de texto corrido (detectado no teste:
  `display: inline` com prosa ao redor). Toda outra exceção precisa entrar na tabela §6.

---

## 4. Acessibilidade universal e a cascata (ADR-016)

- `@layer theme, base, components, utilities, overrides, a11y;` — **`a11y` é a última camada**.
- **Tokens ficam em `@layer theme`** (não em `:root` solto): estilo **fora** de camada vence
  qualquer camada, o que anularia as preferências de acessibilidade. Há **teste de regressão**
  para isso (`DesignTokensTest::tokens_are_declared_inside_a_layer`).
- As preferências entram por `data-a11y-*` no `<html>` (cookie lido no SSR) e **só** trocam tokens,
  contraste, tipografia e imagens — **a marcação nunca muda**.
- Cookies de preferência **não** são criptografados (`bootstrap/app.php` → `encryptCookies(except:)`),
  porque são gravados pelo JS da mesma origem e lidos no servidor.

---

## 5. Histórias e specs E2E (`src/tests-e2e`)

| História | Spec | Cobre |
|---|---|---|
| [01 — Explorar o portal](../../src/tests-e2e/histories/01-explorar-portal.md) | `01-explorar-portal.spec.ts` | home, listagem, detalhe, 404 no shell |
| [02 — Teclado e foco](../../src/tests-e2e/histories/02-teclado-e-foco.md) | `02-teclado-e-foco.spec.ts` | skip links, foco visível, armadilha, busca |
| [03 — Sem JavaScript](../../src/tests-e2e/histories/03-sem-javascript.md) | `03-no-js.spec.ts` | navegação sem JS, SSR das preferências, zero CDN |
| [04 — Axe (WCAG A/AA)](../../src/tests-e2e/histories/04-axe-wcag.md) | `04-axe-wcag.spec.ts` | axe, títulos, landmarks, `alt`, `lang` |
| [05 — Preferências](../../src/tests-e2e/histories/05-preferencias-a11y.md) | `05-preferencias-a11y.spec.ts` | toggles, persistência, efeito real, marcação intacta |
| [06 — Alvos mínimos](../../src/tests-e2e/histories/06-alvos-minimos.md) | `06-alvos-minimos.spec.ts` | 24 px sempre / 44 px no dedo |
| [07 — Acesso e shell do CMS](../../src/tests-e2e/histories/07-cms-shell.md) | `07-cms-shell.spec.ts` | login, shell do painel, Ctrl+K |
| [08 — Notícias no CMS](../../src/tests-e2e/histories/08-cms-noticias.md) | `08-cms-noticias.spec.ts` | criar/publicar, editor rico, `<dialog>` de mídia |
| [09 — Widgets e banners](../../src/tests-e2e/histories/09-widgets-banners.md) | `09-widgets-banners.spec.ts` | perfil por grupo de URL, widget por `id` |
| [10 — Layout do CMS](../../src/tests-e2e/histories/10-cms-layout.md) | `10-cms-layout.spec.ts` | admin surface do Layout (só no painel) |
| [11 — Performance](../../src/tests-e2e/histories/11-performance.md) | `11-performance.spec.ts` | CLS e orçamento de Core Web Vitals |
| [12 — Dados abertos](../../src/tests-e2e/histories/12-dados-abertos.md) | `12-dados-abertos.spec.ts` | catálogo, downloads CSV/JSON/XML, `ETag` |
| [13 — LGPD](../../src/tests-e2e/histories/13-lgpd.md) | `13-lgpd.spec.ts` | política e canal do titular (DSAR) |
| [14 — Cabeçalhos de segurança](../../src/tests-e2e/histories/14-security-headers.md) | `14-security-headers.spec.ts` | CSP/HSTS emitidos pelo edge |
| [15 — Galeria em lote](../../src/tests-e2e/histories/15-cms-galeria-lote.md) | `15-cms-galeria-lote.spec.ts` | lote com alt por imagem, progresso e catálogo |
| [16 — CMS mobile](../../src/tests-e2e/histories/16-cms-mobile.md) | `16-cms-mobile.spec.ts` | off-canvas, alvos ≥ 24 px e axe em Pixel 7 |
| [17 — Configurações](../../src/tests-e2e/histories/17-configuracoes-cms.md) | `17-cms-configuracoes.spec.ts` | seção Geral, 422/404/403, foco no erro e axe |
| [18 — Estrutura do site](../../src/tests-e2e/histories/18-estrutura-site.md) | `18-estrutura-site.spec.ts` | tema/`theme.css`, posição da sidebar (foco), menus do portal e axe |
| [19 — Usuários do painel](../../src/tests-e2e/histories/19-cms-usuarios.md) | `19-cms-usuarios.spec.ts` | criar/login/desativar, 403/422, tabela semântica e axe |
| [20 — Perfis e matriz de permissões](../../src/tests-e2e/histories/20-cms-papeis.md) | `20-cms-papeis.spec.ts` | matriz semântica/teclado, ação desabilitada, exclusão |
| [21 — Perfil próprio](../../src/tests-e2e/histories/21-cms-perfil.md) | `21-cms-perfil.spec.ts` | troca da própria senha, erro de validação, axe |
| [22 — Acesso no mobile e sem JS](../../src/tests-e2e/histories/22-cms-acesso-mobile.md) | `22-cms-acesso-mobile.spec.ts` | `/usuarios`, `/papeis`, matriz e `/perfil` no Pixel 7 (axe + alvo) e a matriz sem JavaScript |
| [23 — Painel de suporte](../../src/tests-e2e/histories/23-suporte-painel.md) | `23-suporte-painel.spec.ts` | grupo invisível e 404 para quem não é da equipe; a equipe vê a visão geral e desativa/reativa módulo com motivo (404/200 imediatos, axe); diagnóstico e leitor da trilha (filtro + detalhe, axe); manutenção 503/200; contato do fornecedor (vazio e preenchido, links seguros, axe) |
| [24 — Estatísticas](../../src/tests-e2e/histories/24-estatisticas.md) | `24-estatisticas.spec.ts` | visita ao portal vira métrica no relatório, filtro de período (preset e custom UTC) e export CSV como anexo, com axe |
| [25 — Editor da home](../../src/tests-e2e/histories/25-editor-home.md) | `25-cms-editor-home.spec.ts` | editor do rascunho da home: seção nova, `<dialog>` nativo, prévia e caminho sem JS, com axe |
| [26 — Publicação e prévia da home](../../src/tests-e2e/histories/26-publicacao-previa-home.md) | `26-cms-publicacao-home.spec.ts` | publicar, portal por locale, prévia autenticada com `noindex` e axe desktop/mobile |
| [27 — Páginas do site](../../src/tests-e2e/histories/27-cms-paginas.md) | `27-cms-paginas.spec.ts` | lista/formulário de página, título por locale, slug recusando rota viva, caminho sem JS e axe desktop/mobile |
| [28 — Página estática no portal](../../src/tests-e2e/histories/28-portal-paginas.md) | `28-cms-paginas-portal.spec.ts` | prévia autenticada `noindex`, publicação com canônica/`hreflang`, arquivamento com 404 noindex, rota viva preservada e axe na tela e na página pública |
| [29 — Destaques do site](../../src/tests-e2e/histories/29-cms-destaques.md) | `29-cms-destaques.spec.ts` | criar destaque com mídia do catálogo, ativar/desativar preservando o registro, arquivar com confirmação nativa, filtro de arquivados e validação server-side sem JS; axe desktop/mobile |
| [30 — Widgets reais por slot](../../src/tests-e2e/histories/30-widgets-slots.md) | `30-cms-widgets-slots.spec.ts` | catálogo derivado do registry no `/cms/layout`, widgets de outros módulos na coluna da página (notícias/datasets) e axe desktop/mobile |
| [31 — Composição da home](../../src/tests-e2e/histories/31-home-composicao.md) | `31-cms-home-composicao.spec.ts` | reordenar por teclado, ocultar preservando a posição, publicar a ordem no portal; ponteiro real no `Pixel 7` (sem sobreposição) e axe desktop/mobile |
| [32 — Banner com vigência](../../src/tests-e2e/histories/32-banner-vigencia-modulo-inativo.md) | `32-cms-banner-vigencia.spec.ts` | peça vigente/futura/expirada com `alt`, CLS ≤ 0,1, módulo inativo esvaziando o slot e axe desktop/mobile |
| [33 — Fonte do dataset](../../src/tests-e2e/histories/33-dados-abertos-fonte.md) | `33-cms-dados-abertos-fonte.spec.ts` | select de fonte do motor (gerado × curado), aviso de catálogo vazio, foco/mensagem na fonte recusada e criação manual sem JS |
| [34 — Listas, filtros e formulários](../../src/tests-e2e/histories/34-listas-formularios.md) | `34-cms-listas-formularios.spec.ts` | filtro `GET` compartilhável com resumo removível e contagem, estado vazio com ações, “alterações não salvas”/`beforeunload`, envio duplo descartado com submitter preservado, sem JS e axe desktop/mobile |

Projetos: `portal-desktop` (1366×768), `portal-mobile` (`Pixel 7`, `pointer: coarse`),
`portal-no-js`, `cms-desktop` (domínio próprio do painel — ADR-037), `cms-mobile` (`Pixel 7`) e
`cms-serial` (lote global: tema/shell, ativação de módulo, home publicada e perfil de layout — um
worker, antes dos demais via `dependencies`). `workers: E2E_WORKERS` (default
4, paralelismo **por arquivo**: dentro do arquivo a ordem é contrato; `--fully-parallel` colide no lote
da galeria), `retries: 0`
(ADR-001: sem flaky). O axe **exige JS**, então no projeto sem-JS o gate é estrutural.

---

## 6. Exceções conhecidas (nunca em massa)

| Exceção | Por que | Onde está |
|---|---|---|
| `contrast unmeasurable` (sniff) | a ferramenta só mede o fundo **próprio** do elemento | `tools/sniff-gate.sh` → `SNIFF_GATE_IGNORE` |
| `text-not-selectable` em botão | `user-select: none` é padrão do daisyUI em controles | idem |

Toda exceção é **contada e impressa** pelo gate (`ignored=N`) e reflete o ledger acima. `fail`
medido **nunca** é isento.

---

## 7. Evidência (o que o gate executa)

```bash
make check E2E=1                                             # cadeia completa: e2e (Playwright + axe) + a11y (sniff) + perf
SNIFF_A11Y_STRICT=1 SNIFF_VIEWPORT=1366x768 tools/sniff-gate.sh   # sniff estrito — desktop
SNIFF_A11Y_STRICT=1 SNIFF_VIEWPORT=390x844  tools/sniff-gate.sh   # sniff estrito — mobile
make check                                                   # modo rápido: a11y roda sempre; e2e/perf são opt-in
```

> `tools/sniff-gate.sh` mede **um** viewport por invocação (`SNIFF_VIEWPORT`) e as páginas de
`KEY_PAGES` (default `/ /dados-abertos`); a cadeia roda os dois viewports e o login do CMS
(`tools/check.sh` → `a11y_gate`). O gate resolve o binário `sniff` no PATH ou a imagem
pinada por digest (ADR-031).

A cadeia roda em modo **estrito por padrão** (`tools/check.sh` → `SNIFF_A11Y_STRICT=1`): um `fail`
medido bloqueia sempre e um `warn` fora do ledger §6 também. No CI, o job `check` roda
`make check E2E=1` — a11y bloqueia merge.

---

## 8. Fora de escopo / donos

| Item | Dono | Fase |
|---|---|---|
| Teste manual com leitor de tela (NVDA/VoiceOver/TalkBack) nas histórias-chave | UX | revisão por release (a automação cobre ~40%) |
| Atalhos de teclado eMAG (`Alt+1..5`) | Layout | F3.I4 |
| Reflow/zoom extremo (400%), `prefers-color-scheme` | Layout | F4 (tema escuro depende de decisão de design) |
| Formulários, tabelas, modais do CMS | CMS | F4.I1 |
| PWA offline acessível | Layout | fase de PWA (ADR-010) |
