0018 — Respiro do label dos campos de formulário¶
| Status | CONCLUÍDA |
| Branch | spec/0018-respiro-do-label-dos-campos |
| Criada em | 2026-08-04 |
| Apps afetados | web · e2e (api: sem impacto) |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
1. Problema¶
No mobile, um campo preenchido cola no campo de cima. O print que originou esta spec é a Análise de Crédito em 390px: Prazo (meses) com o valor 12 — o rótulo "Prazo (meses)" sobe para cima da borda do campo e fica a ~3px do campo IPTU logo acima. Não há respiro: o rótulo parece pertencer ao campo errado, e a coluna de campos vira um bloco único.
O print engana por mostrar só um campo preenchido. Os três de cima (Aluguel, Condomínio, IPTU) parecem certos porque estão vazios — o rótulo ainda está deitado dentro da caixa. Assim que o usuário digita, o rótulo de cada um sobe e a tela inteira fica como o Prazo. Formulário preenchido é o estado normal de quem edita um cadastro; o estado bonito é o do formulário vazio, que quase ninguém vê.
Duas causas somadas:
- O rótulo ocupa espaço que ninguém reservou. Quando sobe (
shrink), o MUI aplicatransform: translate(14px, -9px) scale(0.75)— o rótulo passa a ocupar 9px acima da borda superior do campo, dos quais ~7px são texto visível. Esse espaço sai do vão entre um campo e outro; nenhum contêiner o reserva. - O vão é menor do que o rótulo. O tema declara
spacing: 4(grade de 4pt), então ogap: 1.75doCreditAnalysisFormsão 7px, não 14px. Sete menos nove dá negativo: o rótulo invade a faixa do campo anterior. É o erro que odocs/ui-patterns.mdjá avisa ser "o mais fácil de cometer neste app" e que custou a spec 0016 inteira.
E o desvio não é de uma tela só. O padrão documentado é spacing={4} (16px), seguido por renters, agencies, my-garantidora, settings e outros; mas convivem hoje spacing={2} (8px) no onboarding, spacing={1.5} (6px) em properties e roles, gap: 2.5 (10px) em users, gap: 1.5 (6px) no DecisionPanel e gap: 1.75 (7px) na Análise de Crédito. Cada formulário escolheu o seu. O próprio ui-patterns.md se contradiz: a linha 28 manda spacing={{ xs: 4, sm: 2 }} e a linha 112 manda spacing={4} sempre, argumentando que 8px "não separa nada".
2. Objetivo¶
Que o espaço do rótulo seja do rótulo — reservado pelo próprio campo, no design system, e não dependente de cada página acertar o gap. Um formulário preenchido passa a ter o mesmo respiro de um formulário vazio, em qualquer tela do app e em qualquer viewport.
3. Fora de escopo¶
- Componente
FieldGridcompartilhado. Foi cogitado (seguiria o caminho doFormPanelna spec 0016) e ficou de fora: exigiria migrar ~35 contêineres para uma API nova, quando a reserva no design system resolve o defeito sem que a página precise saber de nada. - Espaçamento horizontal entre campos lado a lado. Continua 16px. Esta spec mexe só no eixo vertical — exceto onde o contêiner declara
gapúnico para os dois eixos e precisa ser separado emrowGap/columnGappara preservar os 16px horizontais. - Barras de filtro e cabeçalhos (
FilterBar,contracts/index,audit-logs,operations-center/HeaderBar,DocumentsPanel). Têm campos com rótulo em linha horizontal comalignItems: "center"; a reserva ali desalinharia o campo em relação aos botões vizinhos. Ver RN4. - Altura, cor, borda e tipografia dos campos. Nada além do espaço vertical.
- Revisão geral do espaçamento do app (entre painéis, entre seções, margens de página). Só o vão entre campos.
4. Regras de negócio¶
-
RN1 — A distância vertical entre dois campos empilhados é 16px, composta de 8px reservados pelo próprio campo (RN2) mais 8px de vão do contêiner (RN3). Descontados os 7px de texto do rótulo, sobram ~9px de branco visível entre a borda inferior de um campo e o topo do rótulo do seguinte. É exatamente o que as telas hoje corretas (
spacing={4}) já mostram: nelas o resultado desta spec é pixel-idêntico ao atual; o que muda é de onde vem o espaço. -
RN2 — Todo campo com rótulo reserva 8px acima de si. A reserva vive no design system (
apps/web/src/design-system/), aplicada aFormControlque tenha umInputLabelcomo filho direto — o que cobreTextField,FormControl+Selecte os campos compostos (DateInput,CepField,MaskedController) sem regra por componente. -
RN3 — Contêiner de campos empilhados usa vão vertical de 8px e horizontal de 16px:
rowSpacing={2} columnSpacing={4}noGrid container, ourowGap: 2, columnGap: 4emdisplay: grid/flex. Onde hoje há umgapúnico, ele se separa nos dois eixos. -
RN4 — A reserva não é global. Vale dentro de contexto de formulário — corpo do
FormPanele diálogos de formulário. Campos com rótulo em barra horizontal (alignItems: "center") ficam de fora: ali a margem superior deslocaria o campo em relação aos botões e selects vizinhos, trocando um desalinhamento por outro. -
RN5 — A reserva é incondicional, presente com o campo vazio ou preenchido. Se dependesse de
.MuiInputLabel-shrink, o formulário saltaria na tela no instante em que o usuário digitasse o primeiro caractere. O layout não pode depender do conteúdo. -
RN6 — O primeiro campo do painel também reserva. É desejado: hoje o rótulo do primeiro campo quase encosta no cabeçalho da seção, porque o corpo do
FormPaneltem 8px de padding no mobile e o rótulo come 9px deles. -
RN7 — A regra vale igual em desktop e mobile. Um valor só, sem breakpoint: o rótulo sobe os mesmos 9px em qualquer largura. Isso resolve a contradição do
docs/ui-patterns.mda favor da linha 112 — e a linha 28 (spacing={{ xs: 4, sm: 2 }}) sai. -
RN8 — Nenhuma tela ganha rolagem horizontal por conta da mudança (
scrollWidth - clientWidth === 0em 375px), e nenhum campo hoje correto fica mais apertado do que está.
5. Impacto por app¶
apps/api¶
Sem impacto. Nenhum modelo, endpoint, schema ou migration.
apps/web¶
Design system (src/design-system/) — a reserva da RN2, exportada como estilo único e documentada junto ao tema. Aplicada pelo FormPanel (todo formulário do app ganha de graça) e pelos diálogos de formulário, que não passam pelo FormPanel.
Descartada a alternativa de uma regra global em theme/components.ts sobre MuiFormControl: ela alcançaria as barras de filtro de Contratos, Auditoria e Centro de Operações e o seletor de tipo do DocumentsPanel, que são linhas horizontais centradas (RN4).
Componentes
components/shared/FormPanel.tsx— passa a aplicar a reserva no corpo.components/shared/DocumentsPanel.tsx— não muda (barra horizontal, RN4).
Contêineres a normalizar (RN3) — o vão vertical vai a 8px, o horizontal fica em 16px:
| Arquivo | Hoje |
|---|---|
pages/credit-analyses/CreditAnalysisForm.tsx | gap: 1.75 (7px, 9×), gap: 1.5, gap: 1.25 |
pages/credit-analyses/components/DecisionPanel.tsx | gap: 1.5 (6px) |
pages/credit-analyses/components/ContractCreateDialog.tsx | gap: 2 (8px) |
pages/contracts/components/BillingCreateDialog.tsx | gap: 2 (8px) |
pages/properties/form.tsx | spacing={1.5} (6px) e spacing={4} |
pages/roles/form.tsx | spacing={1.5} (6px) e spacing={4} |
pages/users/form.tsx | gap: 2.5 (10px) e spacing={4} |
pages/onboarding/StepTenant.tsx | spacing={2} (8px) |
pages/agencies/form.tsx, pages/agencies/show.tsx (diálogo) | spacing={4}, gap: 2 |
pages/renters/form.tsx, pages/my-garantidora/index.tsx, pages/settings/index.tsx | spacing={4} |
pages/settings/credit-analysis-documents/AreaPanel.tsx | gap: { xs: 2.5, sm: 2 } |
pages/login, register, accept-invite, resetPassword, forgotPassword | conferir e alinhar |
Os spacing={4} entram na lista sem mudar de aparência: passam a rowSpacing={2} columnSpacing={4}, e os 8px que perdem no vão voltam pela reserva do campo (RN1).
data-testid: nenhum atributo novo. A mudança é de espaço, não de estrutura; os testids existentes bastam para a verificação da RN1.
Sider: sem impacto — não há módulo novo.
Responsividade & PWA: a regra é única para desktop e mobile (RN7). Sem impacto no service worker ou em assets; o PWA só precisa do rebuild de sempre para servir o bundle novo.
apps/e2e¶
- Spec:
tests/ganha um cenário de layout que abre a Análise de Crédito em viewport de 375px, preenche Aluguel, Condomínio, IPTU e Prazo — é o preenchimento que levanta os rótulos e reproduz o defeito — e afere, porboundingBox, que a distância entre a borda inferior de um campo e o topo do rótulo do seguinte é ≥ 8px. Afere também a ausência de rolagem horizontal (RN8). - Seeds: nenhum. O cenário roda no formulário em branco, com o seed de autenticação já existente.
6. Contrato de API¶
Sem alteração. Nenhum endpoint criado, removido ou modificado.
7. Critérios de aceite¶
- [x] CA1. Dado o formulário de Análise de Crédito em 375px, quando Aluguel, Condomínio, IPTU e Prazo estiverem preenchidos, então o rótulo de cada campo tem ao menos 8px de branco entre ele e o campo imediatamente acima — nenhum rótulo encosta no campo anterior. → aferido: −2,0px antes, +7,0px depois (borda do campo ao topo da caixa do rótulo; o texto visível fica ~2px abaixo disso, dando os ~9px de branco). Os três pares medem 7,0px.
- [x] CA2. Dado qualquer formulário do app (Inquilinos, Imóveis, Imobiliárias, Usuários, Papéis, Parâmetros, Minha Garantidora, Onboarding), quando preenchido, então o vão entre campos empilhados é o mesmo — 16px de campo a campo — em desktop e mobile. → auditado em Inquilinos (375px e 1440px): 8px de reserva em todos os nove campos, nos dois viewports.
- [x] CA3. Dado um campo vazio, quando o usuário digita o primeiro caractere e o rótulo sobe, então nenhum elemento da tela se desloca. → garantido por construção (RN5, reserva incondicional) e coberto por cenário no e2e.
- [x] CA4. Dadas as barras de filtro de Contratos, Auditoria e Centro de Operações e o seletor de tipo do
DocumentsPanel, então os campos com rótulo continuam alinhados verticalmente com os botões e selects da mesma linha. → aferidomargin-top: 0pxnos filtros de Contratos e Auditoria. - [x] CA5. Dado o primeiro campo de um
FormPanel, então seu rótulo não encosta no cabeçalho da seção. → o primeiro campo recebe a mesma reserva de 8px (RN6), visível no screenshot de "Locação e renda". - [x] CA6. Nenhuma tela apresenta rolagem horizontal em 375px (
scrollWidth - clientWidth === 0). → aferido 0px em 375px e em 1440px. - [x] CA7.
docs/ui-patterns.mddeixa de se contradizer: uma única regra de vão entre campos, com a aritmética de 4pt explícita e a menção à reserva do rótulo. - [x] CA8.
tsclimpo (make lint), sem apontamento novo nos arquivos tocados.
8. Plano de implementação¶
Ordem obrigatória no monorepo: api → web → e2e. Aqui a api não entra.
- [x]
apps/api— sem impacto, nada a fazer - [x]
apps/web— reserva de 8px no design system (RN2), com o comentário explicando de onde vem o número (translate(14px, -9px)do MUI) - [x]
apps/web—FormPanelaplica a reserva; diálogos de formulário idem - [x]
apps/web— normalizar os contêineres da tabela da seção 5 pararowSpacing={2} columnSpacing={4}/rowGap: 2, columnGap: 4(RN3) - [x]
apps/web— conferir que barras horizontais ficaram de fora (RN4/CA4) - [x]
docs/ui-patterns.md— regra única de vão entre campos; remover a contradição das linhas 28 e 112 - [x]
apps/e2e— spec de layout do formulário preenchido em 375px - [x]
make test(205 passaram; a falha é a intermitente já documentada) etsclimpo - [x]
/devremote-rebuild+ script de screenshot nos dois viewports, com formulário preenchido, comparando antes e depois
9. Decisões em aberto¶
Nenhuma. As três decisões que abriam a spec foram resolvidas antes da escrita:
- Alcance: varrer todos os formulários, não só a Análise de Crédito.
- Mecanismo: reserva no design system + normalização dos contêineres (não um componente
FieldGrid). - Desktop: mesmo valor do mobile, 16px de campo a campo.
10. Registro¶
O número que a spec previu, medido¶
Antes: −2,0px entre a borda inferior do IPTU e o topo da caixa do rótulo de "Prazo (meses)" — o rótulo entrava na faixa do campo de cima. Depois: +7,0px. É a conta da seção 1 saindo na medida: 7px de vão menos 9px de rótulo dava negativo.
O que divergiu do planejado¶
1. A reserva não pôde ser global no tema — e nem só nas barras de filtro.
A RN4 previu a exceção nas barras horizontais fora do painel. A auditoria achou o mesmo problema dentro do painel: as linhas de corresponsável do CreditAnalysisForm são display: flex com alignItems: "center", com um campo de renda rotulado entre o nome e o botão de remover. A reserva ali empurraria só o campo, desalinhando-o dos vizinhos.
Daí o fieldRowSx, que não estava no plano. Ele usa &&& de propósito: a regra do painel é um seletor descendente com :has() (especificidade 0,3,0) e venceria um & .MuiFormControl-root simples. Sem triplicar a classe, o cancelamento dependeria da ordem em que o emotion injeta as classes — determinismo por acaso.
2. Apareceu um terceiro caso: a linha que vira coluna.
Os dois contêineres do AreaPanel são row no desktop e column no mobile. Precisam da reserva num viewport e do cancelamento no outro — daí o fieldLabelReserveWhenStackedSx, com marginTop: { xs, sm }.
Vale registrar que o AreaPanel já tinha um remendo local para este exato defeito, com o comentário: "Empilhados no mobile, os campos rotulados precisam de folga: com 8px o label flutuante de um encosta na borda do campo de cima." Alguém tropeçou nisto antes, diagnosticou certo e corrigiu no único lugar que dava — a própria página. É a assinatura do problema que a spec 0018 resolve na raiz.
3. Quinze arquivos não passam pelo FormPanel.
O plano supunha que aplicar a reserva no FormPanel cobriria o app. Só oito arquivos o usam: users/form e roles/form ainda têm painel próprio (resquício das seis cópias que a spec 0016 unificou pela metade), e diálogos e telas de autenticação não têm painel nenhum. Cada um recebeu a reserva no próprio contêiner. Unificar users e roles no FormPanel continua pendente — não entrou aqui para não misturar com a spec.
4. O eixo horizontal ficou como estava, e isso merece nota.
A seção 3 dizia "preservar os 16px horizontais". No CreditAnalysisForm não havia 16px a preservar: o gap: 1.75 valia 7px nos dois eixos. Separei em columnGap: 1.75 (inalterado) e rowGap: 2, corrigindo só a vertical. Os 7px horizontais entre campos lado a lado no desktop continuam lá — é um aperto real, mas não é o defeito relatado, e mexer nele mudaria a largura de todas as colunas do formulário. Fica como questão separada.
5. docs/ui-patterns.md estava desatualizado além do espaçamento.
Ao abrir o arquivo para a CA7, apareceram três afirmações vencidas, corrigidas junto: o tema "do pacote" (a spec 0017 desfez o pacote), a sobrescrita de altura do Select apontando para appOverrides/contexts/color-mode (voltou para design-system/theme/components.ts na 0017 — esta estava errada, não só velha), e os exemplos com inputProps/InputLabelProps, removidos do MUI até a v9 e sem nenhuma ocorrência no src/. Na mesma leva, os dois CLAUDE.md passaram a dizer MUI v9 em vez de v6.
Verificação¶
make test: 205 passaram, 1 falhou —test_partner_agency_search.py::test_busca_alcanca_quem_esta_fora_da_primeira_pagina, a intermitente já documentada noCLAUDE.md(empate decreated_atno SQLite). Nenhum arquivo da API foi tocado.tsc: limpo, nowebe noe2e.- Medição no dev-remote, depois do
/devremote-rebuild: os números das CA1–CA6 acima. make e2e: 299 passaram, 6 pulados, 2 falharam — e as duas falhas eram do teste novo, não do código. A suíte existente passou inteira.
O cenário CA3 comparava o y do campo antes e depois de preencher outro campo. Mas boundingBox() é relativo ao viewport, e o fill() do Playwright rola o alvo para dentro da tela: os 11px de "salto" acusados eram scroll. Corrigido para medir a distância entre dois campos dentro do mesmo evaluate, que cancela o scroll. Reexecução: 10/10 em chromium, firefox e webkit — o webkit passando é o que confirma o :has() no motor do Safari, a única dependência de navegador da solução.
Vale como lição: um teste de layout que lê coordenada absoluta mede a posição da câmera, não a do objeto.
Adendo — rótulo deitado fora do centro (achado na validação)¶
Validando o onboarding e o signup no desktop, o usuário viu o rótulo deitado (campo vazio) alinhado à base do campo em vez de centrado. Não é regressão desta spec — a 0018 mexeu na margem do FormControl, nunca na posição do rótulo dentro do campo —, mas é o mesmo assunto e a branch estava aberta, então entrou aqui.
Causa. MuiOutlinedInput.input força height: 36 em todos os tamanhos, mas o MUI continua posicionando o rótulo deitado pelo size: translate(14px, 9px) no small, translate(14px, 16px) no medium — este calculado para a caixa de 56px que o tema não usa. Um rótulo de 18,7px centrado numa caixa de 36px começa em 8,65px: os 9px do small dão +0,34px de desvio, os 16px do medium dão +7,3px.
Aparecia só onde o size="small" que o ui-patterns.md manda foi esquecido — o onboarding (18 campos, nenhum), accept-invite e resetPassword (nenhum), e o signup (um de três).
Correção: uma altura só exige uma posição de rótulo só. O tema passa a fixar translate(14px, 9px) para todo rótulo deitado de campo outlined, independente do size (design-system/theme/components.ts). Corrigir no tema, e não campo a campo, é o que impede o próximo <TextField> sem size de nascer torto.
Aferido depois do rebuild: signup, onboarding e Inquilinos (que já estava certo) todos em +0,34px, em 1440px e 375px. O multiline "Observações" mede −20px de propósito — em textarea o rótulo fica no topo, e continua ficando.
Deixado de fora: acrescentar size="small" às páginas que não passam. Com a correção no tema elas já renderizam igual, e a mudança seria ruído sem efeito visual. Fica a inconsistência com o ui-patterns.md — anotada, não corrigida.