# Auditoria de UI / UX / Acessibilidade — Nexus (julho/2026)

Revisão completa das quatro frentes: **operação/portaria em tempo real**, **gestão/admin
(formulários e tabelas)**, **morador (mobile/PWA)** e **acessibilidade (WCAG 2.1 AA)**.

Escopo varrido: 165 views, `app.css` (681 linhas), `app.js`, `push.js`, `sw.js`, layout,
componentes de navegação e o manifest do PWA.

Este documento registra **o que estava errado, o que foi corrigido e o que ficou pendente**.
As regras que passaram a valer estão no `CLAUDE.md` §3.1 — leia antes de mexer na interface.

---

## 1. Resumo

| Severidade | Achados | Corrigidos | Pendentes |
|---|---|---|---|
| **P0** — quebra de uso / risco operacional / barreira séria | 11 | 11 | 0 |
| **P1** — atrito real | 14 | 14 | 0 |
| **P2** — polimento | 9 | 8 | 1 (decisão consciente — §5) |

O achado mais grave não foi de estética: **11 confirmações de ações destrutivas nunca apareciam**
(a ação disparava direto), e **o painel da portaria mostrava dado velho como se fosse atual**
quando a rede caía.

---

## 2. P0 — corrigidos

### 2.1 Confirmação de ação destrutiva que NUNCA aparecia
O `app.js` interceptava `form[data-confirm]` e `a[data-confirm]`, mas **não** `button[data-confirm]`.
Havia 11 botões assim, em 8 telas — incluindo **excluir documento**, **remover item de checklist**,
**cancelar OS**, **excluir plano preventivo**, **ligar/desligar a bomba d'água**. O usuário clicava e
a ação executava **sem nenhuma pergunta**, exatamente onde se acreditava haver uma trava.

Corrigido em `public/assets/js/app.js` (novo handler de clique em `button[data-confirm]`, usando
`form.requestSubmit(btn)` para preservar o submitter).

### 2.2 Portaria vendo dado velho achando que é atual
No `/monitor`, se o feed caísse (rede, backend fora), o selo verde **"● Ao vivo" continuava
pulsando** e a lista simplesmente parava de crescer. Mesmo problema no painel de **água** (o nível
congelava — e alguém pode ligar a bomba olhando um número velho) e no **mural**.

Corrigido: em `monitor/screen.php`, `automation/water.php`, `mural/screen_sistemas.php` e
`mural/screen_cameras.php`, o poll conta falhas consecutivas e, na 2ª, troca o indicador para
**"SEM CONEXÃO — última atualização HH:MM:SS"** (vermelho, sem pulso). Vocabulário compartilhado
no CSS: `.live-flag` / `.live-flag.stale`.

Além disso: no mural, **"Carregando…" era verde** (`status ok`) — visualmente idêntico a "tudo
normal". Se a primeira chamada falhasse, a TV ficava exibindo um ✅ verde para sempre. Agora o
carregando é neutro e o erro é vermelho. E a tela de câmeras, que ficava **preta** se o primeiro
load falhasse (indistinguível de monitor desligado), agora mostra o erro.

### 2.3 Contraste: nenhum botão sólido passava em AA
Calculado com a fórmula WCAG: **nenhum** ponto do gradiente de `.btn-primary`, `.btn-success` e
`.btn-danger` atingia 4.5:1 com o texto branco — em nenhum dos dois temas. No tema escuro chegava a
**2.14:1**. São os botões de Salvar/Confirmar/Excluir.

Badges, alertas, flash e `env-tag` reprovavam pelo mesmo motivo: usavam a cor de acento como cor de
texto (o `--warning` dava **2.61:1**, e nem sobre branco puro chega a 3:1).

Corrigido com tokens novos no `app.css`:
- `--success-text` / `--warning-text` / `--danger-text` / `--info-text` / `--neutral-text` /
  `--accent-text` → versões escurecidas, medidas para ≥4.5:1 sobre superfície e sobre `*-soft`.
- `--btn-primary-bg` / `--btn-success-bg` / `--btn-danger-bg` + `--on-accent` → botão de **cor
  cheia** (sem gradiente). No claro: texto branco sobre fundo escurecido. No escuro: tinta escura
  sobre a cor viva (padrão de dark UI).
- `--text-faint` no escuro estava em 3.3:1 sobre `--surface-3` — clareado.

### 2.4 Autoatendimento de foto: beco sem saída
Em `/minha-foto`, se o morador **negasse a permissão da câmera** (comum na 1ª vez), o código
**escondia** o botão de captura manual e desabilitava o início — sem `<input type="file">` em lugar
nenhum. Não havia **nenhum caminho** para concluir. Idem em desktop sem webcam.

Corrigido: botão **"Enviar foto do arquivo"** sempre disponível, com recorte 3:4 e **a mesma
validação de QA** do fluxo da câmera (`/minha-foto/qa`). A falha de permissão agora devolve o
usuário às instruções, onde a alternativa está visível.

### 2.5 Modal de confirmação inacessível
Sem foco preso (Tab escapava para a página atrás), sem devolver o foco ao fechar, e o **foco inicial
caía no botão vermelho "Confirmar"** — um Enter reflexo executava a ação destrutiva. Corrigido:
focus trap, `aria-labelledby`, foco inicial no **Cancelar**, foco devolvido ao fechar.

### 2.6 Ordenação de tabela era mouse-only
O `<th>` clicável não tinha `tabindex`, `role` nem handler de teclado: **impossível ordenar por
teclado** em ~30 tabelas. E o estado não era anunciado. Corrigido (Enter/Espaço + `aria-sort`).

### 2.7 Flash sem `aria-live`
`Flash::success/error` é o **único** feedback de salvar/excluir em ~150 telas — e não tinha
`role`/`aria-live`. Leitor de tela nunca anunciava. Corrigido no layout.

### 2.8 Botão de push cobrindo a navegação
O botão "Ativar notificações" era `position:fixed; bottom:16px; z-index:9999` — **por cima** da
bottom-nav (64px, z-index 40), cobrindo o último item do menu no celular. Corrigido (fica acima da
barra) + agora usa `.btn` do design system e explica como reverter se o usuário bloquear.

### 2.9 PWA não instalável
O `manifest.webmanifest` **não tinha `icons`** — e não existia nenhum ícone no projeto (nem
favicon). Gerados `icon-192`, `icon-512`, `icon-maskable-512`, `apple-touch-icon` e `favicon.ico`,
e declarados no manifest.

---

## 3. P1 — corrigidos

- **Rótulos não associados**: ~81% dos arquivos com `<label>` não usavam `for`/`id`. **406 pares**
  associados (211 em formulários + 132 em listas/filtros + os já existentes). Zero `for` órfão.
- **Filtros sem rótulo**: 15 telas em que o campo de busca só tinha `placeholder` → `aria-label`.
- **Tabelas sem `scope`**: **287 `<th>`** receberam `scope="col"` (eram zero no sistema inteiro).
- **`aria-current="page"`** no item de menu ativo (sidebar, drawer, bottom-nav).
- **Sidebar era `<aside>`** → virou `<nav>` (landmark de navegação).
- **"Menu" da bottom-nav era `<a href="#">`** → virou `<button>` com `aria-expanded`/`aria-controls`.
- **Drawer e modal "Abrir chave"**: `aria-modal`, `aria-labelledby`, fecham com **ESC**.
- **Paginação**: `<nav aria-label>`, `aria-label` nos ‹ ›, `aria-current` na página atual.
- **`data-once` sem watchdog**: botão travava em "Processando…" para sempre se a rede engasgasse.
- **Aprovar reserva / Registrar retirada**: sem `data-once` (risco de duplo POST) → corrigido.
- **Status só por cor**: em Minha Casa o online/offline era um ponto colorido + `title` (que não
  existe em toque) → agora tem **texto** ("Conectado" / "Sem conexão").
- **Alvo de toque**: fechar-flash tinha ~18×22px; ícones do header, 38px → 44×44 em toque.
- **Estados vazios**: `/inicio` (primeira tela do morador) dizia só "Nenhum atalho disponível";
  `/os` não oferecia "Nova OS". Corrigidos com ícone + orientação + CTA.
- **QR sem fallback**: se a CDN caísse, o convite mostrava um quadrado em branco → texto alternativo.
- **Confirmação sempre vermelha**: ligar a bomba (rotina) usava o mesmo botão de perigo de excluir →
  `data-confirm-kind="normal"`.

---

## 4. Segunda rodada — as pendências (RESOLVIDAS)

### 4.1 Erro de validação agora aponta O CAMPO
Era o item de maior impacto. Descobrimos que a correção era **muito mais barata** do que a estimativa
inicial: o domínio **já produz** o mapa `campo => mensagem` (`Validator` → `ApiException::getFields()`),
e o `Controller::handleDomainError` **já o recebia** — apenas o descartava, concatenando tudo num
Flash genérico. Não foi preciso mexer em 45 controllers.

O que foi feito:
- `handleDomainError` guarda o mapa na sessão (`_errors`), ao lado do `_old_input` que já existia.
- `Helpers` ganhou `errors()`, `hasErrors()`, `errorMsg()`, **`invalid($campo)`** (devolve
  `aria-invalid` + `aria-describedby`) e **`fieldError($campo)`** (devolve o `<p class="field-error">`).
  A classe `.field-error`, que existia no CSS e **nunca havia sido usada**, agora é usada.
- **294 campos** em 36 formulários foram instrumentados.
- O `app.js` **rola e foca o primeiro campo inválido** ao carregar a página.
- O layout limpa `_errors` após uma renderização (mesma vida útil do `_old_input`).

### 4.2 Cores de status → `H::badge` (tokens, tema escuro)
Havia **dois** padrões de badge com hex fixo, ambos reprovando em AA:
- fundo `hex+22` com o **mesmo hex como texto** (reservas, encomendas, convites, ocorrências,
  prestadores, assembleias);
- **cor cheia com texto branco** (OS, pendências, ocorrências de obra, compras, equipes, planos) —
  branco sobre laranja `#d97706` dá ~2.6:1.

Convertidas **19 views** para `H::badge($txt, $variant)`. Casos que não passavam pelo badge também
foram corrigidos: a **barra do calendário** e a **barra do Gantt** (novas classes `.status-bar` +
`.sb-*`, com fundo suave + texto no token escuro) e a **prioridade no Kanban**. Os hex que
permanecem no Kanban são **decorativos** (ponto da coluna, borda do card) — uso legítimo de cor de
acento.

### 4.3 Foto perdida na revalidação
O navegador nunca restaura `input[type=file]`. O form de pessoa agora **avisa explicitamente**
(quando `H::hasErrors()`) que a foto precisa ser selecionada de novo.

### 4.4 Obrigatórios com legenda
"* campos obrigatórios" (`.form-required-note`) em **42 formulários** — o asterisco sozinho não é
anunciado por leitor de tela nem óbvio para quem não conhece a convenção.

### 4.5 "Limpar filtros"
Link **Limpar** em **24 listagens**, exibido só quando há filtro ativo (volta à URL base).

### 4.6 Hierarquia de headings
**56 telas** usavam `<h2>` como título principal e ficavam **sem nenhum `<h1>`** (o layout não
fornece um), quebrando a navegação por headings. Todas promovidas a `<h1>`, com o tamanho
normalizado no CSS (`.page-head h1`) para os títulos não ficarem gigantes.

---

## 5. Decisão consciente: os dois padrões de tabela responsiva ficam

7 telas (people, vehicles, users, events, access_logs, incontrol/users, audit) resolvem o mobile com
**markup duplicado** (`mobile-only` / `desktop-only`); as outras 34 usam **`cards-mobile`** (CSS puro).

**Não unificamos de propósito.** Os cards mobile dessas 7 telas são mais ricos que a tabela desktop
equivalente (miniatura de foto, agrupamento próprio) — convertê-los para `cards-mobile` **perderia
informação** em troca de elegância de código. O risco real do padrão duplo é a divergência entre as
duas versões ao longo do tempo; isso fica mitigado pela regra registrada no `CLAUDE.md` §3.1:
**tela nova usa `cards-mobile`**; o markup duplicado é legado e não deve ser replicado.

---

## 6. Como validar no servidor

Nada aqui exige migração de banco. Depois de subir os arquivos:

```
php api/scripts/static_check.php
```

Confira visualmente, com atenção a:
- **Botões** (Salvar/Excluir): agora são cor cheia, sem gradiente — inclusive no tema escuro.
- **Excluir qualquer coisa**: o modal de confirmação deve aparecer (antes, em 11 lugares, não aparecia).
- **`/monitor`**: derrube a rede por ~10s — o selo deve virar "SEM CONEXÃO".
- **`/minha-foto`**: negue a câmera — o botão "Enviar foto do arquivo" deve resolver.
- **Celular**: instalar o PWA (agora tem ícone) e conferir que o botão de notificações não cobre o menu.
- **Erro de validação**: salve uma pessoa do tipo *visitante* **sem validade** — a página deve rolar
  até o campo, marcá-lo em vermelho e mostrar a mensagem **embaixo do campo** (não só no topo).
- **Tema escuro**: abra `/os` e `/reservas` — os badges de status devem estar legíveis (antes eram
  branco sobre laranja).

### Números da revisão

| | |
|---|---|
| `<th scope="col">` adicionados | 287 |
| pares `label` ↔ campo associados | 406 |
| campos com erro por campo (`aria-invalid` + `.field-error`) | 294 |
| formulários com legenda de obrigatórios | 42 |
| listagens com "Limpar filtros" | 24 |
| telas promovidas a `<h1>` | 56 |
| views com badge convertido para tokens | 19 |
| confirmações destrutivas que voltaram a existir | 11 |
