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ÍDANota 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 dosxvalem 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
FormPanelparacomponents/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 desm. - Telas fora do
AppLayout— login, registro, recuperação de senha, onboarding. - Admin global (
src/admin/). Tem casca própria (AdminLayout) e não consome oPageHeader. - Rever a composição do
RecordCardou daMobileFilterBar. Ajuste de medidas, não de estrutura. - Promover o
FormPanelao pacote@garantora/design-system. A peça nasce emapps/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_Hemcomponents/mobile-shell/index.tsx). A tab bar segue em 62px. - RN2. No mobile, o
PageHeaderdesenha o ícone dentro de um contêiner: caixa de 40×40px,borderRadius: 1.5, fundotheme.garantora.surfaceNavActive, glifo de 24px emtheme.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
PageHeadercontinua 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 passarsize={ICON_SIZE.button}e produzir um topo de 16px. - RN5. Ação no topo do mobile tem alvo de 44px — o mesmo
DRAWER_ICON_BTNda 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) parap: { xs: 4 }(16px), em todas as páginas desrc/pages/. Osm: 3do 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 depx/py: 1.75/1.5(7/6px) para3/3(12/12px); título e cada linha demetademt: 0.5/0.25(2px/1px) paramt: 1(4px); rodapé de ações depy: 0.75(3px) parapy: 2(8px). - RN9.
RecordCardListpassa despacing={1.25}(5px) paraspacing={3}(12px). - RN10.
MobileFilterBarpassa demb: 1.5(6px) paramb: 3(12px), e o contador demt: 0.5(2px) paramt: 1(4px). - RN11. Nos formulários, o
Grid containerpassa despacing={2}(8px) paraspacing={4}(16px), nos dois viewports. Em coluna única — que é o quexs: 12produz — 8px entre um campo comhelperTexte 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) paragap: { xs: 5, sm: 4 }(20px no mobile, 16px no desktop) — painel separa mais que campo, senão a hierarquia some. O rodapé fixo vai depy: 1.5(6px) parapy: { xs: 3, sm: 1.5 }(12px no mobile). - RN18. Todo campo de formulário tem a mesma altura: 36px. O
Selectficava em 23px porque o pacote dimensionaMuiOutlinedInput.input, e o MUI aplicaheight: auto; minHeight: 1.4375emna própria classe.MuiSelect-select— 1.4375em sobre os 16px do piso do iOS dá exatamente 23px. Corrigido emappOverridesdocolor-mode, comdisplay: flexpara centrar o texto, já que o pacote zera o padding vertical.
Peça única¶
- RN13. Existe um
FormPanel, emcomponents/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. Aceitatitle,description?,children,data-testid?e umsx?que se funde aoPaper— é osxque preserva a sombra particular do painel de Parâmetros sem impor sombra aos outros. - RN14. O
FormPanelusap: { 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 nodocumentElemente 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:gridTemplateColumnspassa aminmax(0, 1fr)e o segmented control ficaorientation="vertical"no mobile. - RN16. Nenhum
data-testidexistente 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:
40x40nas quatro - [x] CA3. Em 1440px o topo está idêntico ao de antes: ícone de 20px sem contêiner. — medido:
20x20nas 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 === 0em todas as telas tocadas, inclusive Parâmetros. — medido:0; era256em 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
skipconhecidos, 0 falhas, contra o container reconstruído
8. Plano de implementação¶
- [x]
apps/api— sem impacto - [x]
apps/web—components/shared/FormPanel.tsxnovo + export - [x]
apps/web— migrar as seis páginas;settingspassa a sombra viasx - [x]
apps/web—PageHeader: contêiner do ícone, tamanho próprio,data-testid - [x]
apps/web—MobileShell:TOPBAR_H64 - [x]
apps/web— alvo de 44px na ação do topo (TOPBAR_ACTION_SX) - [x]
apps/web—RecordCard,RecordCardList,MobileFilterBarna 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— atualizardocs/ui-patterns.md - [x]
apps/e2e—tests/e2e/layout-mobile.spec.ts - [x]
pnpm build(tsc) limpo;make testsem 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
MuiSelectsem altura é defeito do pacote, não do app — a sobrescrita daqui é remendo local. Vale levar junto com a divergência dosurfacePanelHeader.
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.