No mobile (<768px) staff perde o botão de abrir sidebar na top bar (CampaignMobileTopBar role-gated via useCampaignPageChromeRole + isStaffCampaignRole; leader mantém) e o sheet de navegação deixa de montar (CampaignSidebar early-return quando isMobile && staff). Filtros salvos de Municípios (B18) migram para o drawer Mais (MunicipalityNavSavedFilters variant 'overflow', mesma store, apagar/desfazer/sucessão de foco; drawer ganha overflow-y-auto). Correção de bug pré-existente (D4): o Toaster vivia dentro do wrapper [contain:layout_paint] do painel da AI, que vira containing block para position:fixed e stacking context próprio — toasts com ação (ex. Desfazer) ficavam inalcançáveis sob os portais z-50 de drawer/sheet. Movido para o root layout (campaign). Testes: unit top bar (staff sem trigger / leader com trigger) + e2e (staff sem trigger/dialog, leader com sheet, B18 no drawer completo).
10 KiB
Impl: C102 — Mobile: sem botão/sheet de sidebar (navegação pela barra inferior + "Mais")
Status: aprovado Atualizado em: 2026-08-09 Issue: #498 Intenção: docs/plans/c102-mobile-sem-botao-sidebar.md Appetite restante: herdado (~0,5–1 dia eng; sem schema — só shell + drawer + testes)
Leitura da intenção
- Outcome: no mobile (<768px), nenhuma tela
/campanhade staff mostra o botão de abrir sidebar na barra superior e o sheet de navegação não é renderizado para staff; todos os destinos continuam alcançáveis via barra inferior + drawer "Mais"; filtros salvos de Municípios (B18) continuam acessíveis no mobile; desktop e leader inalterados. - O que NÃO negociar: leader (lockdown) mantém o sheet — é a única navegação dele no mobile; desktop (≥ md) inalterado — sidebar offcanvas B38 continua; sem regressão de wizard/busca/PWA.
- O que reavaliar: "áreas prováveis" da intenção apontam para 4 arquivos do shell — confirmei que são os donos certos:
CampaignMobileTopBar.tsx(únicoSidebarTriggermobile),CampaignSidebar.tsx(o<Sidebar>que vira Sheet no mobile),CampaignBottomNav.tsx(drawer "Mais"),MunicipalityNavSavedFilters.tsx(sub-lista B18). Olayout.tsxnão muda.
Abordagem recomendada
flowchart LR
A[CampaignMobileTopBar] -->|role via useCampaignPageChromeRole| B[isStaffCampaignRole?]
B -->|staff| C[sem SidebarTrigger]
B -->|leader| D[SidebarTrigger como hoje]
E[CampaignSidebar] -->|isMobile && staff| F[return null - sheet desmontado]
E -->|desktop / leader| G[sidebar/Sheet como hoje]
H[CampaignBottomNav] -->|variant overflow| I[MunicipalityNavSavedFilters no drawer Mais]
I -->|mesma store localStorage| J[links + apagar + desfazer]
Opções consideradas: ver decisões D1–D3.
Recomendação: gate de papel dentro dos componentes que já possuem o contexto (top bar) e o viewport (CampaignSidebar), e a sub-lista B18 migrada para o drawer "Mais" como segundo lar do mesmo componente (variante).
Rejeitadas: prop-drilling de user para a top bar (layout e N testes mudam sem necessidade); gating no layout.tsx (o breakpoint é cliente, exigiria wrapper novo); modificar o primitivo Sidebar da shadcn para "no mobile sheet" (polui o genérico para um consumidor); deixar B18 desktop-only (deixa um recurso entregue inalcançável no mobile — a intenção recomenda migrar).
Decisões de engenharia
D1 — onde esconder o SidebarTrigger no mobile.
Opções: A) na CampaignMobileTopBar, lendo useCampaignPageChromeRole() (já envolve o componente no layout) + isStaffCampaignRole | B) prop user do layout | C) useSidebar().isMobile dentro da top bar.
Recomendação: A — o provider CampaignPageChromeProvider role={user.role} já existe no layout e a top bar já consome contextos dele; nenhuma mudança de contrato, testes unitários atuais (renderTopBar com role coordinator) continuam válidos. Alternativas rejeitadas: B porque propaga dependência de layout e quebra os unit tests de render sem props; C porque useSidebar exige SidebarProvider e duplica um sinal que o papel já carrega.
D2 — como desmontar o sheet para staff no mobile.
Opções: A) early return null em CampaignSidebar quando isMobile && isStaffCampaignRole(user.role) (o useSidebar() já fornece isMobile, e o <Sidebar> da shadcn renderiza o Sheet só if (isMobile)) | B) gating no layout por user-agent/viewport | C) prop no primitivo Sidebar.
Recomendação: A — dono do concern é o próprio CampaignSidebar; no desktop (≥768px) o rail continua exatamente como hoje; no mobile staff o Sheet nunca monta. Rejeitadas: B porque viewport é sinal de cliente e user-agent é frágil (breakpoint matchMedia é a fonte canônica no repo); C porque altera um primitivo de UI genérico para um caso.
D3 — B18 (filtros salvos) no mobile staff.
Opções: A) MunicipalityNavSavedFilters ganha variant: 'sidebar' | 'overflow' e o CampaignBottomNav renderiza a variante no drawer "Mais" (mesma store useMunicipalitySavedFilters, mesma lógica de apagar/desfazer/foco) | B) componente irmão novo dentro de CampaignBottomNav.tsx com a lógica duplicada | C) desktop-only + débito.
Recomendação: A — um componente, um conhecimento (linha = link + apagar + undo + sucessão de foco), dois lares; a store foi desenhada para "a barra de filtro escreve e a navegação lê" (subscribe, B18) e o drawer vira leitor extra sem nenhuma mudança de dados. Rejeitadas: B porque duplica comportamento que a auditoria do repo consolida (não "twin"); C porque a intenção recomenda A e registra B apenas como fallback.
D4 — toast de "Desfazer" clicável com o drawer aberto (bug pré-existente exposto pelo C102).
O e2e revelou: o <Toaster> vivia dentro de CampaignAISidebarShell → div [contain:layout_paint] (painel da AI). contain: paint vira containing block para position: fixed E cria stacking context próprio — o toast (z-999999999 do sonner) ficava preso ABAIXO do portal z-50 do drawer/sheet, e o clique no "Desfazer" era interceptado pelo viewport do drawer.
Opções: A) mover <Toaster position="top-center" /> para o layout raiz (campaign)/layout.tsx (irmão do {children}, direto no <body> — nenhum ancestor contain) | B) zIndex prop no sonner (inútil: o z-index fica preso no stacking context do contain) | C) fechar o drawer ao apagar (muda UX de snackbar).
Recomendação: A — correção no dono do problema (placement do toaster); o toast passa a flutuar acima de sheets/drawers em todos os fluxos /campanha, não só neste. Rejeitadas: B porque não resolve; C porque altera o comportamento de deleção. (Pré-existente: qualquer toast com ação era inalcançável com modal aberto — o C102 só expôs porque o drawer agora é lar de uma deleção com undo.)
Componentes / mudanças
CampaignMobileTopBar(src/components/campaign/shell/CampaignMobileTopBar.tsx): nodata-mode="app", renderizaSidebarTriggersó quando!isStaffCampaignRole(useCampaignPageChromeRole())(leader mantém). Wizard/home-search: inalterado (já sem trigger).CampaignSidebar(src/components/campaign/shell/CampaignSidebar.tsx): early returnnullquandoisMobile && isStaffCampaignRole(user.role)— o Sheet mobile não monta para staff; rail desktop intacto; leader intacto.MunicipalityNavSavedFilters(src/components/campaign/shell/MunicipalityNavSavedFilters.tsx): propvariant: 'sidebar' | 'overflow'(defaultsidebar—CampaignSidebarnão muda chamada). Varianteoverflow: wrapper seção (label "Filtros salvos de Municípios",aria-label), linhas em markup de drawer (link + botão lixeira),onNavigatefecha o drawer, sucessor de foco resolve para linha vizinha → último link dos destinos / link "Perfil" do footer (calculado ANTES da remoção, mesmo padrão do sidebar).CampaignBottomNav(src/components/campaign/shell/CampaignBottomNav.tsx): renderiza a varianteoverflowentre os destinos e oDrawerFooter; container de links ganhamin-h-0 overflow-y-auto(lista longa + filtros não pode clipar no max-h 85dvh).(campaign)/layout.tsx:<Toaster position="top-center" />move do(app)/layout.tsxpara o root layout (D4).- Migration: sem migration (sem schema).
- Access / Consent: nenhum toque — papéis já chegam por
user.role/contexto; leader lockdown intacto. - UI: Impeccable B — encaixe no shell existente; sem redesign; reaproveita tokens e markup do drawer B164 e da sidebar B18.
Dados → forma (se aplicável)
- Não apresenta dados novos — é migração de navegação. Forma da sub-lista = a mesma da sidebar (linhas link + lixeira), re-estilizada com o markup do drawer.
Fases verificáveis
- Shell (trigger + sheet) —
CampaignMobileTopBar+CampaignSidebar. Verificável: unit test da top bar (staff sem trigger, leader com trigger); manual no navegador mobile. - Drawer "Mais" (B18) — variante
overflowdeMunicipalityNavSavedFilters+CampaignBottomNav. Verificável: e2e mobile salva filtro → abre "Mais" → seção visível, navega, apaga, desfaz. - Gates —
pnpm gate:fast(lint/typecheck/format/knip/cycles/unit+int),pnpm test:e2e(specs de shell),pnpm buildlocal;pnpm push→ PR Ready--base main+Closes #498→ auto-merge →gh pr checks --watch --required.
Rabbit holes / Não escopo (engenharia)
- Não mexer no
Sidebarda shadcn,SidebarProvider,SidebarInset, cookiesidebar_state,CampaignSidebarViewportDefault(continua inofensivo com o sheet desmontado). - Não tocar
CampaignDesktopHeader(trigger desktop) nem a AI sidebar (CampaignAISidebarShell). - Não mudar
nav.ts—getCampaignOverflowNavjá cobre tudo do staff; só B18 faltava e entra pelo drawer. - Não mexer no shortcut
cmd+b(no staff mobile ele alternaopenMobilesem Sheet para mostrar — no-op inofensivo). - Não redesenhar barra inferior/drawer (B164) nem remover sidebar desktop.
Riscos e mitigação
- Flash de hidratação:
useIsMobileéfalseno primeiro render — o Sheet staff existe fechado por 1 frame até omatchMediamedir; sem efeito visual (fechado, portal). Mitigação: nenhuma ação; o padrão B167 (useIsMobileMeasured) não agrega aqui porque a diferença é invisível. - Foco após apagar o último filtro no drawer: sucessor resolvido antes da remoção (linha vizinha → link "Perfil" do footer); nunca cai em
<body>com o drawer aberto. - Drawer estourando 85dvh com destinos + filtros:
overflow-y-autono container. - Regressão desktop/saved-filters e2e:
campaignSavedFilters.e2e.spec.tsroda no viewport desktop (1280) — sidebar continua com a sub-lista; nenhuma asserção muda. - Coordenação C101: mesma top bar mobile; C102 libera o slot do hamburger; sem ordem dura — cada item entrega sozinho.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto (staff mobile sem botão/sheet; destinos 100% via barra+Mais; B18 no drawer; leader e desktop intactos)
- Invariantes AGENTS/engineering-standards (sem schema, sem access, sem Consent; papéis por contexto/predicado existente)
- Testes previstos: unit
campaignMobileTopBar(staff/leader) + e2e mobile (staff sem trigger/sheet, leader com sheet, B18 no drawer) — ver fases