docs(B38): registra sidebar recolhido em tablet + fill-in de limitar o mapa a Bahia

Registro de roadmap-item de dois pedidos de produto de 2026-07-26, commitado
como estava na arvore de trabalho do checkout principal:

- B38 sidebar recolhido em tablet (`collapsible="offcanvas"`, trigger em `md+`,
  default por faixa de viewport com o cookie `sidebar_state` vencendo apos o
  toggle) + entrada no grafo de dependencias.
- Fill-in: limitar pan/zoom do mapa da Bahia a `BAHIA_BOUNDS` (`maxBounds` com
  folga de 10%, `maxBoundsViscosity: 1` e `minZoom` calculado no
  `ResizeObserver` que ja existe), so em `BahiaMap.tsx`.
- Nota de dependencia soft do B18 para o B38: com o rail recolhido, o submenu
  de filtros salvos precisa tratar collapsed como "abrir rail".
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 18:05:51 -03:00
parent f63cd2cd50
commit a305188e69
4 changed files with 248 additions and 4 deletions

View file

@ -103,6 +103,7 @@ Componentes:
- Soft: **B16** (barra slim = destino natural do Salvar) e **B17** (vizinho na mesma barra) — não bloqueiam; sem eles, Salvar pousa ao lado de Limpar na fileira atual.
- Soft: precedente Visitados ([visitados-recentemente.md](visitados-recentemente.md)) — padrão storage/logout.
- Soft: **B38** ([sidebar-recolhido-tablet.md](sidebar-recolhido-tablet.md)) — com `collapsible="offcanvas"`, o submenu hover/expand sob Municípios só é usável com o rail expandido (ou no Sheet mobile); se B38 landar antes, o craft do B18 trata collapsed como “abrir rail”.
- Nenhuma dependência dura de outro plano. Reusa `municipalityUi.ts`, `CampaignSidebar`, `SidebarMenuSub*`, `CampaignListPendingBoundary`.
## Não escopo

View file

@ -0,0 +1,116 @@
# Limitar o mapa da Bahia a pan/zoom dentro do estado
Status: rascunho
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos)
Impeccable: B — encaixe em `BahiaMap`/`MunicipalityMapPanel` (mapa do Início staff); muda só comportamento de interação, sem layout novo
Appetite: ~0,25–0,5 dia eng; sem migration, sem UI nova — só opções de `L.map`
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` § Design Principles, princípio 2 "Clarity under pressure" (toda tela ganha o próprio lugar; evitar que um gesto acidental — arrastar ou dar zoom-out demais — jogue o usuário para fora da geografia que importa, no meio da pressão de campo). Tema `data-theme='campaign'` (irrelevante aqui — não há CSS/token novo).
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. Não há shape a fazer (não é UI nova) nem harden/optimize previsto (mudança pequena e isolada).
Brief compacto (B ambíguo — aqui pouco ambíguo, registrado por completude):
- **Persona / contexto:** Alex (coordenador) ou um assessor, olhando o mapa do Início ou o painel de comparação de candidatos, muitas vezes no celular, em campo.
- **Job principal:** ler a distribuição de um valor pela Bahia sem se perder da geografia que importa.
- **Estratégia de cor:** N/A — item não toca cor/paleta, só bounds/zoom.
- **Edit where you see:** N/A — não há dado a editar aqui, é uma restrição de navegação num mapa somente-leitura.
- **Anti-goals:** não adicionar texto instrutivo ("você não pode sair da Bahia") por cima do mapa — o próprio comportamento (bounce-back ao arrastar, botão de zoom-out desabilitado no limite) já comunica a trava; não redesenhar controles do Leaflet.
## Dados → decisão → apresentação
Dados: N/A — o item não adiciona, agrega nem reformata nenhuma métrica exibida no mapa; restringe apenas a navegação (pan/zoom) da câmera Leaflet sobre os mesmos dados que já são pintados hoje.
## Contexto
`BahiaMap` (`src/components/campaign/map/BahiaMap.tsx:247-259`) cria o mapa Leaflet com:
```ts
const map = L.map(container, {
zoomControl: true,
attributionControl: true,
scrollWheelZoom: false,
})
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
```
`BAHIA_BOUNDS` (linhas 32-35) já existe como o retângulo `[[-18.5, -46.8], [-8.5, -37.0]]` usado para o enquadramento inicial e para o fallback de `fitMapToHighlights` quando não há chaves destacadas. O tile layer é o OpenStreetMap global (linha 256). Hoje nada impede o usuário de:
- **Arrastar (pan)** o mapa para fora da Bahia — `dragging` está no valor padrão do Leaflet (`true`) e não há `maxBounds`.
- **Dar zoom-out** além do nível em que o estado cabe inteiro na tela — não há `minZoom`; o botão "−" do `zoomControl` e o pinch-zoom em touch continuam ativos até o mínimo absoluto do Leaflet.
`scrollWheelZoom` já está desabilitado (provavelmente para não capturar o scroll da página ao passar o mouse por cima do mapa) — isso é comportamento existente e não faz parte deste pedido.
Único consumidor hoje: `MunicipalityMapPanel.tsx` (`src/components/campaign/map/MunicipalityMapPanel.tsx:713-727`), que renderiza `<BahiaMap mode="municipality" ... />` no Início staff (`/campanha`) tanto no modo normal quanto no modo de comparação de candidatos (escala divergente). Um arrasto ou zoom-out acidental — fácil de fazer com pinch/scroll no celular em campo — leva a tiles vazios (oceano, outros estados) sem nenhum dado pintado, sem indicar como voltar além de dar zoom-in de novo. Pedido de produto (2026-07-26): "fixar o mapa que exibimos apenas a Bahia... para que o usuário não saia da zona de interesse por engano. Ele pode fazer zoom e pan, mas apenas dentro da região da Bahia" — ou seja, não é para desabilitar zoom/pan (já parcialmente restritos hoje via `scrollWheelZoom: false`), é para **limitar seu alcance** ao estado.
## Objetivos
- Arrastar o mapa não consegue sair dos limites geográficos da Bahia (com uma pequena folga de conforto, não a fronteira exata).
- Dar zoom-out não passa do nível em que o estado inteiro cabe na viewport — sem mostrar tiles fora da Bahia.
- Zoom-in continua livre até o `maxZoom: 18` do tile layer, como hoje.
- Dentro dos limites, pan/zoom continuam tão fluidos quanto hoje — a trava só age na borda.
- Comportamento idêntico nos dois `mode` (`municipality` e `territory`) e em qualquer consumidor futuro de `BahiaMap` — a mudança fica dentro do componente compartilhado, não em `MunicipalityMapPanel`.
- O limite de zoom se recalcula quando o contêiner muda de tamanho (mobile vs. desktop, painel normal vs. futuro dialog), usando o mesmo `ResizeObserver` que já existe para `invalidateSize()` — não duplicar observer.
- Sem migration, sem collection, sem server action, sem mudança nas props públicas de `BahiaMap`.
## Decisões travadas
- **Bounds da trava = a mesma constante `BAHIA_BOUNDS` já usada no `fitBounds` inicial**, sem segunda fonte de verdade geográfica. **Rejeitado:** calcular a bounding box a partir do TopoJSON dos 417 municípios (`bahiaGeometries.ts`) — mais "preciso" ao contorno real, mas exigiria esperar o módulo de geometria carregar (é `import()` dinâmico, ~132 KB) antes de a trava existir, e o retângulo já sobra confortavelmente para o único objetivo aqui (não deixar sair do estado por engano, não desenhar uma fronteira exata).
- **`maxBoundsViscosity: 1` (trava rígida), não elástica.** O pedido é explícito — "não sair... por engano" — e uma viscosidade parcial (ex. Leaflet default incremental) ainda deixa arrastar um pouco para fora antes de voltar, que é precisamente o engano que o pedido quer eliminar. **Rejeitado:** viscosidade padrão (`0`, sem trava nenhuma) e viscosidade intermediária (ex. `0.6`) — ambas permitem sair visivelmente da Bahia antes de qualquer resistência.
- **`minZoom` calculado em runtime via `map.getBoundsZoom(BAHIA_BOUNDS)`**, o mesmo cálculo que o `fitBounds` já faz internamente, em vez de uma constante fixa. O zoom em que o estado cabe inteiro depende do tamanho do contêiner (`heightClassName` varia por consumidor — hoje só `MunicipalityMapPanel`, mas o componente é compartilhado) e da largura da viewport (mobile vs. desktop). **Rejeitado:** constante única (ex. `minZoom: 6`) — erra para qualquer contêiner com aspect ratio diferente do testado.
- **i18n e naming**: sem strings novas (nenhuma UI textual nova); identificadores propostos em inglês (`applyBahiaMinZoom`, se extraído como função local) seguem o padrão já usado no arquivo (`fitMapToHighlights`, `resolvePathStyleForFeature`).
## Questões em aberto
- **A folga (`pad`) ao redor de `BAHIA_BOUNDS` para o `maxBounds` deve ser igual ao padding do `fitBounds` inicial ou maior?** Opções: (a) usar `BAHIA_BOUNDS` sem folga extra para `maxBounds`; (b) aplicar `L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` (10%) só para o `maxBounds`, mantendo `BAHIA_BOUNDS` original para o `fitBounds` e o cálculo de `minZoom`. **Recomendação:** (b) — sem folga, a trava "gruda" exatamente na fronteira do retângulo ao arrastar perto da borda, cortando ao meio o último município visível; uma folga pequena (10%) é suficiente para dar conforto tátil ao arrastar sem deixar o usuário perceber que "saiu" da Bahia _(assumido — validar visualmente no craft; se 10% parecer pouco/demais, ajustar o número, não a decisão)_.
## Abordagem proposta
```mermaid
flowchart LR
mount["useEffect de mount<br/>(container pronto)"] --> create["L.map(container, {<br/>...,<br/>maxBounds: BAHIA_BOUNDS.pad(0.1),<br/>maxBoundsViscosity: 1 })"]
create --> fit["map.fitBounds(BAHIA_BOUNDS)<br/>(já existe)"]
fit --> minzoom["applyBahiaMinZoom(map):<br/>map.setMinZoom(map.getBoundsZoom(BAHIA_BOUNDS))"]
resize["ResizeObserver<br/>(já existe p/ invalidateSize)"] --> minzoom
```
Componentes:
- **`BahiaMap.tsx`** (`src/components/campaign/map/BahiaMap.tsx`, único arquivo tocado):
- No `useEffect` de criação do mapa (linhas ~247-281), acrescentar `maxBounds` e `maxBoundsViscosity: 1` às opções de `L.map(...)`, calculando o bounds com folga a partir de `L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` (Questão em aberto acima).
- Extrair um pequeno helper local `applyBahiaMinZoom(map: L.Map)` que chama `map.setMinZoom(map.getBoundsZoom(BAHIA_BOUNDS))`, com guarda para o caso do contêiner ainda ter tamanho zero (`getBoundsZoom` pode retornar `Infinity`/`NaN` num container `0×0` — ex. aba oculta) — só aplicar quando `Number.isFinite(zoom)`. Chamar uma vez logo após o `fitBounds` inicial.
- Reusar o `resizeObserver` já existente (linhas ~262-269, hoje só chama `map.invalidateSize()`) para chamar `applyBahiaMinZoom(map)` de novo a cada resize — não criar um segundo observer.
- Nenhuma mudança nas props públicas do componente (`BahiaMapProps` inalterado) — `MunicipalityMapPanel.tsx` e qualquer futuro consumidor não precisam de alteração.
- **Sem migration, sem collection, sem server action.**
## Dependências
Nenhuma de outro plano. Reusa só a constante `BAHIA_BOUNDS` e o `ResizeObserver` já existentes em `BahiaMap.tsx`.
## Não escopo
- **Restringir o viewport ao escopo do assessor** (ex.: só ao portfólio de municípios administrados) em vez de à Bahia inteira — o pedido é sobre não sair do estado, não sobre reforçar RBAC territorial no mapa. `fitToKeys`/`interactiveKeys` (já existentes) fazem um recorte diferente — foco visual e hover, não bloqueio de pan. Um item futuro pode pedir isso explicitamente; não é este.
- **Mudar `scrollWheelZoom: false`** — comportamento pré-existente e não relacionado a bounds; fora deste pedido.
- **Overlay/instrução textual** explicando a trava — o comportamento nativo do Leaflet (bounce-back ao arrastar, botão "−" desabilitado no `minZoom`) já comunica o limite; revisitar só com evidência de confusão real de usuário.
- **Aplicar a mesma trava a outro mapa do produto** — hoje `BahiaMap` é o único componente de mapa Leaflet (usado só por `MunicipalityMapPanel`); a trava, por viver dentro do componente compartilhado, já vale de graça para qualquer consumidor futuro.
## Rabbit holes
- **Calcular bounds "reais" a partir do TopoJSON dos 417 municípios em vez do retângulo `BAHIA_BOUNDS`.** Se alguém "só for mais preciso": passa a depender do módulo de geometria assíncrono (`loadMunicipalityGeometryModule`, ~132 KB) só para travar o pan, atrasando quando a trava passa a existir e complicando o cálculo (bounds de um `MultiPolygon` não é um simples `LatLngBounds` de retângulo). **Mitigação:** manter `BAHIA_BOUNDS` retangular — já é o que o `fitBounds`/fallback existentes usam, e precisão de contorno não é o objetivo (é não deixar sair do estado por engano).
- **"Já que estou mexendo, também limito por escopo do assessor."** Explode em decidir se um assessor deveria sequer _ver_ municípios fora do portfólio dele no mapa (hoje pode, só não interage) e se a trava deveria variar por role — mudança de política de acesso, não de UX de mapa. **Mitigação:** este item trava só no nível estadual, igual para todos os papéis; ver "Não escopo".
## Adiado com gatilho
Nenhum neste item.
## Referências
- `docs/roadmap.md` (Fill-ins abertos)
- `src/components/campaign/map/BahiaMap.tsx` — único arquivo a alterar; `BAHIA_BOUNDS` (linha 32), criação do mapa (linhas 247-259), `resizeObserver` existente (linhas 262-269)
- `src/components/campaign/map/MunicipalityMapPanel.tsx` — único consumidor hoje; confirma que a mudança é transparente às props (`BahiaMapProps` inalterado)
- Leaflet API — opções `maxBounds`/`maxBoundsViscosity`/`minZoom` de `L.Map`, método `getBoundsZoom(bounds)` e `LatLngBounds.pad(bufferRatio)` (`@types/leaflet`)
- `PRODUCT.md` § Design Principles, princípio 2 — "Clarity under pressure"

View file

@ -0,0 +1,123 @@
# Sidebar recolhido em tablet (/campanha)
Status: rascunho
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B38)
Impeccable: B — encaixe no shell `CampaignSidebar` + layout `(app)`; sem rota nova
Appetite: ~0,5 dia eng; ligar collapsible shadcn + trigger em md+ + default por faixa de viewport; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (register `product` — Field Desk; clareza sob pressão / field + desk) / `DESIGN.md` (Rail Mist sidebar; hybrid mobile top bar Mandate Red vs rail desktop) · tema `data-theme='campaign'`.
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só chrome do shell; sem redesign de nav/itens).
Brief compacto:
- **Persona / contexto:** Assessor / CG em iPad ou laptop estreito (768–1023 px) varre listas densas; o rail de 16 rem rouba viewport da tabela. No desktop largo (≥1024) o rail aberto continua o padrão de mesa.
- **Job principal:** no tablet, ganhar largura de conteúdo por padrão e reabrir a navegação com um botão óbvio; no desktop, rail aberto por padrão com toggle opcional.
- **Estratégia de cor:** Restrained — reusa tokens `sidebar-*` e o `SidebarTrigger` já estilizado; sem segunda chrome bar inventada.
- **Edit where you see:** não — só chrome de navegação.
- **Anti-goals:** reinventar drawer/sidebar fora do primitivo shadcn; rail de ícones como obrigação (ver Decisões); mudar bottom nav mobile; persistência de preferência que lute com o default de produto por faixa.
## Dados → decisão → apresentação
Dados: N/A — chrome de navegação; nenhum KPI/série/mapa.
## Contexto
O shell de `/campanha` já monta o **Sidebar shadcn** completo:
- Layout (`src/app/(campaign)/campanha/(app)/layout.tsx`): `SidebarProvider` + `CampaignSidebar` + `SidebarInset`; `SidebarTrigger` só no header mobile (`md:hidden`).
- `CampaignSidebar` força `collapsible="none"` — em `md+` o rail fica **sempre expandido** (`w-(--sidebar-width)` = 16 rem), sem gap offcanvas/icon.
- Mobile (`useIsMobile`, breakpoint **768** em `src/hooks/use-mobile.ts`): o próprio `Sidebar` renderiza `Sheet` — comportamento que o produto quer **manter**.
Pedido de produto (2026-07-26): em **tablet** o sidebar deve nascer **recolhido**, com botão para expandir; mobile permanece recolhido (Sheet); **desktop aberto por padrão**, ainda podendo abrir/fechar. **Buscar no shadcn antes de inventar** — o kit local já expõe `collapsible: 'offcanvas' | 'icon' | 'none'`, `SidebarTrigger`, cookie `sidebar_state` e atalho ⌘/Ctrl+B.
Referência de bloco shadcn: **sidebar-07** (collapse + `SidebarTrigger` no inset) — padrão a espelhar no layout, não a copiar o sample de breadcrumb/nav.
Soft histórico: FD2 Fase 5 (“Sidebar layout-transition”) era motion cortável; este item é o **comportamento responsivo** pedido, não só transição CSS.
## Objetivos
- Em viewport **tablet** (`md` … abaixo de `lg`, i.e. 768–1023 px): sidebar desktop **colapsado por padrão**; `SidebarTrigger` visível para expandir/recolher.
- Em viewport **desktop** (`lg`+, ≥1024 px): sidebar **aberto por padrão**; toggle continua disponível.
- Em viewport **mobile** (&lt;768): Sheet + header vermelho + bottom nav **inalterados**.
- Reusar o primitivo em `src/components/ui/Sidebar.tsx` (`collapsible`, `SidebarProvider`/`SidebarTrigger`); sem segundo sistema de drawer.
- Guardrails: sem migration, sem collection, sem Consent, sem server action; `print:hidden` / unlock print do E16 preservados; leader/staff nav (`nav.ts`) intactos.
## Decisões travadas
- **Trilha B com ID B38 (não fill-in anônimo).** Chrome do shell da vertical inteira, pedido explícito de produto, paralelizável e cortável — merece nó no grafo. (2026-07-26, roadmap-item.) **Rejeitado:** só fill-in sem ID (some no grafo ao lado de B18); absorver em R6 (atrasa quick win de viewport); inventar componente fora do shadcn Sidebar.
- **`collapsible="offcanvas"` (não `"icon"`, não manter `"none"`).** Recolhido = gap `w-0` / rail fora da tela; conteúdo ganha a largura toda — o job do tablet nas listas densas. Expandir = `SidebarTrigger` (e ⌘B). **Rejeitado:** `"icon"` (rail 3 rem + tooltips; obriga adaptar logo/perfil/role badge e ainda come viewport — sidebar-07 é referência de *toggle*, não de modo); `"none"` (status quo); Sheet também em tablet (duplica o idioma mobile e perde o peer-gap do desktop).
- **Faixas:** mobile &lt;768 (Sheet, já); tablet 768–1023 (`md`…`lg-1`) default **collapsed**; desktop ≥1024 (`lg+`) default **expanded**. Alinha ao `md:` já usado no layout/bottom nav e ao `lg` Tailwind padrão. **Rejeitado:** tablet = tudo `md+` collapsed (mata o default aberto no desktop); breakpoint custom 900 px fora do design system; só CSS sem estado (não liga o trigger shadcn).
- **Default inicial: viewport quando não há cookie `sidebar_state`; cookie vence depois do primeiro toggle.** `SidebarProvider` já grava o cookie — não reinventar storage. No layout RSC: ler cookie se existir; senão `defaultOpen` derivado do User-Agent/viewport é frágil — preferir efeito client mínimo na 1ª montagem sem cookie (matchMedia `min-width: 1024px`) **ou** passar `defaultOpen` só do cookie e, sem cookie, `true` no SSR + sync tablet→`setOpen(false)` uma vez. **Rejeitado:** ignorar cookie sempre (apaga preferência); cookies separados por faixa (overkill); auto-collapse em todo `resize` (briga com o usuário).
- **`SidebarTrigger` visível em `md+`** (não só mobile). Colocar num header slim do `SidebarInset` (ou strip sticky) com `hidden md:flex` / espelho do mobile — o trigger mobile do top bar vermelho permanece `md:hidden`. **Rejeitado:** só atalho de teclado; trigger flutuante fora do inset; esconder trigger no desktop (pedido: manter abrir/fechar).
- **i18n e naming:** identificadores em inglês (`defaultOpen`, `collapsible="offcanvas"`); copy/`aria-label` do trigger já em pt-BR (“Abrir ou fechar menu da campanha”).
## Questões em aberto
- **Onde pousa o trigger em `md+`: header slim permanente no inset vs. só quando `state === 'collapsed'`?** **Opções:** A) slim bar sempre com trigger (como sidebar-07) | B) trigger só no estado collapsed (máxima densidade no desktop aberto). **Recomendação:** **A** — affordance descoberta e simétrica com o mobile; altura ~`min-h-11` touch; no desktop aberto o custo é uma fileira fina. _(assumido — validar no craft)_
- **Sem cookie: sync tablet com `useEffect`+`matchMedia` ou `defaultOpen={false}` global?** **Opções:** A) sync uma vez por faixa na 1ª hidratação | B) defaultOpen false para todos (desktop começa fechado — conflita com o pedido) | C) só cookie+defaultOpen true. **Recomendação:** **A**. _(assumido)_
## Abordagem proposta
```mermaid
flowchart TD
layout["(app)/layout.tsx<br/>SidebarProvider"] --> side["CampaignSidebar<br/>collapsible=offcanvas"]
layout --> inset["SidebarInset"]
inset --> mobileHdr["header md:hidden<br/>SidebarTrigger Sheet"]
inset --> deskHdr["header slim md+<br/>SidebarTrigger offcanvas"]
side --> mobile["isMobile: Sheet<br/>inalterado"]
side --> desk["md+: peer gap<br/>expanded|collapsed"]
provider["defaultOpen / cookie<br/>+ matchMedia lg"] --> layout
```
Componentes:
- **`CampaignSidebar`** (`src/components/campaign/shell/CampaignSidebar.tsx`): trocar `collapsible="none"` → `collapsible="offcanvas"`; manter classes de borda/`h-svh`/`print:hidden` compatíveis com o container do primitivo (revisar no craft — o ramo `offcanvas` usa o wrapper `peer`/`md:flex`, não o `div` simples do `"none"`).
- **`(app)/layout.tsx`**: (1) header slim `hidden md:flex` com `SidebarTrigger` (e branding mínimo se o critique pedir — senão só o botão); (2) `SidebarProvider` com `defaultOpen` alinhado ao cookie quando existir; (3) se necessário, ilha client mínima (`CampaignSidebarOpenDefault` ou props no provider) para aplicar default tablet collapsed / desktop open **uma vez** na ausência de cookie — **não** bifurcar o primitivo `Sidebar.tsx` sem necessidade.
- **`src/components/ui/Sidebar.tsx`**: depth check — **preferir zero patch**. Só tocar se o default responsivo exigir API (ex. exportar nome do cookie / ler estado). Não reinstalar o bloco inteiro do registry.
- **Smoke visual:** 360 (Sheet), 768/820 (collapsed + trigger), 1280 (open + toggle). Print do dossiê (E16) sem regressão de chrome.
- **Migration**: Sem migration, sem collection, sem server action.
Depth check: reusa `Sidebar`/`SidebarProvider`/`SidebarTrigger`/`useSidebar` já no produto; espelha sidebar-07 só no *padrão* de trigger no inset. Sem segundo drawer, sem fork do shadcn.
## Dependências
- Nenhuma dura de outro plano. Reusa shell e primitivo existentes.
- Soft: **B18** (submenu de filtros salvos sob Municípios) — hover/expand assume rail utilizável; com offcanvas collapsed o usuário precisa expandir antes do 2º nível. Documentar na implementação do B18 se este landar antes; não bloqueia B38.
- Soft: FD2 Fase 5 / R6 — motion de layout; fora do appetite deste item.
## Não escopo
- Redesenhar itens de nav, bottom nav, ou `nav.ts` / papéis.
- Modo `collapsible="icon"` / tooltips por ícone (sidebar-07 full).
- Preferências de usuário no Payload / settings de perfil.
- Mudar `MOBILE_BREAKPOINT` (768) ou unificar todos os `useIsMobile` do app.
- Submenu B18 / Visitados — planos próprios.
## Rabbit holes
- **Patch profundo em `Sidebar.tsx` “para tablet nativo”.** Explode diff com o registry e knip/a11y do primitivo. **Mitigação:** configurar via props/`defaultOpen`/layout; patch mínimo só com comentário se inevitável.
- **Auto-collapse em todo resize / orientação.** Briga com preferência e cookie. **Mitigação:** default só na 1ª montagem sem cookie.
- **Aproveitar para refatorar logo/perfil/secondary nav.** Fora do appetite. **Mitigação:** só o necessário para offcanvas não quebrar overflow (labels já truncam via `SidebarMenuButton`).
## Adiado com gatilho
- **`collapsible="icon"` como opção de densidade.** Revisitar se, após uso real em tablet, a mesa pedir nav por ícone sem abrir o rail (evidência ≥2 atores ou critique R6).
- **Persistir preferência por faixa (tablet vs desktop).** Revisitar se o cookie único gerar atrito medido (abre tablet “preso” aberto após uso no desktop).
## Referências
- `docs/roadmap.md` (Trilha B · B38; Janela 1)
- `src/components/ui/Sidebar.tsx` — `collapsible`, cookie, `SidebarTrigger`, ramo mobile Sheet
- `src/hooks/use-mobile.ts` — `MOBILE_BREAKPOINT = 768`
- `src/components/campaign/shell/CampaignSidebar.tsx` — hoje `collapsible="none"`
- `src/app/(campaign)/campanha/(app)/layout.tsx` — Provider + trigger só mobile
- shadcn block **sidebar-07** (registry) — padrão Trigger no inset
- `docs/plans/field-desk-ux-pos-critique.md` — Fase 5 motion (adjacente, não escopo)
- `docs/plans/filtros-salvos-municipios.md` — B18 soft no sidebar
- AGENTS.md — naming; shell campaign
- `PRODUCT.md` / `DESIGN.md` — Field Desk / Rail Mist / hybrid mobile bar

File diff suppressed because one or more lines are too long