Pular para conteúdo

0016 — Topo e espaçamento no mobile: listas e formulários

Status CONCLUÍDA
Branch spec/0016-layout-mobile-listas-forms
Criada em 2026-08-04
Apps afetados web · e2e (api sem impacto)

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

Nota de revisão (2026-08-04). A primeira versão desta spec calculou todos os espaçamentos numa grade de 8pt. O tema do design system usa spacing: 4 — os valores do sx valem metade do que pareciam (p: 2 é 8px, não 16px). Os números abaixo foram refeitos e medidos no browser. A causa raiz do problema mudou junto: não era padding demais dentro do painel, era margem de menos em toda parte.

1. Problema

No celular, o topo das telas de cadastro não sustenta a página. A barra tem 56px de altura e recebe um bloco de duas linhas (título + subtítulo) ao lado de um ícone de 20px solto, sem contêiner e sem cor própria — o ícone some ao lado do texto e o topo lê como um cabeçalho de tabela, não como a identidade da tela. Quem abre Inquilinos, Imóveis ou Imobiliárias no telefone não tem, à primeira vista, o sinal de "onde estou" que a sider dá no desktop.

O segundo incômodo é de espaço, e a causa é uma só: o app foi escrito como se a grade fosse de 8pt, e ela é de 4pt. O tema do pacote declara spacing: 4 — theme.spacing(4) é 16px. Cada sx do app entrega metade do respiro que o número sugere:

Onde No código Na tela Parece
Margem da página p: { xs: 2 } 8px 16px
Entre cartões da lista spacing={1.25} 5px 10px
Dentro do cartão px: 1.75 / py: 1.5 7px / 6px 14px / 12px
Título → primeira linha de contexto mt: 0.25 1px 2px
Rodapé de ações do cartão py: 0.75 3px 6px
Entre campos do formulário spacing={2} 8px 16px
Entre painéis do formulário gap: 2.5 10px 20px

Uma margem de 8px na borda da tela é metade do que qualquer aparelho usa; 5px entre cartões não separa registro de registro; 1px entre o nome e o telefone cola as duas linhas. Não é uma tela apertada — é o app inteiro a meio passo da grade.

Há ainda um defeito próprio, que só aparece medindo: Parâmetros rolava 256px na horizontal num aparelho de 375px. O grid dos painéis usa 1fr, e um item de grid tem min-width: auto — a coluna não encolhe abaixo do conteúdo. O segmented control de "Tipo de cobrança inicial" mede 615px de min-content com os três rótulos lado a lado, e esticava a coluna inteira.

2. Objetivo

No celular, o topo de qualquer tela passa a ter presença — ícone em contêiner tingido dentro de uma barra de 64px — e listas e formulários passam a respeitar a grade de 4pt de verdade, sem que o desktop mude um pixel.

3. Fora de escopo

  • Desktop (≥ 600px). Nenhuma mudança visual. A extração do FormPanel para components/shared/ precisa ser pixel-idêntica no desktop; é refatoração, não redesenho.
  • Reconciliar o tingimento do cabeçalho de painel com o pacote. O design system tem theme.garantora.surfacePanelHeader (navy a 5%); o app usa teal a 5%. Adotar o token mudaria o matiz de todo painel, no claro e no desktop. Fica anotado para o design system decidir.
  • A gaveta de navegação e a tab bar. Vieram da spec 0011. Só a altura da top bar muda (RN1).
  • O DataGrid. Só aparece a partir de sm.
  • Telas fora do AppLayout — login, registro, recuperação de senha, onboarding.
  • Admin global (src/admin/). Tem casca própria (AdminLayout) e não consome o PageHeader.
  • Rever a composição do RecordCard ou da MobileFilterBar. Ajuste de medidas, não de estrutura.
  • Promover o FormPanel ao pacote @garantora/design-system. A peça nasce em apps/web/src/components/shared/.

4. Regras de negócio

Regras de layout — verificáveis em viewport, não em banco. Todo valor de sx abaixo está anotado com o pixel que produz na grade de 4pt.

Topo

  • RN1. A top bar do mobile passa de 56px para 64px (TOPBAR_H em components/mobile-shell/index.tsx). A tab bar segue em 62px.
  • RN2. No mobile, o PageHeader desenha o ícone dentro de um contêiner: caixa de 40×40px, borderRadius: 1.5, fundo theme.garantora.surfaceNavActive, glifo de 24px em theme.garantora.navActiveFg. São os dois tokens da pílula do item ativo da gaveta — o topo repete o sinal de "você está aqui" que a navegação já usa.
  • RN3. No desktop o PageHeader continua como hoje: ícone solto de 20px, color: secondary.main, opacity: 0.85, sem contêiner. O componente decide pelo breakpoint; a página não passa variante.
  • RN4. O tamanho do glifo é do PageHeader, não de quem o chama. As 25 chamadas de hoje caem no default de 20px por coincidência — nada impediria a próxima de passar size={ICON_SIZE.button} e produzir um topo de 16px.
  • RN5. Ação no topo do mobile tem alvo de 44px — o mesmo DRAWER_ICON_BTN da gaveta (spec 0011, RN2).
  • RN6. Título e subtítulo continuam em uma linha cada, com reticências (noWrap). Barra mais alta não é licença para o título quebrar em duas linhas.

Espaço — a grade de 4pt

  • RN7. A margem da página no mobile vai de p: { xs: 2 } (8px) para p: { xs: 4 } (16px), em todas as páginas de src/pages/. O sm: 3 do desktop não muda. É a regra que mais muda a percepção: 8px é metade do que qualquer aparelho usa de margem.
  • RN8. No RecordCard: corpo de px/py: 1.75/1.5 (7/6px) para 3/3 (12/12px); título e cada linha de meta de mt: 0.5/0.25 (2px/1px) para mt: 1 (4px); rodapé de ações de py: 0.75 (3px) para py: 2 (8px).
  • RN9. RecordCardList passa de spacing={1.25} (5px) para spacing={3} (12px).
  • RN10. MobileFilterBar passa de mb: 1.5 (6px) para mb: 3 (12px), e o contador de mt: 0.5 (2px) para mt: 1 (4px).
  • RN11. Nos formulários, o Grid container passa de spacing={2} (8px) para spacing={4} (16px), nos dois viewports. Em coluna única — que é o que xs: 12 produz — 8px entre um campo com helperText e o próximo não separa nada; e no desktop, lado a lado, 8px também não. (Revisto após a primeira entrega: valia só para o mobile e o desktop seguia nos 8px de sempre — ver Registro.)
  • RN12. O gap entre painéis passa de gap: 2.5 (10px) para gap: { xs: 5, sm: 4 } (20px no mobile, 16px no desktop) — painel separa mais que campo, senão a hierarquia some. O rodapé fixo vai de py: 1.5 (6px) para py: { xs: 3, sm: 1.5 } (12px no mobile).
  • RN18. Todo campo de formulário tem a mesma altura: 36px. O Select ficava em 23px porque o pacote dimensiona MuiOutlinedInput.input, e o MUI aplica height: auto; minHeight: 1.4375em na própria classe .MuiSelect-select — 1.4375em sobre os 16px do piso do iOS dá exatamente 23px. Corrigido em appOverrides do color-mode, com display: flex para centrar o texto, já que o pacote zera o padding vertical.

Peça única

  • RN13. Existe um FormPanel, em components/shared/FormPanel.tsx. As seis cópias locais (renters/form, properties/form, agencies/form, my-garantidora/index, settings/index, credit-analyses/CreditAnalysisForm) são substituídas por ele. Aceita title, description?, children, data-testid? e um sx? que se funde ao Paper — é o sx que preserva a sombra particular do painel de Parâmetros sem impor sombra aos outros.
  • RN14. O FormPanel usa p: { xs: 2, sm: 3 } (8px no mobile, 12px no desktop). O painel já tem borda; com a margem da página em 16px (RN7), 8px de padding interno bastam e devolvem largura ao campo.

Invariantes

  • RN15. Em 375px, nenhuma tela tocada pode rolar na horizontal: scrollWidth - clientWidth === 0, medido no documentElement e no <main> — a casca mobile é height: 100dvh; overflow: hidden, então um campo estourando infla o container interno sem que o documento perceba. Isso inclui corrigir Parâmetros, a única tela que já rolava: gridTemplateColumns passa a minmax(0, 1fr) e o segmented control fica orientation="vertical" no mobile.
  • RN16. Nenhum data-testid existente muda de nome ou desaparece — a suíte e2e atual precisa seguir passando sem edição.
  • RN17. Permissões e papéis: sem impacto.

5. Impacto por app

apps/api

Sem impacto. Nenhum modelo, endpoint, migration ou schema.

apps/web

Arquivo Mudança
components/mobile-shell/index.tsx TOPBAR_H 56 → 64 (RN1)
components/shared/PageHeader.tsx contêiner do ícone no mobile, tamanho próprio do glifo, TOPBAR_ACTION_SX (RN2–RN6)
components/shared/FormPanel.tsx novo — peça única com padding responsivo (RN13, RN14)
components/shared/RecordCard.tsx RecordCard e RecordCardList (RN8, RN9)
components/shared/MobileFilterBar.tsx margens (RN10)
components/shared/index.ts exporta FormPanel e TOPBAR_ACTION_SX
pages/** (25 arquivos, 28 pontos) margem da página p: { xs: 4, sm: 3 } (RN7)
pages/settings/index.tsx minmax(0, 1fr) + segmented control vertical no mobile (RN15)

Páginas que perdem a cópia local do FormPanel: renters/form, properties/form, agencies/form, my-garantidora/index, settings/index (passa a sombra via sx), credit-analyses/CreditAnalysisForm. users/form e roles/form não têm FormPanel — usam Paper com panelSx local e recebem só o padding responsivo equivalente.

data-testid novos — todos no PageHeader, que não tinha nenhum: page-header, page-header-icon, page-header-title, page-header-subtitle.

Sider: sem impacto — nenhum módulo novo.

Responsividade & PWA: abaixo de sm valem RN1, RN2, RN5, RN7–RN12, RN14 (xs); de sm para cima nada muda. Sem mudança de manifest, service worker ou assets — mas o web roda como build no dev-remote, então validar exige rebuild do container e reabrir o app instalado, senão o que aparece é o bundle cacheado.

Documentação: docs/ui-patterns.md ganha a regra da grade de 4pt, o FormPanel compartilhado e o contêiner do ícone do topo.

apps/e2e

Spec nova: tests/e2e/layout-mobile.spec.ts, em 375×812. Quatro cenários: topo (altura, contêiner, alvo de 44px, título numa linha), ausência de scroll horizontal nas quatro telas, margem de 16px + largura do campo, e 12px entre cartões.

Seeds: nenhum novo.

6. Contrato de API

Sem contrato novo. Nenhuma rota, campo ou código de erro tocado.

7. Critérios de aceite

  • [x] CA1. Em 375px, a barra do topo mede 64px e o ícone aparece num quadrado arredondado de 40×40 com fundo tingido. — medido: topbar 64, ícone 40x40
  • [x] CA2. Inquilinos, Imóveis, Imobiliárias e Parâmetros apresentam o mesmo topo. — medido: 40x40 nas quatro
  • [x] CA3. Em 1440px o topo está idêntico ao de antes: ícone de 20px sem contêiner. — medido: 20x20 nas cinco telas
  • [x] CA4. Em 375px, o conteúdo começa a 16px da borda da tela (era 8px). — medido: x = 16
  • [x] CA5. Em 375px, scrollWidth - clientWidth === 0 em todas as telas tocadas, inclusive Parâmetros. — medido: 0; era 256 em Parâmetros
  • [x] CA6. Dois cartões consecutivos têm 12px entre si (eram 5px). — medido: 12
  • [x] CA7. O rodapé fixo do formulário tem 12px de respiro vertical no mobile.
  • [x] CA8. grep -rn "const FormPanel" acha uma definição.
  • [x] CA9. Parâmetros mantém a sombra própria dos painéis no desktop.
  • [x] CA10. pnpm build (tsc) limpo; a API não foi tocada.
  • [x] CA11. A suíte e2e existente segue verde sem edição de spec (RN16). — 98 passando, 2 skip conhecidos, 0 falhas, contra o container reconstruído

8. Plano de implementação

  • [x] apps/api — sem impacto
  • [x] apps/web — components/shared/FormPanel.tsx novo + export
  • [x] apps/web — migrar as seis páginas; settings passa a sombra via sx
  • [x] apps/web — PageHeader: contêiner do ícone, tamanho próprio, data-testid
  • [x] apps/web — MobileShell: TOPBAR_H 64
  • [x] apps/web — alvo de 44px na ação do topo (TOPBAR_ACTION_SX)
  • [x] apps/web — RecordCard, RecordCardList, MobileFilterBar na grade de 4pt
  • [x] apps/web — margem de página, Grid spacing, gap de painéis e rodapé fixo
  • [x] apps/web — corrigir o scroll horizontal de Parâmetros
  • [x] apps/web — atualizar docs/ui-patterns.md
  • [x] apps/e2e — tests/e2e/layout-mobile.spec.ts
  • [x] pnpm build (tsc) limpo; make test sem regressão
  • [x] Validado no browser em 375px e 1440px, com medidas, não só olhando

9. Decisões em aberto

Nenhuma. As quatro que travavam a spec foram fechadas na entrevista:

Pergunta Decisão
Escopo das telas Todas as listas e formulários, atacando os componentes compartilhados
Desenho do topo Ícone de 24px em contêiner de 40px + barra de 56 → 64px
Leitura de "espaços justos" Os dois: devolver largura e aumentar o respiro
Alcance Só mobile (< 600px); desktop intocado

10. Registro

A grade era 4pt, não 8pt

O achado que reescreveu a spec. Os valores originais (FormPanel de p: 3 → p: 2, cartões de 1.25 → 1.5) foram calculados supondo theme.spacing(1) === 8px. O tema do pacote declara spacing: 4, então aquelas mudanças entregavam 1px de respiro a mais entre cartões — nada. Só apareceu porque a validação mediu: o gap entre dois cartões saiu 6px onde a spec prometia 12.

Consequência: a leitura do problema mudou. Não era padding demais dentro do painel, era margem de menos em toda parte — a começar pelos 8px da borda da tela. Os valores da seção 4 são os medidos, não os pretendidos.

O campo do formulário ficou 8px mais estreito, de propósito

A spec original prometia "devolver largura ao campo". Medido: 333px antes, 325px depois. A margem da página dobrou (8 → 16px) e comeu os 8px que o padding menor do painel devolveu. Foi uma troca deliberada — num aparelho de 375px não dá para ter margem de 16px e o campo mais largo ao mesmo tempo, e a margem é o que se vê. O critério de aceite foi reescrito para medir a margem, que é o que de fato mudou.

Parâmetros já rolava 256px na horizontal

Descoberto na validação, não no planejamento. Confirmado como pré-existente medindo a main com git stash: 256px antes, 248px com as mudanças (o padding menor do painel melhorou 8px). A causa é gridTemplateColumns: "1fr" — item de grid tem min-width: auto e não encolhe abaixo do min-content de 615px do segmented control. Corrigido com minmax(0, 1fr) e orientation="vertical" no mobile. Está fora do que a spec previa, mas dentro da RN15, e uma tela em escopo que rola de lado é a pior versão da queixa que originou o trabalho.

Cabeçalho de painel: teal, não navy

Ao unificar as seis cópias, duas (agencies/form, my-garantidora) ramificavam para background.default no tema escuro e quatro usavam teal a 5%. A peça única adota a maioria — que é também a da Análise de Crédito, a mais recente (spec 0015). No tema claro as duas fórmulas já davam o mesmo valor, então a mudança é só no escuro, nessas duas telas.

O pacote tem theme.garantora.surfacePanelHeader, mas é navy a 5%, não teal: adotá-lo mudaria o matiz de todo painel do app, inclusive no desktop. Fica anotado como divergência a reconciliar com o design system.

Margem de página não é padding de painel

A substituição de p: { xs: 2, sm: 3 } por { xs: 4, sm: 3 } pegou 32 pontos, e quatro não eram invólucro de página: o corpo do SelectionBlock da Análise de Crédito e três painéis de Equipe e Perfis de Acesso, que usam Paper com panelSx local em vez do FormPanel. Esses voltaram para { xs: 2, sm: 3 } — corpo de painel segue o FormPanel (8px), não a margem da tela (16px). Sobraram 28 pontos em 25 arquivos. A distinção não é cosmética: um painel com 16px internos dentro de uma página com 16px de margem soma 32px de moldura, que é o problema original de volta.

Espaçamentos que já estavam certos, e um vizinho

agencies/form e my-garantidora já usavam spacing={2.5} nos Grid; viraram { xs: 4, sm: 2.5 } como os demais. E settings/credit-analysis-documents/AreaPanel teve um py: { xs: 2, sm: 1.5 } promovido a { xs: 3, sm: 1.5 } junto com os rodapés — não é rodapé fixo, é uma linha de campos empilhados cujo próprio comentário já dizia que 8px era pouco.

Como foi validado

Boa parte da medição rodou antes do rebuild ser possível, num ambiente montado à mão — prática descartada depois desta spec (ver adiante). O que vale é a validação final: container reconstruído e medido contra https://dev.garantora.com.br/app, com os mesmos números.

O que impedia o rebuild era o agente SSH: sem SSH_AUTH_SOCK, docker compose build falha antes de começar, porque o design system é dependência git de repo privado. Isso hoje é problema resolvido — o skill /devremote-rebuild cuida do agente.

Medidas finais, em 375px e 1440px:

Medida Antes Depois
Altura da top bar (mobile) 56px 64px
Ícone do topo (mobile) 20px solto 40×40 com contêiner
Ícone do topo (desktop) 20px solto 20px solto (intacto)
Alvo do botão de ação (mobile) ~40px 44×44
Margem da página (mobile) 8px 16px
Entre cartões 5px 12px
Largura do campo "Nome" (mobile) 333px 325px
Scroll horizontal — Parâmetros 256px 0
Scroll horizontal — demais telas 0 0

e2e executado, contra o container reconstruído

O bloqueio do build caiu: o host tem uma chave (/root/.ssh/id_ed25519, sem passphrase) que autentica no GitHub. Com ssh-agent + ssh-add no mesmo comando do docker compose build web, o pnpm install da dependência git privada passa. Build em EXIT=0, imagem de 95,9 MB, compose up -d web recriado.

Isso permitiu validar contra o build de produção, não só contra o servidor de dev — e as medidas batem, o build não introduziu diferença:

Medida 375px 1440px
Top bar 64px —
Ícone do topo 40×40 20×20
Margem da página 16px —
Entre cartões 12px —
Scroll horizontal (4 telas) 0 0

layout-mobile.spec.ts: 5/5 passando (setup + os quatro cenários), em 24,7s.

Suíte completa (chromium): 98 passando, 2 skip, 0 falhas, em 3,2 min. Os dois skip são os semeadores em massa já marcados test.skip no repositório, não regressões. RN16 confirmada — nenhum spec existente precisou de edição.

Segunda passada: respiro real e a altura do Select

O usuário apontou, depois da primeira entrega, que os campos continuavam sem respiro e que o select era mais baixo que os outros campos. As duas coisas se confirmaram na medição, e as duas eram minhas:

Respiro. Eu havia subido o Grid spacing só no mobile ({ xs: 4, sm: 2 }), então o desktop seguia nos 8px originais. Medido: 16px no mobile, 8px no desktop. Passou a spacing={4} — 16px nos dois. O gap entre painéis foi para { xs: 5, sm: 4 } (20/16px), para o painel continuar separando mais que o campo.

Altura do Select. Medido: 23px contra 36px dos demais, em todos os viewports. A causa não é do app nem do padding: o pacote dimensiona MuiOutlinedInput.input com height: 36, mas o MUI aplica height: auto; minHeight: 1.4375em na própria classe .MuiSelect-select, que ganha da regra do tema. 1.4375em × 16px = 23px — o número batia exato, o que confirmou o diagnóstico antes de qualquer tentativa.

A correção é uma sobrescrita MuiSelect.styleOverrides.select em appOverrides (contexts/color-mode/), com display: flex; alignItems: center — sem isso o texto não se centra, porque o pacote zera paddingTop/Bottom. Vale para os dois caminhos de select do app: TextField select (Inquilinos, Imóveis) e FormControl + Select (Análise de Crédito, Parâmetros, Equipe).

Depois da correção, medido em Inquilinos, Imóveis, Análise de Crédito, Parâmetros e Equipe: selects e inputs todos em 36px, gaps de 16px, sem scroll horizontal em 375px nem em 1440px. Nenhum select do app é multiple, então fixar a altura é seguro.

A medição contra o container ainda pegou um resíduo: a zona condicional de Parâmetros (Percentual + Base de cálculo, dentro do bloco tingido) tinha gap: 2 próprio, então no mobile aqueles dois campos seguiam a 8px. Corrigido para gap: 4, e o 1fr daquele grid virou minmax(0, 1fr) — a mesma armadilha da RN15, uma linha antes de virar defeito.

Suíte e2e completa, contra o container reconstruído: 98 passando, 2 skip, 0 falhas (3,3 min).

Para o design system: o MuiSelect sem altura é defeito do pacote, não do app — a sobrescrita daqui é remendo local. Vale levar junto com a divergência do surfacePanelHeader.

O ambiente isolado que usei aqui não deve se repetir

Antes do rebuild, parte da validação rodou num servidor Vite no host com proxy para a API do dev-remote — um ambiente montado à mão. Funcionou, mas a prática foi descartada depois desta spec: o que se valida tem de ser o que vai para o ar, e um ambiente paralelo diverge no build de produção, no service worker e no roteamento do Caddy. A regra agora é /devremote-rebuild e validar contra o dev-remote; está nos CLAUDE.md da raiz e do apps/e2e, e nos skills /spec, /fix e /run-test.

As duas armadilhas que aquela rodada revelou sobreviveram, no lugar onde servem: o Host: dev.garantora.com.br exigido pelo Caddy está no skill /devremote-rebuild, e o page.goto que derruba a sessão está nas pegadinhas do apps/e2e/CLAUDE.md.