Pular para conteúdo

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:

  1. O rótulo ocupa espaço que ninguém reservou. Quando sobe (shrink), o MUI aplica transform: 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.
  2. O vão é menor do que o rótulo. O tema declara spacing: 4 (grade de 4pt), então o gap: 1.75 do CreditAnalysisForm são 7px, não 14px. Sete menos nove dá negativo: o rótulo invade a faixa do campo anterior. É o erro que o docs/ui-patterns.md já 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 FieldGrid compartilhado. Foi cogitado (seguiria o caminho do FormPanel na 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 em rowGap/columnGap para 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 com alignItems: "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 a FormControl que tenha um InputLabel como filho direto — o que cobre TextField, FormControl + Select e 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} no Grid container, ou rowGap: 2, columnGap: 4 em display: grid/flex. Onde hoje há um gap único, ele se separa nos dois eixos.

  • RN4 — A reserva não é global. Vale dentro de contexto de formulário — corpo do FormPanel e 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 FormPanel tem 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.md a 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 === 0 em 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, por boundingBox, 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. → aferido margin-top: 0px nos 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.md deixa 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. tsc limpo (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 — FormPanel aplica a reserva; diálogos de formulário idem
  • [x] apps/web — normalizar os contêineres da tabela da seção 5 para rowSpacing={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) e tsc limpo
  • [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 no CLAUDE.md (empate de created_at no SQLite). Nenhum arquivo da API foi tocado.
  • tsc: limpo, no web e no e2e.
  • 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.