Sistema

Primitivas & padrões

Este é o capítulo que a marca precisa pra virar produto. Cor e tipografia dizem como a Alemão parece; aqui está o que ela é quando alguém clica: 16 primitivas em components/ui/ e 37 padrões compostos em components/admin/shared/ e components/shared/. Cinquenta e três peças, nenhuma decorativa.

Quase tudo abaixo está renderizado ao vivo — o especímen é o mesmo componente que roda no admin, importado do mesmo arquivo. Onde não deu, a legenda diz fac-símile e explica o motivo. E no fim tem a dívida: onze pontos onde a base ainda não cumpre o que promete.

Qual superfície de token manda aqui

O sistema tem duas superfícies de token, e as duas são legítimas. Errar de superfície é o jeito mais rápido de um componente novo sair fora da marca.

SuperfícieOnde viveQuem consomeComo se escreve
A · MARCA / sitecomponents/site-alemao/tokens.tsSó o site público do Alemão (SiteShell injeta as CSS vars)hex e CSS vars: --az, --am, --tinta
B · PRODUTO / adminapp/globals.css (:root) + tailwind.config.tsAdmin, app do funcionário, Brand Hub — e as 53 peças deste capítulocanais RGB via classe Tailwind: bg-blue, text-ink, border-line

A regra de escolha é o ambiente, não o gosto: se o arquivo mora em components/site-alemao/, é superfície A. Qualquer outro lugar é superfície B. Nenhuma das 16 primitivas usa a superfície A — elas são a base do produto. E os materiais de vidro também não se misturam: .vidro-* é a voz da marca (veja Materiais de vidro), .glass-* é a voz do admin — e é o que o Card usa.

Como ler as fichas

Toda ficha tem o mesmo formato: nome, tipo de módulo, adoção medida, resumo, import copiável, tabela de props, especímenes e o par usar / não usar.

RótuloO que significa
compartilhadoSem diretiva e sem hook — quem importa define o ambiente. Roda no servidor e no cliente.
clientTem "use client" no topo. Importar daqui (Server Component) funciona: vira boundary. O que NÃO funciona é passar função como prop.
serverSem diretiva, renderizável no grafo RSC — é como os padrões compostos do admin nascem.
AdoçãoNúmero de arquivos que importam o componente, medido por grep em app/ + components/. Adoção zero é sinal de peça morta.
Fac-símileHTML escrito à mão imitando o componente. Aparece só quando o componente real exige prop de função ou contexto que esta página não tem.

Ação

Uma primitiva só, e é a mais usada do sistema: 117 arquivos. Tudo que dispara algo no admin passa por ela — ou deveria.

Button

compartilhado117 arquivos

O botão do admin, construído com cva. Sete variantes, seis tamanhos, dois formatos. Peso máximo font-medium (500) — o cabeçalho do arquivo é explícito: nunca semibold em botão.

Detalhe que evita bug: quando não é asChild, o type recebe default "button" — é isso que impede submit acidental de um botão qualquer dentro de <form>.

PropTipoDefaultPra que serve
variantprimary | accent | outline | ghost | soft | link | destructiveprimaryPeso do botão na hierarquia da tela.
sizexs | sm | md | lg | xl | iconmdAltura e padding. Também redimensiona o ícone interno.
shaperounded | pillroundedrounded = 16px. pill pra FAB e CTA de hero (1 uso hoje).
blocktrue— (ausente = largura automática)w-full. Padrão do submit de modal (25 usos).
asChildbooleanfalseTroca o <button> pelo Slot do Radix — é assim que o botão vira <Link> ou DialogTrigger sem perder o estilo.
…restButtonHTMLAttributes—disabled, form, onClick, aria-*. Encaminha ref (forwardRef).
  • primary · 102 usos
  • outline · 101 usos
  • ghost · 80 usos
  • soft · 11 usos
  • accent · 8 usos
  • destructive · 7 usos
  • link · 1 uso
As sete variantes, com a adoção real medida no repo. primary, outline e ghost respondem por 283 dos 310 usos — o resto é tempero.
  • xs · h-8
  • sm · h-9
  • md · h-11
  • lg · h-12
  • xl · h-14
Os cinco tamanhos vivos. O sexto — size="icon" (h-11 w-11) — está declarado no cva e tem ZERO usos no repo: é variante morta, ver a dívida no fim da página.
  • disabled
  • shape="pill"
  • Ver coresasChild + Link
Estados e formatos. O foco é focus-visible (só teclado), o press é active:scale-[0.96] embutido na base do cva, e disabled é opacity-50 + pointer-events-none. Dê Tab nos botões pra ver o anel.
  • repouso
  • o improviso de hoje
Fac-símile — Fac-símile do estado de carregando: não existe prop loading. Cada tela improvisa disabled + troca de rótulo (ex.: novo-cliente-dialog.tsx:255). É a interação mais repetida do admin e a única sem padrão na primitiva.

Use quando

  • Toda ação clicável do admin, sem exceção
  • Navegação que precisa parecer botão — com asChild + Link
  • primary é a ação principal da tela; só uma por tela
  • accent (amarelo) pro CTA quente: WhatsApp, converter orçamento
  • block no submit de modal e de formulário no celular

Não use quando

  • Link de texto no meio de parágrafo — ali é <a>, não variant="link"
  • Duas primary competindo na mesma tela
  • Ação destrutiva sem confirmação — destructive é só a cor, não o guarda
  • Amarelo com texto branco: sobre amarelo o texto é sempre azul (a variante já resolve)

Entrada

Seis primitivas de coleta de dado — e três buracos. Não existe Textarea, Select, Checkbox nem Radio em components/ui/: o repo tem 55 <textarea>, 69 <select> e 13 checkbox escritos à mão.

Input

compartilhado54 arquivos

O campo de texto base: altura 48px (h-12), radius 16px (rounded-md), texto text-body-lg. É a base dos três derivados — MaskedCurrencyInput, PasswordInput e UppercaseInput — então qualquer coisa que você conserte aqui conserta os quatro.

Quase nunca aparece sozinho: o consumidor canônico é o Campo, que já entrega o par rótulo + controle.

PropTipoDefaultPra que serve
typestring"text"Tipo nativo do input, desestruturado com default no componente.
…restInputHTMLAttributes—value, onChange, placeholder, disabled, required, maxLength. Encaminha ref.
error / invalid——NÃO EXISTE. Sem prop e sem estilo aria-invalid:. Ver a dívida no fim da página.
Repouso e foco. Clique no campo: o anel é focus:ring-2 ring-blue/15 — focus:, não focus-visible:, o que faz o anel aparecer também no clique de mouse. Diverge de Button e Switch (ver dívida).

Placa incompleta — fac-símile, não é a primitiva.

Desabilitado (opacity-50 + cursor-not-allowed) e o fac-símile do estado de erro que a primitiva NÃO tem — aqui feito à mão com border-danger e aria-invalid, exatamente o improviso que a dívida pede pra acabar.

Use quando

  • Todo campo de texto, número, data, e-mail e telefone do admin
  • Sempre dentro de um Campo (ou com Label + htmlFor), nunca solto
  • Com inputMode no celular quando o teclado numérico ajuda

Não use quando

  • Dinheiro — ali é MaskedCurrencyInput
  • Senha — ali é PasswordInput, sem exceção (regra de 06/07/2026)
  • Texto longo de vários parágrafos — <textarea> (a primitiva ainda não existe)
  • Sinalizar erro por conta própria com border-danger na mão: crie o estado na primitiva

Label

client28 arquivos

Rótulo de campo em cima do @radix-ui/react-label — clicar no rótulo foca o controle associado por htmlFor. O caixa-alta não está na primitiva: quem adiciona uppercase tracking-wider é o wrapper Campo. Fora do Campo, o rótulo é caixa normal.

PropTipoDefaultPra que serve
htmlForstring—Amarra o rótulo ao id do controle. Sem isso o clique não foca nada.
…restComponentPropsWithoutRef<LabelPrimitive.Root>—className, children. Encaminha ref.
Ao vivo. À esquerda, Label cru (text-caption text-ink-soft). À direita, o mesmo Label como o Campo o entrega — caixa-alta e tracking. Clique nos dois rótulos: o foco vai pro campo.

Use quando

  • Todo controle de formulário tem rótulo visível — não use placeholder como rótulo
  • Com htmlFor apontando pro id do campo
  • Dentro de Campo quando o formulário está num Dialog do admin

Não use quando

  • Como texto de apoio ou legenda — ali é <p> text-caption
  • Contando com peer-disabled: ele nunca dispara na prática (ver dívida)

MaskedCurrencyInput

client8 arquivos

Campo de dinheiro estilo maquininha de cartão: cada dígito empurra os centavos pra esquerda — 21015 vira 210,15. O value trafega em reais (210.15), não em centavos.

Por dentro guarda os dígitos crus num useRef e intercepta as teclas no onKeyDown (0–9, Backspace, Delete) com preventDefault, em vez de reparsear o texto formatado. É o único jeito do backspace apagar exatamente um dígito sem se perder na vírgula, que muda de lugar a cada tecla.

PropTipoDefaultPra que serve
valuenumber— (obrigatória)Valor em REAIS. 210.15, não 21015.
onValueChange(valor: number) => void— (obrigatória)Devolve número já pronto pra conta — sem parse no caller.
…restOmit<InputProps, "value" | "onChange" | "type" | "inputMode">—type="text" e inputMode="numeric" são fixos — daí o Omit. placeholder também é fixo (0,00).
vazio (placeholder fixo)
digitou 21015
compacto em tabela (h-9)
Fac-símile — Fac-símile: onValueChange é uma função, e Server Component não passa função pra Client Component. Os campos abaixo são <input readOnly> com as classes do Input, só pra mostrar o desenho e o comportamento. O componente real está em 8 telas do Caixa, OS e Orçamentos.

Use quando

  • Qualquer valor em dinheiro: Caixa, OS, orçamento, recebimento
  • Compacto dentro de tabela — passe className="h-9 px-2.5 text-body" (receber-os-dialog.tsx:201)

Não use quando

  • Quantidade, percentual ou peso — ali é Input com inputMode
  • Exibir valor já salvo sem edição — ali é texto com brl() de lib/currency

PasswordInput

client4 arquivos

Campo de senha com olho pra mostrar/esconder. Regra registrada no próprio arquivo: todo campo de senha do sistema usa este componente (pedido de 06/07/2026). O botão do olho é type="button" com tabIndex={-1} — fica fora da ordem de tabulação pra não atrapalhar quem preenche o formulário no teclado — e o aria-label alterna entre “Mostrar senha” e “Esconder senha”.

PropTipoDefaultPra que serve
…restOmit<InputProps, "type">—Tudo do Input menos o type, que é interno. Força pr-12 no campo pra abrir espaço pro olho.
Ao vivo, com estado de verdade: clique no olho e o type alterna entre password e text, junto com o ícone Eye / EyeOff.

Use quando

  • Login do admin, definir senha, convidar e editar membro
  • Qualquer campo novo de senha — não recrie o olho à mão

Não use quando

  • Campo de PIN/código de 4-6 dígitos, que se lê em voz alta e não precisa esconder
  • Confirmação de senha em que o produto decidiu não oferecer o olho

UppercaseInput

client2 arquivos

Input que sobe pra maiúscula enquanto se digita. A transformação é no valor (e.target.value.toUpperCase() antes de chamar o onChange do caller), não text-transform no CSS: o dado que chega no formulário já vem em caixa-alta.

Não precisa de lógica de cursor porque a transformação não muda o tamanho da string nem reordena caracteres. Restrição declarada no arquivo: usado só no título de tarefas e demandas (06/07/2026).

PropTipoDefaultPra que serve
…restInputProps—A interface inteira do Input, nada omitido. Encaminha ref.
Ao vivo. Digite no campo: o valor vira maiúscula a cada tecla, e o cursor não pula.

Use quando

  • Título de tarefa interna e de demanda Apol — e só

Não use quando

  • Nome de pessoa, endereço, observação — caixa-alta em texto corrido grita
  • Só pra aparência: se o dado não precisa estar em maiúscula no banco, use CSS

Switch

client4 arquivos

Toggle on/off acessível: <button type="button" role="switch" aria-checked> com a bolinha branca deslizando. É sempre controlado — checked e onCheckedChange são obrigatórios, não existe modo não-controlado.

PropTipoDefaultPra que serve
checkedboolean— (obrigatória)Estado. Vira aria-checked, então leitor de tela anuncia certo.
onCheckedChange(v: boolean) => void— (obrigatória)Recebe o valor JÁ invertido — o componente não guarda estado.
disabledboolean—Trava o toggle (ex.: item da régua quando o master está desligado).
idstring—Pra amarrar num Label por htmlFor.
aria-labelstring—Nome acessível quando não há rótulo visível. É a ÚNICA aria aceita pelo tipo: aria-labelledby, name e onBlur não passam.
  • off
  • on
  • on + disabled
Fac-símile — Fac-símile: onCheckedChange é função obrigatória, e esta página é Server Component. Os trilhos abaixo carregam as classes reais do componente, mas são estáticos e aria-hidden. Note o off em bg-[#cbd5e1] — hex cru que ignora o token --line (ver dívida).

Use quando

  • Ligar/desligar algo que vale imediatamente, sem botão Salvar
  • Régua de automações, permissão de perfil, chave do WhatsApp
  • Sempre com rótulo visível ou aria-label — um trilho sozinho não diz o que faz

Não use quando

  • Dentro de formulário que só aplica no Salvar — ali é checkbox
  • Escolha entre duas opções nomeadas (A ou B) — ali é radio
  • Sem tamanho alternativo: não existe variante de tamanho nem de cor

Campo

server7 arquivos

O par rótulo + controle dos formulários em Dialog do admin. Existe pra ninguém recriar Label uppercase tracking-wider + campo em cada modal novo. O header do rótulo tem min-h-[20px] mesmo sem acessório — é o que impede a linha de pular quando um campo tem acessório e o vizinho não.

PropTipoDefaultPra que serve
labelstring— (obrigatória)Texto do rótulo. O caixa-alta é aplicado aqui, não no Label.
childrenReactNode— (obrigatória)O controle: Input, MaskedCurrencyInput, select à mão, ComboSugestoes.
acessorioReactNode—Controle pequeno à direita, na MESMA linha do rótulo. Nasceu do checkbox de NF-e no modal do Caixa, que não cabia em linha própria.
com NF-e
Ao vivo, com e sem acessório. A altura do cabeçalho é a mesma nos dois — é o min-h-[20px] fazendo o trabalho.

Use quando

  • Todo campo de formulário dentro de Dialog do admin
  • Com acessorio quando a opção secundária pertence ao campo (NF-e, 'aplicar a todos')

Não use quando

  • Formulário de página inteira que já tem seu próprio layout de rótulos
  • Como wrapper de grupo de campos — ele é um campo, não uma seção

Exibição

Nove exports que não recebem clique: informam. Aqui mora a segunda primitiva mais usada do sistema — o Card, em 113 arquivos — e é justamente a que carrega a dívida mais grave.

Badge

compartilhado57 arquivos

Pílula de status (presente, atraso, falta, encerrou) e de indicador (“verificado”). Renderiza um <span> — é rótulo, não controle: não tem hover, foco nem disabled. Função simples, sem forwardRef.

Decisão documentada no arquivo: em warning e danger o fundo vem do MESMO token do texto com /15, e não de um orange-50 fixo. Com o fixo, no tema escuro o texto clareava e o fundo não — “Alta” chegou a 1,57:1 na visão em Lista da aba Apol.

PropTipoDefaultPra que serve
toneblue | yellow | success | warning | danger | neutral | inkblueSemântica da pílula. É a única prop própria.
…restHTMLAttributes<HTMLSpanElement>—className, title, children. NÃO aceita ref.
  • blueblue · 24
  • yellowyellow · 12
  • presentesuccess · 27
  • warningwarning · 20
  • faltadanger · 12
  • neutralneutral · 31
  • inkink · 1
Os sete tons ao vivo, com a adoção medida. Ao lado de cada um, o número de arquivos que o usam. text-tiny = 11px, uppercase, tracking .06em, peso 600.

Use quando

  • Status de registro numa lista, tabela ou card
  • neutral pro estado sem carga (rascunho, arquivado) — é o tone mais usado, 31 vezes
  • Texto curtíssimo: a pílula é h-6 e não quebra linha (whitespace-nowrap)

Não use quando

  • Como botão ou filtro clicável — Badge não tem estado de foco
  • Frase inteira dentro da pílula
  • Amarelo com texto branco: em yellow o texto é sempre azul (o tone já resolve)

Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter

compartilhado113 arquivos

A superfície do admin. Card é literalmente glass-strong rounded-lg — vidro forte do namespace .glass-*, que é a voz da Apol no produto, não o .vidro-* da marca. Nenhuma variante: toda variação é className no caller.

Regra de aninhamento: filho sempre com radius MENOR que o pai (rounded-lg = 20px no card, rounded-md = 16px dentro dele). O Card não tem padding próprio — vem dos sub-componentes (todos p-5) ou do caller.

PropTipoDefaultPra que serve
CardHTMLAttributes<HTMLDivElement>—Nenhuma prop própria. glass-strong + rounded-lg. Encaminha ref.
CardHeaderHTMLAttributes<HTMLDivElement>—flex flex-col gap-1.5 p-5 pb-3 — o topo do card.
CardTitleHTMLAttributes<HTMLHeadingElement>—text-heading text-ink. Renderiza <h3> FIXO — não tem prop as/level.
CardDescriptionHTMLAttributes<HTMLParagraphElement>—text-body text-gray num <p>. Apoio do título.
CardContentHTMLAttributes<HTMLDivElement>—p-5 pt-0 — ASSUME um CardHeader acima. Sozinho, o conteúdo encosta na borda de cima.
CardFooterHTMLAttributes<HTMLDivElement>—flex items-center p-5 pt-0 — faixa de ações.

Caixa de hoje

Fechamento parcial · atualizado agora

R$ 4.180,00

14 lançamentos · 3 atendentes

A família completa ao vivo — Header (p-5 pb-3), Title (h3), Description, Content (p-5 pt-0) e Footer. O espaçamento é o do sistema, sem uma classe do caller.

Card com p-4 do caller

painel-regua.tsx:115 faz exatamente isso.

Card p-0 — o conteúdo sangra até a borda. É o que Tabela usa.

Card sozinho com padding do caller — e aqui aparece a segunda convenção de padding do sistema: p-4 (16px) no caller convive com o p-5 (20px) dos sub-componentes na mesma tela.

Use quando

  • Qualquer bloco de conteúdo do admin: KPI, painel, lista, formulário
  • Com a família Header/Content/Footer quando há título — o espaçamento já casa
  • Card p-0 quando o conteúdo é uma tabela que sangra até a borda (é o que Tabela faz)

Não use quando

  • Dentro de outro Card pra criar profundidade — vidro sobre vidro vira borrão
  • No site público do Alemão: ali a superfície é .vidro-* sobre fundo rico
  • Contando com tema escuro: glass-strong tem fundo branco fixo (ver dívida)

Progress

compartilhado3 arquivos

Barra de 6px (h-1.5) com role="progressbar" e aria-valuenow/min/max. O valor é clampado internamente (Math.max(0, Math.min(100, value))), então passar 140 não estoura a barra. O tom é decisão do caller — a ficha de documentos usa vermelho abaixo de 60, azul entre 60 e 99, verde em 100.

PropTipoDefaultPra que serve
valuenumber— (obrigatória)0–100. Clampado internamente.
toneprimary | success | destructive | warningprimaryCor do preenchimento. success é bg-emerald-500 — verde fora do token --success (ver dívida).
classNamestring—Vai no trilho. É a única passagem: o componente NÃO espalha props.
aria-label / id——NÃO ACEITA. Sem forwardRef e sem spread, a barra fica sem nome acessível (painel-whats.tsx:985).
  • destructive · 34% — documentos faltando
  • primary · 72% — em andamento
  • warning · 88% — perto do limite
  • success · 100% — completo
Os quatro tons ao vivo. A largura é style inline, a transição é transition-[width] duration-500 — mudar o value anima.

Use quando

  • Progresso de algo que a pessoa está esperando: checklist, documentos, envio
  • Com um rótulo de texto ao lado dizendo o número — a barra sozinha não é acessível hoje

Não use quando

  • Carregamento indeterminado (não se sabe a porcentagem) — ali é Skeleton
  • Como gráfico de comparação entre itens — ali é GraficoBarras

Skeleton

compartilhado2 arquivos

Bloco pulsante de carregamento: animate-pulse rounded-md bg-line. bg-line é escolha deliberada — token neutro, pra funcionar em qualquer tela. A forma vem toda do className: o componente aceita só isso, nem children, nem style, nem aria-*.

PropTipoDefaultPra que serve
classNamestring—A única prop. Altura, largura e radius do bloco (h-3 w-24, h-10 w-36 rounded-full…).
Ao vivo — a mesma primitiva em quatro formas, todas por className. É assim que page-skeleton.tsx monta a silhueta da tela.

Use quando

  • Espera com forma previsível: a silhueta do conteúdo que vai chegar
  • Dentro de loading.tsx da rota — é como PageSkeleton é montado

Não use quando

  • Espera longa e indefinida sem forma conhecida — ali um texto honesto vale mais
  • Como espaçador ou placeholder decorativo: ele pulsa, e pulsar significa 'está vindo'

Padrões compostos

37 exports em components/admin/shared/ e components/shared/. Não são primitivas: são decisões de produto já resolvidas — cada um nasceu de um padrão que estava copiado em três ou quatro telas. A regra é uma: se está aqui, não recrie.

Três componentes saíram do sistema e não voltam: CartaoItem, ListaPorStatus e QuadroPorStatus só existem como duplicata do iCloud (* 2.tsx), sem arquivo-mãe e sem nenhum import no repo. Se você achou um deles num autocomplete, é fantasma — o substituto é QuadroKanban com renderCard.

Formulário e escolha · 6

ComponenteTipoPropsO que resolve
Camposerver · 7 usoslabel, children, acessorio?Ficha completa na seção Entrada, acima.
ComboSugestoesclient · 2 usosvalue, onChange, onCommit?, opcoes, placeholder?, disabled?, maxLength? = 60, id?, className?Texto livre com a lista COMPLETA de sugestões. Substitui <input list> + <datalist>, que com o campo preenchido só mostra o que casa com o texto — quase sempre a opção já escolhida. A regra que conserta é uma linha: filtrar só depois que a pessoa digita. Item escolhe por onMouseDown, não onClick: o clique chega depois do blur, e o blur já fechou o menu.
SeletorPrioridadeclient · 2 usosvalue, onChange, mostrarLegenda? = trueRégua Urgente · Alta · Média · Baixa, nesta ordem — é uma régua, não 4 botões: a posição é a severidade. role="radiogroup", setas andam com wrap, só o selecionado fica no Tab. Cada nível mostra o PRAZO que promete (“Hoje”, “2 dias”) — sem isso “urgente” vira o padrão de todo mundo. Contraste medido: 5,0 a 8,1 nos quatro níveis.
SeletorDiaclient · 1 usovalor, hoje, hrefTemplate, marcas?A data vira calendário clicável. Antes era só ‹ data ›: voltar duas semanas custava catorze cliques. Iniciais e grade vêm de lib/calendario/grade.ts — as mesmas do calendário de férias e do app do funcionário. hrefTemplate é template com {dia}, e não função, porque Server Component não passa função pra Client Component.
GlassPasswordInputclient · 3 usoslabel + InputHTMLAttributesO campo de senha das telas de vidro (login do funcionário, console Apol): h-16 bg-white/10 backdrop-blur-md border-white/15, rótulo dentro do campo. Está corretamente sobre fundo rico — mas é uma quarta implementação de vidro, sem .vidro-* nem .glass-*.
EditorAvatarclient · 2 usosnome, cor, fotoUrl, aoMudar, enviarEscolher, arrastar, recortar e subir a foto de perfil. O recorte é no navegador e sobe o quadrado final 512×512 — não guarda o original de 4MB pra exibir um círculo de 32px. Aceita drop e Ctrl/Cmd+V. Grava WebP q=0.92 quando o navegador codifica, PNG quando não (a checagem é no blob.type, porque Safari antigo ignora o tipo pedido). O enviar é injetado porque cada tela tem guard diferente.

Os cinco últimos exigem prop de função (onChange, aoMudar, enviar) e por isso não aparecem renderizados aqui: esta página é Server Component. Veja-os no admin — ComboSugestoes e SeletorPrioridade no modal de nova tarefa, SeletorDia em Pessoas › Ponto, EditorAvatar no rodapé da sidebar.

Tabelas e dado denso · 11

Existem duas soluções de tabela no sistema, e as duas continuam vivas. A regra de escolha abaixo é a leitura do uso real — 12 arquivos de um lado, 7 do outro — e está aqui pra o próximo módulo não escolher no chute.

EscolhaQuandoPor quê
Tabela + Th/Tr/TdDensidade alta, rodapé de totais, página de servidor (módulo Pessoas)<table> de verdade: leitor de tela anuncia coluna e <tfoot> como total. Fica no grafo RSC — só a casca rolável é cliente.
TabelaAdminColunas que não cabem no celular, linha que expande, ordenação, impressãoFaux-table (<ul> em grid): a coluna secundaria sai do grid no mobile e reaparece dentro da linha expandida, com o nome da coluna junto — nenhuma informação desaparece.
ComponenteTipoPropsO que resolve
Tabelaserver · 12 usoschildren, minWidth? = "48rem", className?Card p-0 + scroll horizontal + <table>. Regra do arquivo: quem rola é o wrapper, nunca a página — por isso o minWidth vai na tabela.
TabelaHeadserverchildren<thead> com a linha de cabeçalho pronta: bg-bg/60 text-tiny uppercase text-gray.
Thserveralign? = "left" + ThHTMLAttributesCélula de cabeçalho, já com scope="col". align="right" pra número.
TabelaBodyserverchildren<tbody> com divide-y divide-line.
TrserverHTMLAttributes<HTMLTableRowElement>Linha com hover padrão hover:bg-blue-soft/40.
Tdserveralign? = "left" + TdHTMLAttributesCélula de corpo: text-body text-ink-soft, respiro de borda em first:pl-4 last:pr-4.
TabelaVaziaservercolSpan, childrenO estado vazio da Tabela: uma <tr> única com o colSpan do número de colunas.
TabelaFootserverchildrenRodapé de totais. Virou <tfoot> (e não uma linha no corpo) porque é semanticamente o total e leitor de tela anuncia como tal.
TabelaAdminclient · 7 usoscolunas, linhas, vazio?, ordem?, onOrdenar?Faux-table com linha expansível e ordenação. O componente só avisa a ordenação (aria-sort); quem ordena é o chamador. Larguras entram por CSS var --cols porque Tailwind não gera classe de string dinâmica. Tem espelho print: completo do breakpoint lg: — A4 retrato tem ~794px, abaixo de 1024, e sem isso a tabela saía no papel sem cabeçalho.
ScrollComDegradeclient · 1 usochildren, className?Área rolável com degradê de “tem mais pra esse lado”. Necessária porque .scroll-pretty esconde a barra — sem sinal, tabela de 7 colunas no celular parecia ter 3. É a razão de tabela.tsx continuar server: medir scroll exige hooks, e isso tornaria o módulo inteiro cliente — o que já derrubou toda página de admin com tabela em produção (TypeError: i.useRef is not a function, invisível no typecheck e no build).
FiltroTabelaclient · 4 usosplaceholder, selects? = [], resumo?, className?Barra de busca + selects + limpar, tudo por querystring — o Server Component da página lê searchParams e filtra no servidor. Sem filtro ativo, o botão Limpar e o resumo somem: “8 notas” ao lado da lista inteira é ruído; filtrando, é a resposta. A querystring é a fonte da verdade, e um useEffect re-sincroniza o input (voltar no histórico, link com filtro pronto).
AtendenteServiçoValor
Adriano SouzaPara-brisa CorollaR$ 1.480,00
BalcãoVidro elétrico GolR$ 620,00
Marcos LimaPolimento de farolR$ 180,00
Total3 lançamentosR$ 2.280,00
A família Tabela ao vivo — Card p-0, cabeçalho, três linhas com hover, célula alinhada à direita e <tfoot> de total. Passe o mouse numa linha. Estreite a janela e o degradê do ScrollComDegrade aparece na borda.
AtendenteServiçoValor
Nenhum lançamento neste dia.
TabelaVazia ao vivo — o estado vazio da Tabela é uma linha, não um card. Repare que ela não sugere próxima ação: quando a seção INTEIRA está vazia, o certo é EmptyState.

Quadro e dado visual · 2

ComponenteTipoPropsO que resolve
QuadroKanbanclient · 5 usositens, colunas, idDoItem, colunaDoItem, renderCard, renderResumoColuna?, onMoverColuna, onReordenar?, selecionadoId?, onSelecionar?, recolherVazias? = true, larguraColuna? = 300, fluido? = false, renderRodapeColuna?, trilhoEsquerda?, trilhoDireita?, onSoltarNoTrilho?O núcleo de Kanban do sistema, extraído do Kanban de OS em 03/07/2026 a pedido explícito (“o Kanban da OS é o padrão, não crie outro”). Contém apenas o tabuleiro — o conteúdo do card é 100% do caller, via renderCard. Estados que valem conhecer: faixa (coluna estreita na borda, nasceu do “A faturar”), trilho (gaveta FORA do quadro, que não entra na conta do fluxo), fluido (a página rola, não o board). Colisão customizada porque a padrão fazia o drop no trilho de 44px cair na coluna vizinha. Sensor de toque com delay: 400ms — 180ms é menos que o tempo de um polegar começar a rolar, e o card era arrastado sem querer.
GraficoBarrasclient · 7 usosdados, altura? = 120, cor? = "blue", ativo?, selecionados?, mostrarVariacao? = false, linhaMedia? = false, linhaTendencia? = false, destacarRecorde? = false, destacarMenor? = false, diaEmAndamento?, aoClicar?, className?O gráfico de barras do DS — sem lib externa de propósito: são <div>s + estado, o que mantém o bundle leve e o visual dentro do token system. Coloração por precedência: recorde → amarelo cheio; menor com movimento → cinza; selecionada → azul cheio; demais → azul 55%. diaEmAndamento tira o dia corrente da disputa de “menor faturamento” — às 9h ele é quase sempre o mais fraco do mês.

Regra geral que saiu do GraficoBarras e vale pro sistema todo: o token de cor do produto é canal RGB sem alfa (rgb(var(--blue))), então o modificador /NN do Tailwind não tem efeito nele — a barra ficava invisível até o hover (corrigido em 05/07/2026). Precisa de opacidade sobre cor do tema? Use hex literal ou opacity-NN no elemento inteiro.

Menu e ação composta · 6

ComponenteTipoPropsO que resolve
MenuFlutuanteclient · 6 usosancora, aberto, onFechar, largura? = 236, childrenMenu que aparece sempre. Existe porque um absolute dentro do card do Kanban era cortado por DOIS ancestrais ao mesmo tempo — overflow recorta independente de z-index. A saída é o conteúdo não estar mais dentro: portal pro <body> com position: fixed. Vira pra cima quando abaixo sobra menos de 200px; reposiciona no scroll com capture: true (o que rola é a coluna, e scroll não borbulha).
MenuTituloclientchildrenTítulo de seção dentro do menu. Usa text-[10px] arbitrário em vez de text-tiny — a escala do config para em 11px.
MenuItemclientativo?, perigo?, onClick, childrenItem clicável com role="menuitem". Três estados: normal, ativo (bg-blue-soft/60 text-blue) e perigo (hover:bg-danger/10). Juntos, o ativo não pinta — ativo && !perigo.
BotaoDivididoclient · 2 usoslabel, icone?, onClick, opcoes, rotuloSeta? = "Mais opções", variante? = "primario", className?A ação principal ocupa o botão inteiro; a seta revela as variações dela. A regra do padrão: o que está escrito no botão é o que acontece ao clicar nele — a seta nunca muda a ação principal, e por isso a opção primária não se repete dentro do menu. A divisa no azul é border-l-[rgba(255,255,255,0.22)]: um border-line sobre o azul viraria um risco cinza.
BotaoImprimirclient · 2 usosrotulo? = "Imprimir"O mínimo cliente possível: window.print() só existe no navegador e a página do recibo é de servidor. Não imprime sozinho ao abrir, de propósito — quem clica em Imprimir às vezes só quer conferir o documento antes de gastar papel.
LinhaModuloclient · 1 usohref, label, icon, ativo?, onNavegar?Linha de módulo do menu do celular. Substituiu o quadradinho em grade de 3 (29/07/2026) por motivo aritmético, não estético: cada grupo tinha a própria grade de 3 colunas, então grupo de 1, 4 ou 7 itens sempre terminava com buracos na última linha, em lugares diferentes. Lista não tem última linha incompleta — e a linha inteira (52px) vira alvo de toque.
BotaoImprimir ao vivo — é um Button size="sm" com ícone Printer. Clicar abre o diálogo de impressão do navegador para esta página.
  • Mover para
  • Em produção
  • A faturar (ativo)
  • Cancelar OS (perigo)
Fac-símile — Fac-símile de MenuItem nos três estados (MenuFlutuante exige ancora com ref e onFechar). As classes são as reais: press, rounded-md, px-2 py-1.5, text-caption.

Identidade · 4

ComponenteTipoPropsO que resolve
Avatarserver · 28 usosname, size? = 44, status? = null, className?, fotoUrl?, cor?, ajuste? = "cover", anel?O círculo de identidade da pessoa em todo o sistema — o componente mais reusado do escopo. Iniciais = primeira letra do primeiro nome + primeira do último; sem foto e sem cor, gradient --blue → --blue-dark. A cor do texto vem de readableTextOn(cor): era text-white fixo, e numa cor clara cadastrada as iniciais simplesmente não existiam.
AvatarAtendenteserver · 3 usosnome, cor, fotoUrl?, icone?, className?, tamanhoIcone? = "size-1/2"Avatar de quem atendeu, com três estados em ordem de precedência: foto → ícone → iniciais. O ícone existe pra quem não é gente: “Balcão” com as iniciais “BA” num círculo colorido se confunde com uma pessoa; com o desenho de uma loja, não tem como ler errado. Tamanho vem de fora pelo className.
LogoEmpresaserver · 15 usossrc, alt, height? = 36, padding? = "md", bare? = false + HTMLAttributesContainer “cartão branco” pra um logo. Regra: em fundo escuro use bare, NUNCA o cartão — os logos de tenant já são exportados com contorno branco na própria arte, e o cartão por cima esconde esse contorno e vira um retângulo sem graça. Só volte pro cartão se o logo não tiver contraste próprio (o “a·” preto da Apol, que é sólido).
ApolMarkserver · 3 usosclassName?A marca “a·” da Apol em SVG inline — assinatura sutil (“por Apol”) em recibo e documento do cliente. Tamanho e opacidade pelo className. Cores hardcoded no SVG: letra #1B1B1B, ponto #FF9900 — nenhuma das duas está em tokens.ts nem em globals.css. A confirmar se #FF9900 é a cor oficial da Apol.
  • ASiniciais
  • MLcor da pessoa
  • APstatus="presente"
  • BRstatus="almoco"
  • VDanel
  • RAsize=28
Avatar ao vivo: iniciais com o gradient da marca, cor da pessoa, status (presente / almoço / ausente) e o anel de 'este é você'.
  • ASiniciais · size-10
  • icone="store"
  • icone="truck"
  • MLchip · size-6
AvatarAtendente ao vivo — iniciais para gente, ícone para posto. É a diferença entre 'BA' num círculo e o desenho de uma loja.
Alemão Auto Vidros
LogoEmpresa no modo cartão (padding md) — o certo sobre fundo claro.
Alemão Auto Vidros
O mesmo logo em bare sobre azul — o contorno branco da arte aparece. Com o cartão aqui, ele desapareceria dentro de um retângulo branco.
Sistema desenvolvido por
ApolMark ao vivo, do jeito que aparece num recibo: h-5 w-auto com opacidade reduzida. As duas cores vêm hardcoded do SVG.

Estados do sistema · 3

O sistema tem quatro estados vazios diferentes — EmptyState, TabelaVazia, a prop vazio do TabelaAdmin e o “Vazia” da coluna do Kanban — e só o primeiro sugere uma próxima ação. A hierarquia: seção inteira vazia → EmptyState; dentro de uma tabela → o vazio da própria tabela.

ComponenteTipoPropsO que resolve
EmptyStateserver · 11 usosicon, title, description, action?, className?O estado vazio padrão do sistema: ícone grande cinza, título, descrição curta e uma próxima ação opcional, renderizada pelo caller.
PageSkeletonserver · 1 uso— (nenhuma)O carregamento padrão do admin — fallback de app/[slug]/admin/loading.tsx. Mostra a forma de uma tela típica (header + 4 KPIs + 5 linhas). Não é fiel a nenhuma tela, de propósito: cobre a troca de módulo no menu, não é cópia pixel-a-pixel.
SeloAoVivoclient · 3 usosestado, titulo, className?O selo do pulso, em três estados: acabou de atualizar (~4s), ao vivo (ponto verde com animate-ping) e pausado (borda tracejada). O ponto pulsando é o que responde “isso está funcionando?” — sem ele não dá pra distinguir “nada mudou” de “parou de vigiar”. Clicar liga/desliga a atualização (aria-pressed).

O mesmo arquivo exporta o hook usePulso (consultar, chave, intervaloMs = 12_000): de tempos em tempos ele pergunta ao servidor uma ASSINATURA curta (“quantas linhas : carimbo da última mexida”) e, se mudou, chama router.refresh() — que recarrega os Server Components pelo caminho normal, com o RBAC inteiro, preservando filtro, modal e scroll. Guardas: não checa com a aba escondida, com modal Radix aberto, nem com arrasto em curso (body[data-arrastando="1"], marcado pelo QuadroKanban). Não é Supabase Realtime de propósito: as tabelas do admin estão com RLS ligada e zero políticas, e ligar Realtime exigiria abrir um SELECT pro cliente — um segundo caminho de acesso, mais fraco que o do app. E o consultar nasce DENTRO do componente cliente: passar função de RSC pra cliente já derrubou a página de Ponto.

Nenhum lançamento hoje

Quando o primeiro serviço for fechado, ele aparece aqui.

EmptyState ao vivo, com ação — o único vazio do sistema que oferece o próximo passo.
  • Atualizado
  • Ao vivo
  • Pausado
Fac-símile — Fac-símile dos três estados do SeloAoVivo (ele exige o objeto EstadoPulso, que vem do hook). Repare no verde: emerald-* cru do Tailwind, fora dos tokens — é a dívida: o --success existe, o SeloAoVivo ainda não usa.
PageSkeleton ao vivo — a silhueta genérica que aparece na troca de módulo do admin. Montado só com Skeleton e Card.

PWA e efeito colateral · 3

ComponenteTipoPropsO que resolve
InstalarAppPassosclient · 2 usosplataforma, linkApp, tomPasso a passo manual de instalação do PWA, por plataforma. Existe porque o caminho de 1 toque (beforeinstallprompt) só existe no Chrome/Android: no iPhone não existe API de instalação nenhuma. Antes as duas telas ensinavam só o caminho do Android, e quem tinha iPhone lia um passo a passo de menu (⋮) que não existe no aparelho. tom escuro para o banner azul da Home, claro para o card do Perfil. Só no iOS aparece o aviso de que o modo standalone tem cookies próprios e vai pedir CPF+senha de novo — sem ele, a pessoa acha que perdeu o acesso e liga pro RH.
PwaInstallButtonclient · 2 usoslogoSrc, tenantNome, linkAppO card de instalação no Perfil, com três caminhos: Chrome/Android abre o instalador nativo em 1 toque; iPhone revela o InstalarAppPassos; já instalado, o componente retorna null e some da tela.
RegistrarSWclient · 1 uso— (renderiza null)Registra o service worker quando o app abre. O Chrome só dispara o beforeinstallprompt quando há um SW registrado com handler de fetch — antes o /sw.js só era registrado dentro do card de notificações, então quem não ligasse push nunca tinha SW e o app nunca ficava instalável. Espera o evento load pra não disputar banda com o primeiro render; falha em silêncio; idempotente.

Diálogo e assinatura · 2

ComponenteTipoPropsO que resolve
DialogMeuPerfilclient · 1 usoslug, aberto, aoFechar, nome, papel, cor?, fotoUrl?Trocar a própria foto direto do rodapé da sidebar — antes o avatar só existia em Configurações › Acessos, junto com perfil de acesso e senha. Salva sozinho, com debounce de 350ms: o gesto é “trocar a fotinha”, não “preencher formulário”, e um botão Salvar só adicionaria um jeito de perder a alteração (o seletor de cor dispara a cada movimento do mouse). Bottom sheet no mobile, modal centrado no desktop.
VersiculoLinhaserver · 2 usosverso, className?A linha do versículo — uma só, discreta. Presentacional de propósito: recebe o versículo pronto e não sorteia nada; o sorteio fica na página, porque sortear dentro do componente causaria divergência de hidratação e porque o relatório precisa sortear N de uma vez pra não repetir entre as folhas. 🚨 Usa itálico — viola a lei da marca. Ver a dívida abaixo.

Dívida das primitivas

Onze pontos onde a base não cumpre o que promete. Estão publicados porque DS que só mostra o que está bonito faz o time repetir o erro achando que está seguindo o padrão. Cada linha tem o custo e a correção.

DívidaCusto hojeCorreção
Card não acompanha o tema escuro.glass-strong (globals.css:795) fixa background: rgba(255,255,255,.82) e não tem nenhum override sob [data-tema="apol"]. Dentro do tema escuro o Card continua quase branco enquanto text-ink vira claro — texto claro em fundo claro. É a 2ª primitiva mais usada: 113 arquivos.Declarar o fundo do .glass-strong pelo token --superficie (que já é 45 43 37 no escuro, globals.css:702) e adicionar o override do tema. O Button já foi corrigido assim — trocou bg-white por bg-superficie exatamente por esse motivo, e o comentário está no arquivo.
Input usa bg-white literalMesma armadilha, três derivados contaminados de uma vez: MaskedCurrencyInput, PasswordInput e UppercaseInput herdam o branco fixo.Trocar por bg-superficie. O comentário do tailwind.config.ts já diz por que o token existe: “no lugar de bg-white literal — é o que permite um tema escuro sem reescrever componente”.
Três vocabulários pro mesmo semânticoErro é danger no Badge e destructive no Button e no Progress — sendo que o Progress pinta bg-danger, o mesmo token com outro nome de prop. Azul é blue no Badge e primary no Button e no Progress. Três primitivas, três dicionários: quem escreve tem que lembrar de cor.Escolher um dicionário e migrar. Sugestão: nome semântico (danger, primary, success) em todas, com alias temporário pro nome antigo até a última tela migrar.
Badge e Progress ainda não usam --successO token existe (text-success, text-success-ink) desde 30/09 — antes disso as 26 mensagens com text-success saíam sem cor. Mas as primitivas continuam com verdes hardcoded (emerald-50/700 no Badge, emerald-500 no Progress) mais os do Avatar, SeletorDia, SeloAoVivo e DialogMeuPerfil.Migrar Badge e Progress pro --success e derivar fundo do mesmo token com /15, como o Badge já faz em warning e danger. O success ficou de fora daquela correção e ainda usa o emerald-50 fixo que o próprio comentário condena.
Buracos na base: sem Textarea, Select, Checkbox, Radiocomponents/ui/ não tem nenhum dos quatro. O repo tem 55 <textarea>, 69 <select> e 13 checkbox escritos à mão, cada um repetindo — ou não — as classes do Input. Cada formulário novo é uma chance de divergir.Criar as quatro primitivas com as classes do Input como base. Já existe referência de estilo pronta nos selects do FiltroTabela e do ComboSugestoes.
Input não tem estado de erroNenhuma prop error/invalid e nenhum estilo aria-invalid:. Os dois únicos aria-invalid do repo estão no form do SITE (form-orcamento.tsx:131,170), que nem usa esta primitiva — ou seja, o admin inteiro valida sem marcar o campo.Estilo em aria-invalid:border-danger + aria-invalid:ring-danger/15 na própria primitiva, e o caller só passa aria-invalid + aria-describedby. Zero prop nova.
Button sem estado de loadingNenhuma prop e nenhum spinner. Cada tela improvisa disabled={pending} + troca de rótulo (“Salvando…”, novo-cliente-dialog.tsx:251,255). É a interação mais repetida do admin e a única sem padrão na primitiva.Prop loading?: boolean que aplica disabled, aria-busy e um spinner no lugar do ícone, preservando a largura do botão (senão a tela pula ao clicar).
Button size="icon" é variante mortaDeclarada no cva (h-11 w-11) e com zero usos em app/ e components/. Botão só de ícone hoje é feito com size="sm" + padding manual — o oposto de um sistema.Ou se adota (e os botões de ícone do admin migram, ganhando alvo de toque de 44px), ou se remove do cva. Variante que ninguém usa é código que mente sobre o padrão.
focus: vs focus-visible:Input usa focus:ring-2 ring-blue/15 (dispara também no clique de mouse); Button e Switch usam focus-visible:ring-2 ring-blue/40. Gatilho diferente, opacidade diferente — e o ring-offset do Switch vem sem cor, então cai no branco default do Tailwind e some sobre fundo escuro.Um anel só pro sistema: focus-visible:ring-2 ring-blue/40 ring-offset-2 ring-offset-bg nas três. Campo de texto pode manter a borda azul no focus:, que é dica de edição, mas o anel é do teclado.
Dois mecanismos de press, e cores cruas fora do tokenButton embute active:scale-[0.96] no cva; Switch e o olho do PasswordInput usam a classe .press — mesma transformação, dois caminhos, e o comentário do Button ainda sugere que são a mesma coisa. Na mesma linha: Switch off em bg-[#cbd5e1], Button em text-[#fff], e text-[#fff] convivendo com text-white em oito arquivos dos padrões compostos.Deixar o .press como único mecanismo (é o que os padrões compostos usam) e trocar os hex crus pelos tokens — --line no trilho off, uma escrita só pro branco.
Cinco exports não encaminham ref nem espalham propsBadge, Progress, Skeleton e Switch são funções simples. Consequência concreta e medível: Skeleton não aceita nem aria-hidden; Progress não aceita aria-label, então a barra de painel-whats.tsx:985 é um role="progressbar" sem nome acessível; Switch tipa aria-label mas não aria-labelledby.forwardRef + spread de ...rest nas quatro. Mudança pequena, destrava acessibilidade e composição.

Dívida dos padrões compostos

DívidaCusto hojeCorreção
✅ VersiculoLinha usava itálico — corrigidoshared/versiculo.tsx marcava a citação com italic, a única ocorrência entre os 37 padrões — e saía em duas superfícies: o dashboard do admin e o relatório de ponto impresso. Itálico é banido na marca.Resolvido em 31/07/2026: virou font-[500] text-ink-soft. A aspa curva já marcava a citação sozinha — a inclinação não fazia trabalho nenhum. Fica aqui como registro, porque dívida paga também ensina.
🚨 EditorAvatar oferece o azul erradoeditor-avatar.tsx:398 → value={cor || "#1f3d8e"}. O azul da marca é #1137A2; #1F3D8E é o azul antigo. Ou seja: a cor que o admin sugere ao cadastrar uma pessoa não é a cor da marca.Trocar pelo token. E varrer #1F3D8E no repo inteiro — este achado é de um arquivo só, provavelmente não é o único.
Um conjunto de primitivas paralelo: components/central/ui.tsxExporta Card, Badge, Btn, Pill, EmptyState, Modal, Drawer, SectionTitle — os mesmos papéis, com API diferente (<Badge bg fg> em vez de tone) e paleta hex própria (page: "#08080C", gold: "#FF9900"), sem relação com nenhuma das duas superfícies de token. Quem abre o autocomplete encontra dois Card e dois Badge importáveis.Decidir: ou central/ é um produto separado com DS próprio declarado, ou migra pra components/ui/. Conviver sem regra é o pior dos três.
Nomenclatura de callback divididaonClicar e aoClicar são o mesmo conceito com dois nomes; onFechar (MenuFlutuante) e aoFechar (DialogMeuPerfil) também. E cor significa hex livre em quatro componentes e enum de série no GraficoBarras.Escolher um prefixo (on*, que é maioria) e um nome pra cor de série (serie, não cor).
AvatarAtendente repete um bug já corrigido no AvatarAvatar calcula o texto com readableTextOn(cor); AvatarAtendente fixa text-[#fff] — uma cor clara de atendente deixa as iniciais invisíveis, exatamente o bug que o Avatar já pagou. Além disso um exige cor e o outro tem fallback.Usar readableTextOn nos dois e dar fallback de gradient ao AvatarAtendente.
Escala tipográfica furada abaixo de text-tinytext-[10px], text-[11px] e text-[9px] em MenuTitulo, AvatarAtendente, SeletorPrioridade e GraficoBarras. A escala do config para em text-tiny (11px) — abaixo disso todo mundo improvisa. No SeletorPrioridade o contraste foi medido (não é descuido), mas o tamanho segue fora do sistema.Criar um degrau text-micro (10px) no config, com line-height e tracking definidos, e migrar os quatro.
Duas soluções de tabela sem regra escrita, e quatro estados vaziosTabela (12 usos, só Pessoas) e TabelaAdmin (7 usos) resolvem o mesmo problema de dois jeitos, e nenhum arquivo diz quando usar qual — o próximo módulo escolhe no chute. Cada uma tem o próprio vazio, e nenhuma usa o EmptyState compartilhado.A regra de escolha está publicada na seção “Tabelas e dado denso” acima — a confirmar com o Victor e depois escrita no cabeçalho dos dois arquivos. Vazio: seção inteira → EmptyState; dentro de tabela → o vazio da tabela.
EmptyState sem aria-hidden no íconeÉ o único componente do escopo com ícone puramente decorativo sem a marcação — LinhaModulo, SeletorPrioridade, TabelaAdmin, ScrollComDegrade e GraficoBarras todos marcam.Adicionar aria-hidden no <Icon>. Uma linha.
peer-disabled do Label é decorativoExige um irmão anterior com a classe peer. O único peer do repo está num checkbox sr-only sem Label acoplado, e o consumidor canônico (Campo) põe o Label dentro de um <div> separado do campo — nunca irmão. A regra nunca dispara.Ou o Campo passa a colocar Label e controle como irmãos com a classe peer, ou o estilo sai do Label. Código que não roda em lugar nenhum ensina errado.
CardContent depende do CardHeader sem dizerCardContent é p-5 pt-0 — assume um Header acima fechando o respiro do topo. Usado sozinho, o conteúdo encosta na borda. Nada no tipo sinaliza a dependência. E as duas convenções de padding (16 do caller, 20 dos sub-componentes) convivem na mesma tela.Documentar no tipo (ou aceitar pt quando é o primeiro filho) e fixar 20px como padding único do Card no DS.
Ícone dimensionado duas vezes no ButtonA base do cva faz [&_svg]:size-[18px], e os callers passam className="size-4" no ícone assim mesmo. Mesma especificidade de utilitário: o resultado depende da ordem no CSS gerado — ou seja, o tamanho do ícone no admin não é previsível pela leitura do componente.Tirar o size-* dos callers e deixar o botão mandar. Se algum caso precisa de outro tamanho, ele vira variante — não exceção local.

Fica de olho

O que mantém a marca inteira — e o que a quebra.

Sempre

  • Antes de criar componente, procurar nas 53 peças deste capítulo
  • Importar de components/ui/ e components/*/shared/ — nunca copiar as classes
  • Superfície B (bg-blue, text-ink, border-line) em tudo que não é o site público
  • Todo controle com rótulo visível ou aria-label; ícone decorativo com aria-hidden
  • Filho com radius MENOR que o pai (rounded-md dentro de rounded-lg)
  • Opacidade sobre cor do tema: hex literal ou opacity-NN no elemento
  • Estado vazio de seção com EmptyState — sempre com próxima ação

Nunca

  • Recriar Button, Card ou Input com classes copiadas na tela nova
  • Importar Card/Badge de components/central/ui.tsx achando que é o DS
  • Textarea, select e checkbox à mão com classes inventadas — use as do Input até a primitiva existir
  • Misturar .vidro-* (marca) com .glass-* (admin) na mesma superfície
  • Amarelo como texto sobre claro, ou texto branco sobre amarelo
  • Itálico em qualquer lugar — inclusive citação: a aspa curva já marca, a inclinação não faz trabalho
  • Passar função como prop de Server Component pra Client Component

Onde continuar

Os capítulos que este depende e que dependem dele.