0020 — Padronizar o topo de página¶
| Status | CONCLUÍDA |
| Branch | spec/0020-padronizar-topo-de-pagina |
| Criada em | 2026-08-06 |
| Apps afetados | web · e2e (api: sem impacto) |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
1. Problema¶
1.1 No mobile, a ação principal não parece um botão¶
O topo tem ícone, título e subtítulo, e à direita a ação da tela. Medido no dev-remote, essa ação é um glifo de 44×44 com background: rgba(0,0,0,0) e sem borda — o alvo de toque existe, a afordância não. O usuário vê um + solto no canto e precisa deduzir que ali se clica.
A mesma ação no desktop é um botão laranja de 136×32 com rótulo. Mesma função, duas linguagens visuais — e no mobile, nenhuma.
1.2 O topo não é o mesmo em toda tela¶
Das 22 páginas que usam PageHeader:
| Falta | Onde |
|---|---|
| Ícone | contracts/show |
| Subtítulo | os cinco formulários, renters/show, roles/show |
E três telas internas ficam fora do componente: credit-analyses/show e operations-center montam topo próprio, e o operations-center ainda tem um HeaderBar paralelo só dele.
1.3 A decisão de "como fica no mobile" está copiada nas páginas¶
Cinco páginas repetem o mesmo ternário:
const newButton = isMobile ? (
<IconButton sx={TOPBAR_ACTION_SX}><Icon name="plus" /></IconButton>
) : (
<Button variant="contained" startIcon={…}>Novo inquilino</Button>
);
Só três importam o TOPBAR_ACTION_SX; as demais mandam o botão com rótulo nos dois viewports, ocupando a barra de 390px. A escolha de como a ação se apresenta é do topo, não de cada página — enquanto for de cada página, a próxima tela decide de novo.
É o mesmo padrão das specs 0016 e 0018: peça compartilhada existe, mas parte da decisão ficou fora dela e derivou.
1.4 No desktop sobra espaço e falta contexto¶
A barra mede 58px no desktop e 64px no mobile, com o mesmo conteúdo. No desktop sobra largura — e ao mesmo tempo a tela de detalhe não diz de onde veio nem como voltar: em renters/show há o título "João Silva" e nada que ligue a "Inquilinos". Não existe breadcrumb em lugar nenhum do app.
Espaço sobrando e contexto faltando, ao mesmo tempo, é o sinal de que o espaço está sendo gasto com decoração em vez de informação.
2. Objetivo¶
Um topo só, com regra explícita: ícone, título, subtítulo e ação, iguais em toda tela interna. A ação é reconhecível como botão nos dois viewports, e o desktop usa o espaço que tem a mais para dizer onde o usuário está.
3. Fora de escopo¶
- Telas fora da casca do app —
login,register,onboarding,accept-invite,verify-email,resetPassword,forgotPassword,not-found,unauthorized. Têm layout próprio de acesso e não têm barra de navegação. - Redesenhar a gaveta ou a barra de abas do mobile. A spec 0016 fixou a barra em 64px e a 0011 a gaveta; aqui só o conteúdo do topo muda.
- Mudar a altura das barras. 64px no mobile e 58px no desktop continuam.
- Ações por linha de lista (ver/editar/excluir) e ações dentro de painel. A spec trata do topo da página.
- Rodapé fixo dos formulários. Continua sendo onde vivem Salvar/Cancelar; formulário não passa
actionsao topo, como oui-patterns.mdjá manda.
4. Regras de negócio¶
Estrutura¶
-
RN1 — Toda tela interna usa
PageHeader, com ícone e título obrigatórios. Sem exceção: as três telas que hoje montam topo próprio passam a usar o componente. -
RN2 — Subtítulo é obrigatório em lista e detalhe, opcional em formulário. Em lista ele diz o que a tela contém ("Pessoas físicas e jurídicas cadastradas"); em detalhe, identifica o registro. No formulário o título já basta — "Nova pessoa" não precisa de glosa.
-
RN2a — No detalhe, o subtítulo é
<o que é> · <como se identifica>, e a primeira metade nunca some. "Pessoa física · CPF 123…", "Residencial · São Paulo/SP", "Perfil do sistema · 12 permissões".
A regra nasceu de um defeito: a primeira versão desta spec pôs CEP … no imóvel e CNPJ … na imobiliária — só a segunda metade, e condicional. Num imóvel sem CEP o subtítulo desaparecia, e o tipo do imóvel (Residencial/Comercial), que é o que classifica o registro, não aparecia em lugar nenhum do topo. Campo opcional não pode ser o que dá identidade à tela.
- RN3 — O ícone é o mesmo do item da gaveta para aquele módulo. É o que faz o topo repetir o sinal de "você está aqui" em vez de inventar um segundo vocabulário. Já valia pelo
ui-patterns.md; passa a ser verificável.
A ação¶
-
RN4 — A ação principal é um botão preenchido nos dois viewports. No desktop, com rótulo; no mobile, 44×44, fundo sólido na cor da ação, raio de 6px — a mesma linguagem dos demais botões do app. Fundo transparente não é opção: alvo de toque sem afordância é o defeito da seção 1.1.
-
RN5 — Quem decide a forma é o
PageHeader, não a página. A página passa o que a ação faz (rótulo, ícone, handler, cor); o topo decide como ela se apresenta em cada viewport. O ternárioisMobile ? … : …sai das cinco páginas. -
RN6 — Toda ação em forma de ícone tem
aria-labelcom o mesmo texto do rótulo do desktop. Sem isso a versão mobile fica muda para leitor de tela — e é a versão em que o rótulo visível some. -
RN7 — No máximo duas ações no topo. A partir da terceira, as excedentes vão para um menu "mais". Numa barra de 390px que já carrega ícone, título e subtítulo, três alvos de 44px não cabem sem espremer o título.
O desktop¶
-
RN8 — Tela de detalhe e de formulário mostram trilha de navegação no desktop:
<módulo> › <registro>, com o módulo clicável de volta para a lista. É o que o espaço extra do desktop compra — informação, não respiro. -
RN9 — A trilha não aparece no mobile. Em 390px ela roubaria a largura do título, e a volta já existe na barra de abas e no gesto do sistema. O desktop ganha o que cabe no desktop; não é a mesma tela esticada.
-
RN10 — A trilha substitui o subtítulo no detalhe quando os dois diriam a mesma coisa. Duas linhas de contexto sobre um título de uma linha é moldura demais.
5. Impacto por app¶
apps/api¶
Sem impacto. Nenhum modelo, endpoint, schema ou migration.
apps/web¶
components/shared/PageHeader.tsx — concentra o que hoje está espalhado:
- Nova prop
primaryAction?: { label, icon, onClick, color?, testId? }, que o componente renderiza comoButtoncom rótulo no desktop eIconButtonpreenchido de 44×44 no mobile (RN4/RN5), comaria-labelderivado delabel(RN6). - Nova prop
breadcrumb?: { label, to }para a trilha do desktop (RN8/RN9). actionscontinua existindo para o que não é a ação principal, com o teto da RN7.
Páginas a ajustar:
| Página | O quê |
|---|---|
renters/index, properties/index, credit-analyses/index, agencies/index, renters/show | trocar o ternário por primaryAction (RN5) |
users/index, roles/index, settings/document-types | idem — hoje mandam o botão com rótulo também no mobile |
contracts/show | passar ícone (RN1) |
cinco formulários, renters/show, roles/show | subtítulo onde a RN2 exige |
credit-analyses/show, operations-center | adotar o PageHeader; o HeaderBar do Centro de Operações perde a parte de título e fica só com os filtros |
| detalhes e formulários | breadcrumb (RN8) |
data-testid: page-header-action no botão principal e page-header-breadcrumb na trilha. Os testids de ação já existentes nas páginas (renters-new-button e afins) são preservados — passam pelo testId da primaryAction, senão a suíte e2e quebra em massa.
Sider: sem impacto — nenhum módulo novo.
Responsividade & PWA: é o assunto da spec. Sem impacto no service worker além do rebuild.
apps/e2e¶
- Spec: cenário que percorre as telas internas e afere RN1 (ícone e título presentes), RN4 (a ação tem fundo sólido no mobile —
backgroundColornão transparente e ≥44px), RN6 (aria-labelpresente), RN7 (no máximo duas ações) e RN8/RN9 (trilha no desktop, ausente no mobile). - Seeds: nenhum. As telas existentes bastam; o detalhe usa um registro do seed atual.
6. Contrato de API¶
Sem alteração. Nenhum endpoint criado, removido ou modificado.
7. Critérios de aceite¶
- [x] CA1. Toda tela interna tem ícone e título no topo, incluindo
credit-analyses/show,operations-centerecontracts/show. - [x] CA2. Toda lista e todo detalhe têm subtítulo, e o do detalhe não depende de campo opcional — a classificação do registro está sempre lá (RN2a).
- [x] CA3. No mobile, a ação principal mede ≥44×44 e tem fundo sólido —
backgroundColorcomputado não é transparente. - [x] CA4. A mesma ação, no desktop, é um botão com rótulo visível.
- [x] CA5. Nenhuma página decide sozinha a forma da ação: a busca por
isMobile ? <IconButtonempages/não retorna ocorrência. - [x] CA6. Toda ação em ícone tem
aria-labelnão vazio. - [x] CA7. Nenhum topo tem mais de duas ações.
- [x] CA8. Telas de detalhe e formulário mostram a trilha no desktop, e o módulo leva de volta à lista; em 375px a trilha não é renderizada.
- [x] CA9. Nenhuma tela rola na horizontal em 375px.
- [x] CA10. Os
data-testidde ação existentes continuam respondendo — a suíte e2e atual passa sem alteração de seletor. - [x] CA11.
tsclimpo, 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—PageHeader:primaryActioncom as duas formas (RN4/RN5/RN6) - [x]
apps/web—PageHeader:breadcrumbdo desktop (RN8/RN9/RN10) - [x]
apps/web— migrar as oito páginas com ação, preservando os testids (CA10) - [x]
apps/web— ícone e subtítulo onde falta (RN1/RN2) - [x]
apps/web—credit-analyses/showeoperations-centeradotam o componente - [x]
apps/web— trilha nas telas de detalhe e formulário - [x]
docs/ui-patterns.md— a seção AppBar passa a descrever a regra completa - [x]
apps/e2e— spec do topo, nos dois viewports - [x]
make testetsc - [x]
/devremote-rebuild+ screenshots nos dois viewports, conferindoscrollWidth - clientWidth == 0
9. Decisões em aberto¶
Nenhuma. As três que abriam a spec foram resolvidas antes da escrita:
- Ação no mobile: botão preenchido na cor da ação, 44×44, raio 6px.
- Desktop: trilha de navegação nas telas internas, em vez de só uniformizar.
- Escopo: todas as telas internas, incluindo as três que hoje estão fora do
PageHeader.
10. Registro¶
O que mudou, medido¶
| Antes | Depois | |
|---|---|---|
| Ação principal no mobile | 44×44, rgba(0,0,0,0), sem borda | 44×44 preenchido, raio 6px |
| Ação principal no desktop | botão com rótulo | igual — mas agora vem do mesmo lugar |
Telas internas sem PageHeader | 3 | 0 |
Ternário isMobile ? IconButton : Button nas páginas | 5 | 0 |
| Trilha de navegação | não existia | detalhe e formulário, só no desktop |
O que divergiu do planejado¶
1. A spec previa primaryAction; faltava a segunda ação.
As telas de detalhe têm Editar e Excluir — as duas que a RN7 permite. Com só primaryAction tipada, a segunda continuaria como ReactNode cru na página, e no mobile um botão com rótulo ao lado do preenchido estoura os 390px. Entrou secondaryAction, com a mesma mecânica e variante contornada: duas ações preenchidas lado a lado disputam a mesma atenção e nenhuma vence. A borda é o que a mantém reconhecível como botão.
2. A trilha repetia o título — e a própria RN10 tinha previsto.
A primeira versão renderizava <módulo> › <título>, e o título aparecia de novo logo abaixo, em negrito. Duas linhas dizendo a mesma coisa num bloco de 48px. Passou a mostrar só o caminho de volta (‹ Inquilinos), que é a informação que faltava — o detalhe não dizia de onde veio.
Foi a validação visual que pegou; o teste passava nas duas versões, porque toContainText não distingue redundância.
3. credit-analyses/show adotou o topo, mas manteve as ações onde estavam.
A tela tem mais de duas ações (aprovar, reprovar, pendenciar, cancelar, criar contrato) e já as mandava para a barra fixa do rodapé no mobile. Isso é a RN7 sendo cumprida antes de existir — o teto de duas no topo. O PageHeader levou título, subtítulo, ícone e trilha; as ações ficaram.
4. O HeaderBar do Centro de Operações tinha o defeito de cor da spec 0019.
Ao mover o título para o PageHeader, apareceu <Typography sx={{ color: "primary" }}> — string de paleta na propriedade CSS, que o MUI descarta. Mesmo defeito que a 0019 corrigiu nos chips. Sumiu junto com o bloco.
5. Um breadcrumb foi parar num FormPanel.
O script que inseriu a trilha casava o primeiro title={ do arquivo, e em agencies/form.tsx esse primeiro era de um FormPanel. O tsc pegou na hora (Property 'breadcrumb' does not exist on type 'FormPanelProps') — é o tipo de erro que uma migração por regex produz e o compilador cobra barato.
6. Um teste da spec 0015 quebrou — e estava acoplado ao tipo do elemento.
credit-analyses-form-steps.spec.ts afirmava o título com page.locator('h5').first(). Era o título em página que aquela tela mantinha; a 0020 o levou para o PageHeader, que portala para a barra do app e usa subtitle1. Nenhum h5 sobrou, e o teste quebrou em três navegadores.
A correção foi trocar o seletor pelo testid do componente (page-header-title), não recriar o h5. Acoplar teste a tipo de elemento é o que faz uma mudança de layout quebrar um teste de conteúdo — a asserção ali é sobre o texto, e agora ela sobrevive à próxima mudança de topo.
Vale como confirmação da CA10: os oito data-testid de ação atravessaram a migração intactos, que era o risco que a spec nomeou. Quem quebrou foi o único seletor que não era um testid.
Adendo — sete telas ficaram de fora, e o erro foi de método¶
A validação do usuário achou o formulário de Análise de Crédito sem o topo novo. Auditando por rota em vez de por nome de arquivo, não era uma tela: eram sete.
| Rota | Arquivo | Por que escapou |
|---|---|---|
/credit-analyses/create e /:id/edit | CreditAnalysisForm.tsx | o nome não é index/show/form.tsx |
/admin, /admin/tenants, /admin/tenants/:id, /admin/users | admin/pages/** | vivem fora de pages/ |
A CA1 era verificável e eu a verifiquei errado. O check globava find pages -name "index.tsx" -o -name "show.tsx" -o -name "form.tsx" — uma aproximação de "tela" que casa com a convenção e não com a realidade. Cobertura de tela se afere enumerando as rotas do App.tsx e seguindo o import até o arquivo; foi assim que as sete apareceram, e é assim que o e2e passou a aferir.
E a correção das telas de admin quebrou antes de consertar. O AdminLayout tem barra própria e não provê o portal do AppHeader. Como useAppHeader tem default nulo, o PageHeader renderizava nada, em silêncio — troquei os títulos em página dessas quatro telas por um componente invisível, e só a medição no dev-remote mostrou.
A causa raiz não era do admin: era do componente. Um PageHeader que some quando não acha portal é armadilha. Agora, sem portal, ele renderiza no lugar — funciona em qualquer casca, e a tela nunca fica sem título.
De quebra, as três telas de admin tinham <Chip sx={{ color: chip.color }} /> — o mesmo defeito de string-de-paleta-em-CSS que a spec 0019 corrigiu nos chips e que não alcançou admin/, pelo mesmo motivo: a varredura daquela spec também olhou só pages/.
Verificação¶
make test: 206 passaram — nenhum arquivo da API tocado.tsc: limpo nowebe noe2e.page-header.spec.ts: 10/10 em chromium, nos dois viewports — dois cenários novos cobrem exatamente o que escapou: as telas cujo arquivo não segue a convenção de nome, e o admin com sua casca própria.- Sem rolagem horizontal nas três telas medidas, em 390px e 1440px.
- Suíte e2e completa: 337 passaram, 6 pulados, 3 falharam na primeira execução — as três eram o mesmo cenário da spec 0015, nos três navegadores, pelo seletor acoplado descrito acima. Corrigido o seletor, o arquivo inteiro passa 22/22 nos três navegadores.