Skip to content

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

  1. shadcn/Radix como base. Toda primitiva visual vive em src/components/ui/*. Consumers compõem, não reescrevem.
  2. 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.
  3. Override consciente. Consumers podem sobrescrever via className apenas com justificativa clara (ex: lista que precisa de mais altura por agrupamento). Documentar inline.
  4. 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.

tsx
// 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:

tsx
<SelectContent className="max-h-[280px]">  {/* +2 itens — listas com agrupamento */}

Anti-padrão:

tsx
{/* ❌ 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.

tsx
{/* ✅ 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-auto no DialogContent interno (não no body).
  • hideCloseButton apenas 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"). Ver FRONTEND_MODAL_DENSITY_STANDARD.md. Nunca alterar a primitiva src/components/ui/dialog.tsx para 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:

tsx
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

ContextoToken / Classe
Background appbg-background
Texto principaltext-foreground
Texto secundáriotext-muted-foreground
Cardbg-card text-card-foreground
Ação primáriabg-primary text-primary-foreground
Ação destrutivabg-destructive text-destructive-foreground
Header de tabela (Controle)bg-blue-400 (decisão de negócio — coordenador)
Borda padrãoborder-border
Focofocus-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:

ts
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

text
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 layout

Estado em URL via query params (deep-link obrigatório): ?tab=<raiz>&<chave>=<entidade>&secao=<secao>

Quando aplicar

Os 3 critérios precisam ser verdadeiros:

  1. 3+ entidades irmãs paralelas — não hierárquicas; cada uma autônoma.
  2. Configuração densa por entidade — >1 sub-tela ou >5 campos significativos cada.
  3. 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 ?secao da 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 com aria-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ódigo
  • ATOMIC_RENDERING.md — renderização atômica
  • mem://ui/select-dropdown-max-height-standard — regra do cap em Select
  • mem://ui/radix-primitive-composition-restriction — restrições de composição Radix