Componentes
Navegação
Rail
RailLinks pra uma lista simples; RailGroups quando os destinos têm categorias. No lg+ o grupo vira coluna com rótulo; abaixo disso o rótulo some e os itens voltam a ser uma tira horizontal única (contents derruba o wrapper) — três tiras empilhadas custariam a altura que o palco não tem.
A pista tem mask-image na borda direita no <lg: é a pista de que há mais itens, já que a barra de rolagem fica escondida.
O fio pendura em um item o destino que só existe DENTRO dele — a construção de um modelo de contrato, que se abre a partir da lista e leva o nome do modelo. É RailItem.filho, publicado pela própria tela (ui/rail-filho.tsx: ela sabe o nome, o rail não). Com um filho aceso o PAI perde a pílula e fica só em text-fg: quem está aceso é a tela aberta, e duas pílulas diriam que se está em dois lugares. Um filho por vez — dois já seriam um grupo, que o rail sabe fazer. O fio só existe no lg+; na tira horizontal o filho é a próxima pílula.
A saída mora no pé do rail, atrás de um fio: ui/voltar-do-rail.tsx, pílula cinza cheia (bg-ink-200, hover bg-border-strong) com a seta e o destino por extenso, “Voltar a Cadastros”. Não é destino da tela, por isso não fica entre os itens nem acende em verde. Só no lg+: no celular o rail é tira no topo e a tela decide o seu voltar. Vale na ficha da pessoa e na Configuração da Agenda; o “Sair” das Configurações da conta ocupa o mesmo lugar em vermelho, porque encerra a sessão.
Item ativo por pathname, com aria-current="page". Largura útil no palco: 288 − 40 − 26 = 222px — daí o min-w-0 truncate no rótulo, senão “Custos da clínica” vaza da pista.
Recorte de visão (segmented control)
| Peça | Receita |
|---|---|
| Trilho | flex items-center gap-1 rounded-full bg-ink-100 p-1 · overflow-x-auto |
| Item | h-8 rounded-full px-3.5 text-sm font-medium · pointer-coarse:h-10 |
| Ativo | bg-surface text-fg shadow-xs — a pílula “sobe” dentro do trilho |
| Em código | classeDoSegmento(ativo, extra) em lib/ui/trilho.ts: a roupa do item num lugar só; a tela passa só o que acrescenta (gap, aria-disabled:) |
| Movimento | TrilhoSegmentado (components/ui/trilho-segmentado.tsx) no lugar do div do trilho: a superfície do escolhido vira UMA peça que anda até o item novo em 240ms na curva ease-deslizar, e o fundo do item sai sozinho (data-pilula). Nenhum item muda; sem JS, o trilho é o que sempre foi |
| Inativo | text-fg-muted hover:text-fg (sem fundo) |
| Contagem | text-xs text-fg-subtle tabular-nums |
| Processo vivo | pulso size-1.5 rounded-full bg-info-strong |
| Semântica | role=tablist/tab + aria-selected + aria-controls + ← →; o painel leva role=tabpanel + aria-labelledby. Nunca aria-pressed: é crachá de toggle, não de aba |
| Variante moldura | rounded-full border border-border (sem o trilho cinza) — a troca de visão da Agenda |
Sim
Um trilho, um selecionado. O conjunto lê como UM controle, e é uma parada só de Tab.
Não
Dois chips soltos, cada um com sua cor: viram dois filtros independentes aos olhos de quem lê — e o verde da marca sugere “ativo”, não “selecionado entre dois”.
NuncaA aba sublinhada foi avaliada e recusada — 23/08/2026
O canon herdado do Prospify previa aba com sublinhado (border-b-2 border-brand) pra navegação entre sub-coleções. Nenhuma tela nunca usou, e ela saiu do vocabulário: o produto tem duas peças que cobrem o espaço inteiro — segmented control pra recorte da mesma lista, rail pra sub-telas de uma área.
Não é economia de desenho, é economia de decisão: um terceiro formato dormindo no guia acaba usado por engano, e aí a tela nasce falando uma língua que só ela fala. Se algum dia aparecer uma tela com seis ou mais recortes de nome longo — o único caso em que o trilho do segmented control vira uma barra cinza atravessando a tela —, isso é uma decisão nova, com o caso concreto na mão. Não reabra sem ele.
Pedaço da lista (paginação)
| Peça | Receita |
|---|---|
| Casco | nav aria-label="Paginação" · flex flex-wrap items-center justify-between gap-x-4 gap-y-3 |
| Frase | “51 a 100 de 10.044 cadastros” — text-sm text-fg-muted tabular-nums, com aria-live=polite: trocar de página não recarrega a tela |
| Anterior / Próxima | Button variant=ghost size=sm, com data-icone e disabled nas pontas |
| Degrau | Button size=icon-sm · o atual é variant=secondary + aria-current="page" |
| Salto | … em text-fg-subtle, aria-hidden — reticência, nunca botão |
| Celular | os degraus somem (sm:flex) e sobra “4 de 201”: numa tela estreita eles quebravam em duas linhas por cima dos botões |
| Regra | lib/paginacao.ts — 50 por página, primeira e última sempre visíveis, a atual com uma vizinha de cada lado, e buraco de UMA página vira a própria página |
Armadilha já pagaA página é janela de leitura, nunca fronteira do que existe
Paginar corta o desenho, não o dado. Contagem de aba, alcance da vista, exportar e ação em lote continuam lendo o recorte inteiro — quem contar o que está na tela achando que conta o filtro erra pelo tamanho da página.
Por isso a paginação chega acompanhada da faixa “Marcar os N do filtro”: com a lista cortada, o ☑ do cabeçalho passa a valer a página, e sem a faixa a tela teria PERDIDO a seleção em massa que já tinha.
Peças de identidade e de linha
Verificação em duas etapas
Um código a mais na hora de entrar.
Códigos de recuperação
Nenhum código restante.
// A linha cuida do próprio ritmo vertical; quem separa é o pai.
<div className="divide-y divide-border">
<SettingsRow title="…" description="…"><Button/></SettingsRow>
<SettingsRow title="…" description="…" tone="negative"><Button/></SettingsRow>
</div>
// Dentro de um Card com o fio de ponta a ponta, o recuo lateral passa a ser
// da LINHA — é pra isso que existe o `className` (Configurações da agenda).
<Card>
<CardHeader>…</CardHeader>
<div className="divide-y divide-border">
<SettingsRow className="px-6" title="…" description="…"><Button/></SettingsRow>
</div>
</Card>No celular a SettingsRow empilha (texto em cima, ação embaixo): lado a lado, a descrição ficava espremida em ~114px. É a regra 11 aplicada a um componente pequeno.