Merge branch 'cursor/617e8b7f'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 18:40:58 -03:00
commit 0942686433
8 changed files with 68 additions and 19 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B38 entregue em código (2026-07-26):** sidebar recolhido em tablet no shell de `/campanha`. `CampaignSidebar` deixou `collapsible="none"` e passou a `collapsible="offcanvas"` (gap `w-0` — listas densas ganham largura no iPad). Layout `(app)` lê cookie `sidebar_state` no SSR para `defaultOpen`; `CampaignSidebarViewportDefault` colapsa **uma vez** em viewport 768–1023 px quando não há cookie (`matchMedia` `min-width: 1024px`); após o primeiro toggle o cookie vence. `SidebarTrigger` no header vermelho mobile (`md:hidden`) inalterado; nova fileira slim `hidden md:flex` no inset com o mesmo label pt-BR. Mobile continua Sheet. Sem migration. Gate: tsc/lint/format/knip/check:cycles/522 unit verdes; int 399/400 (1 timeout pré-existente em `municipalityPageData`); build não rodou neste worktree sem `.env.local`. Plano: [`sidebar-recolhido-tablet.md`](../../../docs/plans/sidebar-recolhido-tablet.md). Soft **B18:** com rail collapsed o submenu de filtros salvos exigirá expandir antes do 2º nível.
- **B24 entregue em código (2026-07-26):** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios`. Tirou o botão "Salvar" (anti-goal do princípio 4) e o `trendNote` hidden; `MunicipalityListTrendControl` agora espelha a máquina de `MunicipalityListExpectedVotesControl` — draft/display/committed + AbortController + geração, status ~150 ms / nota 600 ms (timer cancelável), flush no blur e no fechamento do Popover, Badge otimista, `Textarea` "Justificativa" (`maxLength` 2000), `Alert` + live region `sr-only`, tooltip B23 lendo a nota exibida. Endpoint `POST /campanha/municipios/political-trend` (mesmo shell CSRF/JSON do B27: `isSameOriginRequest` + `campaignJsonMutationRoute`) chama `setMunicipalityPoliticalTrend` **sem** `revalidatePath` — faceta/`?trend=` reconciliam na próxima navegação. Lista/page deixam de passar `trendFormAction`; `/editar` mantém o form action multi-campo. Sem migration. Gate verde (tsc/lint/format/knip/check:cycles/479 unit/393 int/build); e2e B24 + prewarm da rota; Aikido 0 findings. Plano: [`autosave-tendencia-lista-municipios.md`](../../../docs/plans/autosave-tendencia-lista-municipios.md).
- **B14 entregue em código (2026-07-26):** card "Onde estou" no Início staff. **A auditoria do plano derrubou sua decisão central antes de codar:** o plano de 24/07 tratava point-in-polygon como rabbit hole ("explode bundle") e escolhia um artefato commitado de centroides — mas `MunicipalityMapPanel` monta sempre em `mode="municipality"` e `BahiaMap` chama `loadMunicipalityGeometryModule()` no mount, então os 417 polígonos (132 KB crus, **41 KB gzip**, chunk próprio memoizado) já estão no browser de quem abre `/campanha`. Decidido com o usuário: usar polígonos. Saíram de escopo `pnpm build:municipality-centroids`, `src/lib/municipalityCentroids/` e o snapshot test — e o card passou a responder "o município **onde estou**" em vez de "o mais próximo". Centroide sobrevive só para ordenar o fallback.

View file

@ -1,12 +1,14 @@
# Sidebar recolhido em tablet (/campanha)
Status: rascunho
Status: entregue
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: —
**Revisão na entrega (2026-07-26):** `CampaignSidebar` passou a `collapsible="offcanvas"`; layout RSC lê cookie `sidebar_state` em `defaultOpen`; ilha `CampaignSidebarViewportDefault` aplica colapso único em viewport <1024 px quando não há cookie; header slim `md+` com `SidebarTrigger`. Questões em aberto fechadas: trigger sempre visível no inset (opção A); sync tablet via `matchMedia` one-shot (opção A).
## 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'`.
@ -108,6 +110,7 @@ Depth check: reusa `Sidebar`/`SidebarProvider`/`SidebarTrigger`/`useSidebar` já
- **`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).
- **Flash breve sem cookie em tablet (SSR `defaultOpen` aberto → ilha `matchMedia` colapsa).** Revisitar só se campo reportar incômodo; mitigação aceita na entrega: uma frame antes da hidratação.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,17 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import React from 'react'
import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav'
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/Sidebar'
import {
SIDEBAR_COOKIE_NAME,
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/Sidebar'
import { Toaster } from '@/components/ui/Toaster'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
@ -16,17 +23,23 @@ export default async function CampaignAppLayout({ children }: { children: React.
redirect('/campanha/login')
}
const cookieStore = await cookies()
const sidebarStateCookie = cookieStore.get(SIDEBAR_COOKIE_NAME)
const hasSidebarCookie = sidebarStateCookie !== undefined
const defaultOpen = sidebarStateCookie ? sidebarStateCookie.value === 'true' : true
return (
// print: unlock the h-svh/overflow-hidden shells and drop the app chrome,
// otherwise only the first page of the municipality dossier prints (E16).
<SidebarProvider className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<SidebarProvider
defaultOpen={defaultOpen}
className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible"
>
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden">
<SidebarTrigger
label="Abrir ou fechar menu da campanha"
className="text-primary-foreground"
/>
<SidebarTrigger className="text-primary-foreground" />
<div className="min-w-0 leading-tight">
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
<span className="block truncate text-xs text-primary-foreground/80">
@ -34,6 +47,9 @@ export default async function CampaignAppLayout({ children }: { children: React.
</span>
</div>
</header>
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
</header>
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0"

View file

@ -75,10 +75,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
}
return (
<Sidebar
collapsible="none"
className="h-svh shrink-0 border-r border-sidebar-border print:hidden"
>
<Sidebar collapsible="offcanvas" className="print:hidden">
<SidebarHeader className="border-b border-sidebar-border">
<Link
href="/campanha"

View file

@ -0,0 +1,27 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSidebar } from '@/components/ui/Sidebar'
const DESKTOP_MIN_WIDTH_QUERY = '(min-width: 1024px)'
/** One-shot tablet default when no persisted sidebar_state cookie. */
export const CampaignSidebarViewportDefault = ({
hasSidebarCookie,
}: {
hasSidebarCookie: boolean
}) => {
const { setOpen } = useSidebar()
const appliedRef = useRef(false)
useEffect(() => {
if (hasSidebarCookie || appliedRef.current) return
appliedRef.current = true
if (!window.matchMedia(DESKTOP_MIN_WIDTH_QUERY).matches) {
setOpen(false)
}
}, [hasSidebarCookie, setOpen])
return null
}

View file

@ -17,7 +17,7 @@ import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { PanelLeftIcon } from 'lucide-react'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
export const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'

View file

@ -115,7 +115,11 @@ describe('campaign visual foundation', () => {
)
expect(layoutSource).toContain('data-slot="campaign-content-scroll"')
expect(layoutSource).toContain('min-h-0 flex-1 overflow-y-auto overscroll-contain')
expect(sidebarSource).toContain('h-svh shrink-0')
expect(layoutSource).toContain(
'hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden',
)
expect(sidebarSource).toContain('collapsible="offcanvas"')
expect(sidebarSource).toContain('print:hidden')
})
it('uses light sidebar tokens and readable logout states', () => {