Pular para conteúdo

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 actions ao topo, como o ui-patterns.md já 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ário isMobile ? … : … sai das cinco páginas.

  • RN6 — Toda ação em forma de ícone tem aria-label com 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 como Button com rótulo no desktop e IconButton preenchido de 44×44 no mobile (RN4/RN5), com aria-label derivado de label (RN6).
  • Nova prop breadcrumb?: { label, to } para a trilha do desktop (RN8/RN9).
  • actions continua 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 — backgroundColor não transparente e ≥44px), RN6 (aria-label presente), 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-center e contracts/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 — backgroundColor computado 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 ? <IconButton em pages/ não retorna ocorrência.
  • [x] CA6. Toda ação em ícone tem aria-label nã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-testid de ação existentes continuam respondendo — a suíte e2e atual passa sem alteração de seletor.
  • [x] CA11. tsc limpo, 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: primaryAction com as duas formas (RN4/RN5/RN6)
  • [x] apps/web — PageHeader: breadcrumb do 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/show e operations-center adotam 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 test e tsc
  • [x] /devremote-rebuild + screenshots nos dois viewports, conferindo scrollWidth - 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 no web e no e2e.
  • 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.