From f0dff330bd1ca350842f7873d1e81397b7191dd9 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 19:06:18 -0300 Subject: [PATCH] =?UTF-8?q?feat(C102):=20sem=20hamburger/sheet=20de=20side?= =?UTF-8?q?bar=20no=20mobile=20staff=20=E2=80=94=20navega=C3=A7=C3=A3o=20p?= =?UTF-8?q?ela=20barra=20+=20Mais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/CHANGELOG-AGENTS.md | 2 + .../c102-mobile-sem-botao-sidebar-impl.md | 92 ++++++++++++++++ src/app/(campaign)/campanha/(app)/layout.tsx | 2 - src/app/(campaign)/layout.tsx | 6 ++ .../campaign/shell/CampaignBottomNav.tsx | 49 +++++---- .../campaign/shell/CampaignMobileTopBar.tsx | 12 ++- .../campaign/shell/CampaignSidebar.tsx | 6 ++ .../shell/MunicipalityNavSavedFilters.tsx | 100 +++++++++++++++--- tests/e2e/campaignBottomNav.e2e.spec.ts | 40 +++++++ tests/e2e/campaignSavedFilters.e2e.spec.ts | 47 ++++++++ tests/unit/campaignMobileTopBar.unit.spec.tsx | 26 +++++ 11 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 docs/plans/c102-mobile-sem-botao-sidebar-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index badd51db..e3573836 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md). +**Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no ``). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md). + **Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md). **Recently resolved (2026-08-09):** **B181** (Issue #480) — a `/campanha/agenda` não exibe mais o bloco "Nenhum compromisso nesta janela e neste filtro" + botão "Criar atividade" acima do calendário em janela vazia (só ocupava espaço, redundante com slot-click C91, botão "Nova atividade" dos filtros e FAB C94). Remoção cirúrgica em `ActivityAgenda.tsx` (bloco condicional + órfãos `emptyHref`/imports `Link` e `buildActivityCreateHref`); `buildActivityCreateHref` permanece com 2 consumidores ("Mais detalhes" no inline create, "Nova atividade" nos filtros); loading/erro e `noEventsText` do FullCalendar (vista "Lista") intactos. Teste unitário de regressão: janela vazia não renderiza aviso nem link de criação. Sem migration, access, Consent ou schema. Intenção [`agenda-remover-aviso-vazio.md`](plans/agenda-remover-aviso-vazio.md); impl [`agenda-remover-aviso-vazio-impl.md`](plans/agenda-remover-aviso-vazio-impl.md). diff --git a/docs/plans/c102-mobile-sem-botao-sidebar-impl.md b/docs/plans/c102-mobile-sem-botao-sidebar-impl.md new file mode 100644 index 00000000..a35ac55a --- /dev/null +++ b/docs/plans/c102-mobile-sem-botao-sidebar-impl.md @@ -0,0 +1,92 @@ +# 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 `/campanha` de 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` (único `SidebarTrigger` mobile), `CampaignSidebar.tsx` (o `` que vira Sheet no mobile), `CampaignBottomNav.tsx` (drawer "Mais"), `MunicipalityNavSavedFilters.tsx` (sub-lista B18). O `layout.tsx` não muda. + +## Abordagem recomendada + +```mermaid +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 `` 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 `` 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 `` para o layout raiz `(campaign)/layout.tsx` (irmão do `{children}`, direto no `` — 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`): no `data-mode="app"`, renderiza `SidebarTrigger` só quando `!isStaffCampaignRole(useCampaignPageChromeRole())` (leader mantém). Wizard/home-search: inalterado (já sem trigger). +- **`CampaignSidebar`** (`src/components/campaign/shell/CampaignSidebar.tsx`): early return `null` quando `isMobile && isStaffCampaignRole(user.role)` — o Sheet mobile não monta para staff; rail desktop intacto; leader intacto. +- **`MunicipalityNavSavedFilters`** (`src/components/campaign/shell/MunicipalityNavSavedFilters.tsx`): prop `variant: 'sidebar' | 'overflow'` (default `sidebar` — `CampaignSidebar` não muda chamada). Variante `overflow`: wrapper seção (label "Filtros salvos de Municípios", `aria-label`), linhas em markup de drawer (link + botão lixeira), `onNavigate` fecha 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 variante `overflow` entre os destinos e o `DrawerFooter`; container de links ganha `min-h-0 overflow-y-auto` (lista longa + filtros não pode clipar no max-h 85dvh). +- **`(campaign)/layout.tsx`**: `` move do `(app)/layout.tsx` para 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 + +1. **Shell (trigger + sheet)** — `CampaignMobileTopBar` + `CampaignSidebar`. Verificável: unit test da top bar (staff sem trigger, leader com trigger); manual no navegador mobile. +2. **Drawer "Mais" (B18)** — variante `overflow` de `MunicipalityNavSavedFilters` + `CampaignBottomNav`. Verificável: e2e mobile salva filtro → abre "Mais" → seção visível, navega, apaga, desfaz. +3. **Gates** — `pnpm gate:fast` (lint/typecheck/format/knip/cycles/unit+int), `pnpm test:e2e` (specs de shell), `pnpm build` local; `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 `Sidebar` da shadcn, `SidebarProvider`, `SidebarInset`, cookie `sidebar_state`, `CampaignSidebarViewportDefault` (continua inofensivo com o sheet desmontado). +- Não tocar `CampaignDesktopHeader` (trigger desktop) nem a AI sidebar (`CampaignAISidebarShell`). +- Não mudar `nav.ts` — `getCampaignOverflowNav` já cobre tudo do staff; só B18 faltava e entra pelo drawer. +- Não mexer no shortcut `cmd+b` (no staff mobile ele alterna `openMobile` sem 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` é `false` no primeiro render — o Sheet staff existe fechado por 1 frame até o `matchMedia` medir; 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 `` com o drawer aberto. +- **Drawer estourando 85dvh** com destinos + filtros: `overflow-y-auto` no container. +- **Regressão desktop/saved-filters e2e:** `campaignSavedFilters.e2e.spec.ts` roda 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 + +- [x] 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) +- [x] Invariantes AGENTS/engineering-standards (sem schema, sem access, sem Consent; papéis por contexto/predicado existente) +- [x] Testes previstos: unit `campaignMobileTopBar` (staff/leader) + e2e mobile (staff sem trigger/sheet, leader com sheet, B18 no drawer) — ver fases diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index c96b8fa6..d41582bb 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -23,7 +23,6 @@ import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/Camp import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext' import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast' import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar' -import { Toaster } from '@/components/ui/Toaster' import { TooltipProvider } from '@/components/ui/tooltip' import { isStaffCampaignRole } from '@/lib/campaignRoles' import { deviceLabelFromUserAgent } from '@/lib/deviceLabel' @@ -104,7 +103,6 @@ export default async function CampaignAppLayout({ children }: { children: React. ) : null} - {biometricEnrollment ? ( diff --git a/src/app/(campaign)/layout.tsx b/src/app/(campaign)/layout.tsx index cfb07574..ac68b173 100644 --- a/src/app/(campaign)/layout.tsx +++ b/src/app/(campaign)/layout.tsx @@ -4,6 +4,7 @@ import React from 'react' import { CampaignIosViewportHeal } from '@/components/campaign/shell/CampaignIosViewportHeal' import { RegisterServiceWorker } from '@/components/campaign/shell/RegisterServiceWorker' +import { Toaster } from '@/components/ui/Toaster' import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa' import '../(frontend)/styles.css' @@ -44,6 +45,11 @@ export default function CampanhaRootLayout({ children }: { children: React.React > {children} + {/* Outside the (app) shells on purpose: the AI panel's `contain:layout + paint` wrapper would trap the toaster's `position: fixed` under the + portal z-50 of sheets/drawers, making action toasts (e.g. undo) + unclickable while a modal is open (C102). */} + diff --git a/src/components/campaign/shell/CampaignBottomNav.tsx b/src/components/campaign/shell/CampaignBottomNav.tsx index bad5fac6..08afea2a 100644 --- a/src/components/campaign/shell/CampaignBottomNav.tsx +++ b/src/components/campaign/shell/CampaignBottomNav.tsx @@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation' import { useState, type FormEvent } from 'react' import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth' +import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters' import { getCampaignBottomNav, getCampaignOverflowNav, @@ -115,26 +116,34 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) => Destinos da campanha -
- {overflowNav.map((item) => { - const active = isCampaignNavActive(pathname, item.href) - return ( - setOverflowOpen(false)} - className={cn( - 'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none', - 'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring', - active && 'bg-muted text-primary', - )} - > -