Padrões de UI — Frontend (SSOT)
Documento mestre dos padrões visuais e de composição de componentes do frontend OLP. Regra de ouro: quando um componente da biblioteca (src/components/ui/*) precisa de ajuste consistente, corrija na primitiva, nunca caso a caso no consumer.
1. Princípios
- shadcn/Radix como base. Toda primitiva visual vive em
src/components/ui/*. Consumers compõem, não reescrevem. - Correção na raiz. Se um padrão (ex: altura, espaçamento, cor) precisa valer em todo o sistema, ajuste o componente base — não o cliente.
- Override consciente. Consumers podem sobrescrever via
classNameapenas com justificativa clara (ex: lista que precisa de mais altura por agrupamento). Documentar inline. - Tokens semânticos. Cores via tokens HSL (
bg-primary,text-foreground), nunca cores diretas (bg-blue-500).
2. Select / Dropdown — max-h-[200px]
Regra: todo <SelectContent> exibe no máximo ~6 itens (200px) antes de ativar scroll interno.
// src/components/ui/select.tsx — SelectContent
className={cn(
"... max-h-[200px] ...",
className,
)}Por quê: listas longas (ex: 10+ olimpíadas) "vazam" o layout do modal pai. Cap fixo garante UX previsível e sem regressões em qualquer modal/popover.
Cálculo: SelectItem (~32px com py-1.5 + text-sm) × 6 + Viewport p-1 (8px) ≈ 200px.
Override autorizado:
<SelectContent className="max-h-[280px]"> {/* +2 itens — listas com agrupamento */}Anti-padrão:
{/* ❌ Decidir altura ad-hoc em cada consumer (gera inconsistência) */}
<SelectContent className="max-h-72">
<SelectContent className="max-h-[350px]">
<SelectContent> {/* sem cap → vaza layout */}3. Tooltips em primitivas Radix — Proibido
Não envolver SelectItem, DropdownMenuItem ou similares com <TooltipTrigger>. Radix exige controle interno de ref/foco; o wrapper de Tooltip quebra a navegação por teclado e gera warning cannot be given refs.
Alternativa: usar title="..." HTML nativo no próprio item.
Memória relacionada:
mem://ui/radix-primitive-composition-restriction
4. Cabeçalhos de Seção (TutorialModal)
O container que envolve <title + subtítulo> e <TutorialModal /> deve usar items-start (não items-center) para alinhar o botão "Como usar" ao topo do título — cria linha horizontal harmoniosa.
{/* ✅ Correto */}
<div className="flex items-start justify-between">
<div>
<h1>Agenda</h1>
<p className="text-muted-foreground">Subtítulo...</p>
</div>
<TutorialModal slug="..." />
</div>5. Modais (Dialog)
- Usar
<Dialog>da biblioteca; nunca rolar manual. - Conteúdo extenso: aplicar
max-h-[80vh] overflow-y-autonoDialogContentinterno (não no body). hideCloseButtonapenas em fluxos onde o fechamento é controlado por ação de negócio (ex: confirmação obrigatória).- Modais densos (6+ campos OU altura > 600px): aplicar padrão 3-zonas (header fixo / corpo scrollável / footer fixo) com tokens compactos (
h-9,text-xs,space-y-3,size="sm"). VerFRONTEND_MODAL_DENSITY_STANDARD.md. Nunca alterar a primitivasrc/components/ui/dialog.tsxpara resolver overflow — solução sempre localizada no consumer.
6. Toast — olpToast
Sempre olpToast (@/lib/olp-toast). Proibido sonner direto, toast() cru ou alert(). Em onError: getUserFriendlyError(error) antes do toast — nunca error.message.
7. Loading / Anti-Flash
Todo componente com fetch+cache deve guardar com:
if (isLoading && !data) return <Skeleton />;Evita flash quando a query é re-buscada em background com cache pré-populado.
Referência completa:
ATOMIC_RENDERING.md
8. Tokens Visuais — Referência Rápida
| Contexto | Token / Classe |
|---|---|
| Background app | bg-background |
| Texto principal | text-foreground |
| Texto secundário | text-muted-foreground |
| Card | bg-card text-card-foreground |
| Ação primária | bg-primary text-primary-foreground |
| Ação destrutiva | bg-destructive text-destructive-foreground |
| Header de tabela (Controle) | bg-blue-400 (decisão de negócio — coordenador) |
| Borda padrão | border-border |
| Foco | focus-visible:ring-ring |
9. Espaçamento Vertical Global
Container de página (src/App.tsx): pt-3 / pb-3 no header de novidades para reduzir gap exagerado. Conteúdo interno usa space-y-6 por padrão.
9.1 Anti-padrão: UUID exposto na UI
PROIBIDO renderizar UUIDs (de qualquer entidade — nivel_id, usuario_id, escola_id, papel_id, olimpiada_id, etc.) em superfícies visíveis ao usuário final: labels, badges, listas, tabelas, toasts, tooltips, breadcrumbs, títulos de modal, mensagens de erro amigáveis, dropdowns.
Permitido apenas em:
- Logs (
console.*sanitizados,registrarLog()). - Atributos
data-*(ex.:data-id="..."para testes/automação). - URLs internas (
/admin/.../:id) — mas a UI sempre renderiza o nome. - Mensagens técnicas para suporte com prefixo explícito (ex.:
"ID técnico: …").
Regra para Record<uuid, T> em rendering
Quando um mapa indexado por UUID (ex.: questoesPorNivel, pontuacaoMaximaPorNivel, fasesPorNivel) precisa ser exibido, SEMPRE resolver a chave contra a lista canônica de entidades (niveisPersonalizados, papeis, escolas, etc.) e renderizar o nome. Se a chave for UUID e não resolver: filtrar a entrada (não exibir), nunca cair em fallback que imprima o UUID.
Helper-padrão:
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function resolveLabel<T extends { id?: string; nome: string }>(
key: string,
lookup: T[] | undefined,
): string | null {
const byId = lookup?.find(x => x.id === key);
if (byId) return byId.nome;
const byNome = lookup?.find(x => x.nome === key);
if (byNome) return byNome.nome;
if (UUID_REGEX.test(key)) return null; // não vaza UUID
return key; // sigla legacy aceita (B, C, E…)
}Caso de referência: FasesNiveisCard em src/components/olimpiadas.tsx.
10. Hub-Shell pattern
Padrão de navegação para telas onde N entidades irmãs paralelas convivem, cada uma com configuração densa demais para modal e transversal demais para um wizard linear.
Estrutura canônica
Tab raiz (ex.: Comunicação > Canais)
├── Landing N cards ← entrada visual; status de saúde por entidade
└── Painel da entidade
├── Header com Voltar + nome + badge de status
├── Pills horizontais ← seções internas (não Tabs aninhadas)
└── Conteúdo da seção ← largura total; cada seção decide layoutEstado em URL via query params (deep-link obrigatório): ?tab=<raiz>&<chave>=<entidade>&secao=<secao>
Quando aplicar
Os 3 critérios precisam ser verdadeiros:
- 3+ entidades irmãs paralelas — não hierárquicas; cada uma autônoma.
- Configuração densa por entidade — >1 sub-tela ou >5 campos significativos cada.
- Alternância com contexto — o operador precisa pular entre entidades mantendo a noção do todo (não é um fluxo linear).
Quando NÃO aplicar
- CRUD plano (lista + dialog basta).
- Wizard linear (passos sequenciais com fim definido).
- Listagem simples + filtros (Tabs do shadcn já resolve).
- 1–2 entidades só (não há ganho de organização).
Detalhes obrigatórios
- Pills horizontais, não
<Tabs>shadcn aninhada — Tabs dentro de Tabs vira ruído visual no hub. - Botão Voltar sempre no canto superior esquerdo do painel; limpa
?<chave>e?secaoda URL. - Status de saúde no card da landing — uma badge por entidade (
ok/warn/off/neutral) dá leitura operacional imediata. - Lazy Mount Once preservado no nível superior (tab) e dentro do shell quando seções fazem fetch pesado.
- Seções read-only explícitas quando a configuração de fato fica fora do app (ex.: rate-limit do WhatsApp, DNS do domínio) — não criar inputs decorativos que enganem o operador.
Casos de referência
src/components/admin-comunicacao/enviar/— landing 6 cards (canal × modo) → composer com pills do modo Individual/Massa.src/components/admin-comunicacao/canais/— landing 3 cards (E-mail / WhatsApp / Push) → painel do canal com pills por seção.
Componentes reutilizáveis
canais/channel-shell.tsx— header + pills + slot de conteúdo.canais/channel-card.tsx— card da landing com badge de status.canais/pills.tsx—<Pills>genérico comaria-pressed/role=tab.
11. Quando criar/atualizar este documento
- Nova primitiva em
src/components/ui/*→ adicionar seção aqui. - Decisão visual aplicada em ≥3 telas → padronizar e documentar aqui.
- Override de regra existente → registrar exceção com justificativa nesta seção.
Referências cruzadas:
CODING_STANDARDS.md— padrões gerais de códigoATOMIC_RENDERING.md— renderização atômicamem://ui/select-dropdown-max-height-standard— regra do cap em Selectmem://ui/radix-primitive-composition-restriction— restrições de composição Radix