Fundações
Foco & acessibilidade
O anel de foco
@utility focus-ring {
@apply outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50;
}
/* Mesmo anel, no roxo do agente: território de IA foca em roxo. */
@utility focus-ring-ia {
@apply outline-none focus-visible:ring-[3px] focus-visible:ring-(--ring-ia)/50;
}| Estado | Receita | Onde |
|---|---|---|
| Foco padrão | focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 | Todo campo e todo botão. |
| Foco de IA | focus-ring-ia · ring-(--ring-ia)/50 | O composer do agente e o que é território de IA. É o ÚNICO foco roxo do app. |
| Campo inválido | aria-invalid:border-destructive aria-invalid:ring-destructive/20 | Vem do atributo, não de classe condicional — quem marca é o Field. |
| Card com controle em foco | focus-within:border-brand focus-within:ring-[3px] focus-within:ring-brand-muted | O card acende enquanto se edita dentro dele. |
| Slot de OTP ativo | data-[active=true]:border-ring data-[active=true]:ring-[3px] | O foco real está num input invisível; o slot desenha o estado. |
Sim
Um alvo novo herda a medida do canon com uma classe. Mudar o anel do app vira uma linha em globals.css.
Não
Desligar o contorno nativo sem pôr o anel no lugar deixa quem navega por teclado sem saber onde está. É a falha de acessibilidade mais comum de um design system.
Notafocus-visible, não focus
focus-visible: quem clica com o mouse não vê anel nenhum, quem chega de teclado vê. A exceção é o foco em camadas, que dispara em :focus puro — ali o gatilho precisa pegar o foco entregue por código e o toque no celular, que nunca marcam focus-visible.Alvo de toque
default: "h-10 … pointer-coarse:h-11" /* CTA 40 → 44px */ sm: "h-8 … pointer-coarse:h-10" /* toolbar 32 → 40px */ xs: "h-6 … pointer-coarse:h-8" /* chip 24 → 32px */ icon: "size-10 pointer-coarse:size-11" // Input e SelectNative: h-9 → pointer-coarse:h-10 // Checkbox: size-4 → pointer-coarse:size-5
| Regra | Detalhe |
|---|---|
| Mínimo confortável | 44px no dedo pro alvo principal da tela; 40px pros controles de apoio. |
| Alvo pequeno pede área | Um dot ou ícone de 14px dentro de um botão de 32px está certo: o alvo é o botão, não o desenho. |
| Nunca dois alvos colados | Cluster de ações usa gap-1.5 no mínimo; “Editar” e “Excluir” encostados viram exclusão acidental. |
| Ação principal ao alcance do polegar | No celular os botões de diálogo empilham em flex-col-reverse: o confirmar fica embaixo. |
Teclado
| Padrão | Comportamento | Nota |
|---|---|---|
| Abas (tablist) | role=tablist/tab + aria-selected + ← → trocam de aba, com roving tabIndex (só a ativa é tabulável) | aria-pressed numa aba é o papel errado — é crachá de toggle. |
| Radiogroup (temas, este seletor) | role=radiogroup/radio + aria-checked + setas | Mesmo desenho das abas: uma parada de Tab pro grupo inteiro. |
| Diálogo | Esc fecha, foco preso dentro, foco volta pro gatilho ao sair | O Radix cuida — por isso o Dialog não é reimplementado à mão. |
| Tarja de confirmação | Abre com o foco no Cancelar; Esc cancela; cancelar devolve o foco pro gatilho | Sem devolver o foco, o próximo Tab recomeça do topo da página. |
| Rail e sidebar | aria-current=page no item ativo | É o que o leitor de tela usa pra dizer “você está aqui”. |
| Sidebar recolher | ⌘B | A preferência mora em cookie: o servidor precisa da largura no primeiro HTML. |
| Busca do inbox / deste guia | ⌘K | Foca o campo; não abre modal por cima do que se estava lendo. |
Armadilha já pagaFoco perdido é tarefa perdida
<body> e o próximo Tab recomeça do início da página. Todo componente que remove o próprio gatilho precisa devolver o foco — e só quando foi a PESSOA que fechou, nunca na primeira pintura.Leitor de tela
Erro de campo: a convenção {id}-error
aria-invalid={Boolean(messages)}
aria-describedby={[erro && `${id}-error`, hint && `${id}-hint`].filter(Boolean).join(" ")}
<FieldError id={`${id}-error`} role="alert" /> // fala mesmo com o foco no botão
<p id={`${id}-hint`} class="text-xs text-fg-subtle">…</p>A dica entra no aria-describedby junto com o erro, e a ordem importa: erro primeiro, porque é o que precisa ser ouvido antes. Enquanto só o erro entrava, quem tabulava direto pro campo ouvia o rótulo e não ouvia a instrução — justamente as frases que explicam por que o campo existe.
| Situação | Marcação |
|---|---|
| Erro geral do formulário | role="alert" no FormAlert — e ele rola até ficar visível quando surge |
| Carregando uma região | role="status" aria-busy="true" aria-label="Carregando" |
| Estado que muda sozinho (salvo, salvando) | aria-live="polite" |
| Diálogo | DialogTitle é obrigatório — é o nome acessível do modal |
| Botão só de ícone | aria-label com o verbo e o objeto: “Excluir Marina Alvez” |
| Ícone decorativo | aria-hidden (o Lucide já põe) |
| Imagem de avatar | alt="" — o nome já está escrito ao lado |
| Cartão de resumo que filtra | aria-pressed no botão de filtro (aí sim é toggle) |
| Tabela | cabeçalho real em <th>, não uma <div> com aparência de cabeçalho |
Contraste
| Regra | Detalhe |
|---|---|
| Texto semântico usa o -strong | O tom base sobre o -soft reprova AA em texto pequeno. A regra vale nos cinco temas, mesmo com a direção invertendo no escuro. |
| ink-400 está fora do mínimo | É dot e ícone decorativo; o WCAG não exige contraste de elemento puramente decorativo. Se ele passar a carregar significado sozinho, muda de token. |
| Cor nunca é o único sinal | Todo status tem rótulo escrito ao lado do dot, e o compromisso na agenda tem FORMA (listrado, vazado, ✓) além da cor — pintar o cartão não pode apagar “quem confirmou”. |
| O teste mede, a revisão não precisa lembrar | contraste.test.ts parseia globals.css e valida os pares dos cinco temas. Par novo reprovado = teste vermelho. |
NotaO tema Ardósia tem um fg-subtle mais claro
O resto do checklist
Movimento reduzido
Toda animação tem par com motion-reduce. As utilities pulso e skeleton já carregam o par embutido.
Zoom até 200%
Layout em rem e grades que quebram; nada de altura fixa em px pra caixa de texto.
Autofill do Safari
O amarelo do preenchimento automático é coberto por box-shadow interno e o texto acompanha — num tema escuro ele acenderia no meio do formulário.
color-scheme por tema
Select, campo de data e barra de rolagem nativos seguem a UI, não o sistema operacional.
Idioma declarado
lang="pt-BR" no <html>: é o que faz o leitor de tela pronunciar “agendamento” em português.
Sem texto em imagem
A marca é SVG com nome acessível; qualquer outro texto é texto de verdade.