--- description: Changelog / notebook da vertical /campanha (Núcleos → Praças → Municípios) — histórico de entregas, NÃO restrições de engenharia alwaysApply: false --- # Núcleos Eleitorais → Municípios **Como ler este notebook:** as entradas "**entregue em código**", decisões de sessão e micro-escolhas de implementação abaixo são **changelog** (o que aconteceu e onde está o plano). Não são locks. Não impeçam editar o código que uma entrega anterior criou; não obriguem a espelhar um twin em vez de mudar o dono. Constraints reais de produto/segurança vivem em `AGENTS.md`, `docs/ARCHITECTURE.md` e `.agents/rules/engineering-standards.mdc`. Para o estado vivo da fila, use Issues + `project-status` — não trate este arquivo como fonte canônica de "o que ainda pode mudar". ## Status - **B53 entregue em código (2026-07-30):** sexto grupo da busca global do Início — **Demandas** via `searchHomeDemands` + `HomeSearchDemandGroup` (após Dobradinhas); `POST /campanha/home-search` agrega `demands`; match word-start no `title`; secundário `"{município} · {status}"`; staff access com `overrideAccess: false`; cap 25; int `homeSearchDemands.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-demandas.md`](../../../docs/plans/busca-global-resultados-demandas.md). Próximo na cadeia busca: **B54** grid/cap. - **B70 entregue em código (2026-07-30):** wizard atualizar liderança (A4 standalone) — `/campanha/acoes/atualizar-lideranca?municipio=` com grid 2/3/4 (`WizardLeadershipStep`), form curto de 6 campos (`WizardLeadershipForm`), skip via `trailingAction` quando `entryAction !== 'update-leadership'`, Continuar após Salvar dirty → Início + toast; `loadWizardLeadershipTiles`; writes `updateLeadershipWizard`/`createLeadershipWizard` (Contact+Leadership em transação); `WIZARD_ENTRY_ACTION_QUERY_KEY` em `campaignActionRoutes.ts`; `truncateNameAtWordBoundary`. Sem migration. Plano: [`wizard-atualizar-lideranca.md`](../../../docs/plans/wizard-atualizar-lideranca.md). - **B63 entregue em código (2026-07-30):** wizard registrar sinal (UX-1) — duas etapas no chassis B59: `WizardSignalTypeStep` (grid 2×3 de tiles transparentes + borda, ícone Lucide, Drawer bottom com `infoContent` provisório, `contentAlign="end"` no mobile) e `WizardSignalBodyStep` (textarea + Salvar explícito via `createMunicipalityListSignalFormAction` → `createMunicipalityUpdate` `kind: 'sinal'`). Contrato URL: `?municipio=` (B60) → `?signalType=` (tipo) → body; `?entry=` opcional — skip "Pular registro de sinal" visível só quando embutido (`entry` presente e ≠ `register-signal`), href skip → `/campanha` na v1. Helpers: `wizardSignalHref`, `resolveWizardSignalTypeParam`, `parseWizardEntryActionParam` em `campaignActionRoutes.ts`; `municipalitySignalTypeMeta.ts`; `wizardSignalUi.ts`. B63+ alinha `flowTitle`/`skip` ao chrome B75. Sem migration/Consent. Unit `campaignActionRoutes` + `wizardSignalUi`; e2e standalone + info não avança + skip embutido. Plano: [`wizard-registro-sinal.md`](../../../docs/plans/wizard-registro-sinal.md). Desbloqueia prefill de **B64** (soft). - **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `
` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único. - **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`. - **B77 entregue em código (2026-07-30):** wizard ajuste de votos numa tela — `WizardExpectedVotesStep` substitui o ritual `?cenario=` de B61/B76 por editor único (strip + 3 inputs compact + atalhos no cenário focado); coerência `pessimistic ≤ central ≤ optimistic` só no confirm (`getWizardVoteViolation` + `getWizardVoteViolationHighlights`); `?cenario=` legado → redirect canônico; órfãos `wizardVoteStepTitle`/`mergeWizardVoteEstimate`/etc. removidos. Unit `wizardExpectedVotesStep`/`wizardVoteEstimate`; e2e B61 atualizado. Sem migration. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md). Desbloqueia wiring A1 com menos atrito antes de **B63**/**B64**. - **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md). - **B66 entregue em código (2026-07-30):** modo focado da busca no **focus** (emenda B47) — `homeSearchUiFocused` (`inputFocused || query.isActive`) já no contrato/hook; overlap B68 (sugestões no focus vazio). **Nesta entrega:** `CampaignHomeLayout` retrai summary+spacer (`data-slot="home-chrome"`) e actions com grid `0fr/1fr` + opacity 220ms (`motion-reduce:transition-none`) em vez de `hidden`; `md:flex-none` no chrome; `HomeSearchHitRow` `onMouseDown` preventDefault (blur→click); e2e focus sem digitar + regressão com digitação. Sem migration. Plano: [`modo-focado-busca-no-focus.md`](../../../docs/plans/modo-focado-busca-no-focus.md). - **B61 entregue em código (2026-07-29):** etapa de ajuste de votos no wizard A1 — `WizardExpectedVotesStep` substitui o stub B60 em `atualizar-votos` com `?municipio=`; três passos explícitos média→pessimista→otimista via `?cenario=`; atalhos `2×`/±50/±100; validação client com jump+warning (`getWizardVoteViolation`); **um POST batch** ao concluir otimista (`POST /campanha/municipios/expected-votes`). Helpers em `wizardVoteEstimate.ts` + `wizardActionHref` estendido. Sem migration/Consent. Unit `wizardVoteEstimate`; e2e avanço de cenário + violação. Plano: [`ajuste-votos-wizard.md`](../../../docs/plans/ajuste-votos-wizard.md). Desbloqueia wiring A1 / **B64** (prefill votos). - **B69 entregue em código (2026-07-30):** modelo de liderança simplificado — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo"; migration `20260730_043306_simplify_leadership_fields`, gerada em duas passadas não-interativas e fundida à mão porque o prompt de rename do gerador trava sem TTY; `NOT NULL` emendado) + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, lista com filtros/sort B29, invite autofill/redemption, dossiê). **Decisão de produto 2026-07-30 que reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (várias lideranças na mesma org, ex. SindMed); vertical `/campanha/organizacoes`, join, badges, contadores e `activity.organizations` intactos (a remoção da vertical inteira foi avaliada e descartada na conversa). Lista ganha coluna "Apoio exclusivo" com badge outline "Não exclusivo" só quando false; forms com checkbox default-on + input hidden "false" na criação (último valor vence; ausência total da chave cai no default do zod — pin de int cobre os três caminhos). Coluna Organizações continua texto simples (melhoria = fill-in futuro). `?sector=` legado canonicaliza fora (pin unit). LGPD do convite (`consent`/`consentContentHash`/`consentedAt`) intacto. Gate: tsc/lint/format/cycles verde; knip idêntico ao clean tree (P3 + drift B66/B71 pré-existentes); unit 890+16 falhas pré-existentes idênticas ao clean tree; int 518/518; build; Aikido 0 findings. Plano: [`simplificar-modelo-lideranca.md`](../../../docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70** (wizard atualizar liderança — grid + form curto com exclusivo). - **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal). - **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md). - **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**. - **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Atividades); `POST /campanha/home-search` agrega `stateDeputies`; match word-start em nome ou partido; secundário `partido · N municípios`; assessor vê hits (staff read); `municipalityIdsByStateDeputyIds` em `stateDeputyData`. Int `homeSearchStateDeputies.int.spec.ts`. WA = **B55** (sem telefone na entidade). Plano: [`busca-global-resultados-dobradinhas.md`](../../../docs/plans/busca-global-resultados-dobradinhas.md). Próximo na cadeia busca: **B53** demandas. - **B72 entregue em código (2026-07-29):** gap horizontal da strip de ações do Início (UX-1) — `CampaignHomeActionStrip` `
    ` `gap-6` → `gap-4` (16px); `w-[4.75rem]`, círculo 48px, `snap-x`, scrollbar oculta e pan B67 ✓ inalterados. Sem migration/action/URL. Unit strip 11/11 em `campaignHomeActionButton.unit.spec.tsx`. Plano: [`espacamento-strip-acoes-inicio.md`](../../../docs/plans/espacamento-strip-acoes-inicio.md). - **B65 entregue em código (2026-07-29):** âncora inferior mobile no Início — cadeia `h-full min-h-0` em `CampaignPageShell` + `CampaignHomeStaffChrome` + `CampaignHomeLayout`; wrapper `data-slot="home-dock"` agrupa strip B45 + busca B47; spacer `flex-1` empurra o dock acima da bottom nav (`pb-24` intacto); `md+` inalterado; modo focado B47/B66 intacto; sem `fixed`/`mt-auto`. Unit `campaignHomeLayout.unit.spec.tsx` (6 casos). Plano: [`ancorar-busca-acoes-inicio-mobile.md`](../../../docs/plans/ancorar-busca-acoes-inicio-mobile.md). Desbloqueia animação idle de **B66**. Débito adiado: ajuste fino `pb-24` vs altura real da bottom nav se gap >~8 px em device real. - **B59 entregue em código (2026-07-29):** chassis UX-1 dos wizards de ação — `CampaignWizardShell` (`shared/`: Voltar só mobile com rótulo visível, caption sticky central de município, `contentAlign`, foco no `h1` ao trocar passo); rotas staff `/campanha/acoes/` (cinco entradas espelhando o catálogo B45, passo ponte "Em qual município?" até **B60**); `campaignActionRoutes.ts` + hrefs em `toHomeActionButtonProps`; layout `acoes/` com gate `staff`. Sem migration/action/Consent. Unit `campaignActionRoutes`, `campaignWizardShell`, `campaignHomeActions` atualizado; e2e Início → `atualizar-votos`. Plano: [`chassis-wizard-campanha.md`](../../../docs/plans/chassis-wizard-campanha.md). Desbloqueia **B60** / **B61** / **B63** / **B64**. - **B56 entregue em código (2026-07-29):** resumo da campanha no Início staff — `loadCampaignHomeSummary` + `CampaignHomeSummary` (total central `staffVoteTotal` + cobertura E8 com `Progress`); `summarySlot` em `CampaignHomeLayout`/`CampaignHomeStaffChrome`, oculto no modo focado B47; sem saudação; leader fora. Loader em `campaignDashboardData.ts` (não módulo top-level novo). Unit `campaignHomeSummary.unit.spec.tsx`. Sem migration. Plano: [`resumo-campanha-inicio.md`](../../../docs/plans/resumo-campanha-inicio.md). Desbloqueia slot visual de **B57** (delta 7d, futuro). - **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44. - **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). - **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md). - **B49 entregue em código (2026-07-29):** segundo grupo da busca global do Início — **Lideranças** via `searchHomeLeaderships` + `HomeSearchLeadershipGroup` (entre Municípios e Assessores); payload `leaderships` no mesmo `POST /campanha/home-search`; empty state considera todos os grupos (`homeSearchHasAnyHits`); linha → `/campanha/liderancas/[id]`; `HomeSearchHitRow` compartilhado. Plano: [`busca-global-resultados-liderancas.md`](../../../docs/plans/busca-global-resultados-liderancas.md). - **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Lideranças; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md). - **B51 entregue em código (2026-07-29):** grupo **Atividades** na busca global do Início — `searchHomeActivities` (staff-only; word-start no `title`; access `activity` com `overrideAccess: false`; rascunhos incluídos; cap 25; desempate `startAt` desc); `HomeSearchActivityGroup` após Assessores; secundário `"{município} · {quando}"`; int `homeSearchActivities.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-atividades.md`](../../../docs/plans/busca-global-resultados-atividades.md). - **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md). - **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~ - **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` com slots `data-slot="home-actions"` / `home-search` (ordem mobile ações→busca; desktop busca→ações). Âncora inferior corrigida por **B65** ✓. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md). - **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md). - **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input ✓ → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 ✓ → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios. - **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). Montado no Início por **B45** ✓. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md). - **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**. - **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → ~~B46~~ ✓ → **B47–B55** busca global (wizards depois). - **E12+ entregue em código (2026-07-29):** escala/DRY pós-E12 — F1 forçada: `src/lib/metropolitanoTerritoryPeers.ts` unifica constantes, filtros Salvador vs demais RMS e `catalogPeersForSlug` (rollup E17/E12, `slugsForMetropolitanoSubRow`, benchmark T4). F2 não entrou — `benchmarkBySlug` já memoiza por slug. Spec `metropolitanoTerritoryPeers.unit.spec.ts`. Sem migration/UI. Plano: [`escala-dry-pos-e12.md`](../../../docs/plans/escala-dry-pos-e12.md). - **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md). - **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md). - **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para ``, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `
      ` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md). - **B14 débito B8 F2-D1 entregue em código (2026-07-29):** o card "Onde estou" resolve a ZE de Salvador por containment na malha `bahia-municipality-zones` (`loadMunicipalityZoneGeometryModule`, opcional — falha do chunk degrada para lista filtrada); fallback `nearestZone` (≤ 30 km) quando o ponto cai no município mas fora de todo polígono de zona. `resolveNearbyMunicipality` + `NearestMunicipalityCard`; int/e2e atualizados. Plano: [`municipio-mais-proximo.md`](../../../docs/plans/municipio-mais-proximo.md). filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para ``, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `
        ` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md). - **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md). - **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md). - **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md). - **Fill-in "Selecionados no topo nos filtros do header" entregue em código (2026-07-28):** opções já marcadas sobem ao topo nos Popovers multi-seleção do sistema de listas (municípios, lideranças, territórios, dobradinhas) e nos `CampaignMobileMultiFilterField`. Helper puro `orderFilterOptionsSelectedFirst` em `src/lib/listFilterOptions.ts`; snapshot da seleção na abertura do Popover (ordem congelada enquanto aberto; checkbox continua live); hairline entre grupos, oculto durante a busca (polish do critique). Gate multi = `optionRows.every(checkbox)` — single-select intocado. A premissa "não extrair head genérico" do plano caducou: B21/B29 já tinham pago `CampaignHeaderFilterPopover` compartilhado, então o item pousou em 2 arquivos e 4 listas. Specs unit do helper + do Popover (freeze). Sem migration/action/Consent. Plano: [`selecionados-no-topo-filtros-header.md`](../../../docs/plans/selecionados-no-topo-filtros-header.md). - **E11 entregue em código (2026-07-28):** motor de sugestões v1 — os 8 padrões de maior confiança do relatório §6.1 (P1/P2/P3/P5/P6/P7/K-A/K-B) viram uma fila de decisão com humano no loop: painel "Sugestões" no Início staff (top-5 por triagem + pauta do silêncio) e card na visão geral do município, aceitar/adiar/descartar gravando `allocationDecision`. **Arquitetura em três camadas:** `src/lib/suggestionCatalog.ts` (client-safe — predicados puros sobre `MunicipalityTriggerInput`, um tipo só de números/flags já derivados, + a copy exata dos cards: leitura provável, "descartes" que viram quick-picks do formulário de descarte, menu ordenado barato-antes-de-caro, contraindicação, estatuto; âncoras ilustrativas num objeto interno `SUGGESTION_ANCHORS`, calibração = E15); `src/utilities/municipalityTriggers.ts` (`server-only`, molde do `visitPlannerData.ts` do E13: leitura própria de `municipality` porque o select do scope compartilhado não tem `engagementLevel`/`lastUpdateAt`, + 6 leituras em `Promise.all` — pledges, `countLeadershipsByMunicipality` exportado do planner, metas `cache()`, sinais `kind: 'sinal'` ≤28d, atividades ≥now−42d, últimas decisões ≤90d — e LQ por ano com a fórmula EXATA do classificador E10, sem uma terceira escrita); `resolveSuggestionRecord` (action) que **reavalia o padrão no servidor** antes de gravar — o snapshot registra os fatores do momento da decisão, nunca os do render, e padrão fora da fila recusa com `SUGGESTION_STALE_MESSAGE` (safe message) em vez de registrar decisão sobre fila que andou; lock `municipality-suggestion:{id}:{patternId}` **antes** de reler a última decisão (dois staff no mesmo card serializam). **A auditoria matou o "sem migration" do plano:** o enum real de `outcome` era `aceita|descarta|movimento` — os valores `descartada|adiada` que o plano citava nunca existiram — então "Adiar" custou a migration `20260728_041547_add_allocation_decision_adiada_outcome` (precedente exato do `movimento` do E14); adiamento registra `postponeDays` derivado do nível recomputado (7d; 14d nos níveis 4–5) e `suppressUntil` no snapshot, sem coluna nova. **Supressão é pura e unit-testada** (`isSuggestionSuppressedByDecision`, no catálogo): aceita 14d, descarta 30d, adiada até o snooze (snapshot malformado cai no adiamento mínimo, nunca "para sempre") — com a regra "nível 1 fura a fila": gatilho que volta como risco confirmado atravessa supressão registrada em urgência menor. **Diferencial estrutural codificado (§6.3):** P2 cede a P3 (correligionário segura o campo) e a K-B (série diz perdida); P7 cede a K-A quando há atividade realizada no ciclo — nunca dois veredictos sobre o mesmo dado. **P3 dispara só pelo artefato** (campo ≥ 35% dos válidos + fatia intracampo < 0,5× o padrão estadual próprio + gate de volume na mediana): `runningAgain2026` existe mas está 100% `desconhecido` e sem read path, então "concorre de novo?" é o 1º item do menu (inteligência), não gatilho. **Pauta do silêncio** = alta/N2+ sem gatilho **disparado** (supressão não é silêncio — decisão registrada é resposta) e sem sinal ≥30d; inclui `priority: alta` porque E14 nasceu sem backfill. UI: submit explícito nos três desfechos (exceção prevista em `campanha-edit-where-you-see` para flows com nota/confirmação — aceite carrega a ação do menu escolhida + nota, descarte exige leitura alternativa com quick-picks; adiamento é um toque com a duração no rótulo), radios nativos (`accent-primary` — primeiro radio precedente no repo), foco gerenciado na troca trigger↔form (abrir foca o primeiro campo, cancelar devolve ao trigger — verificado no browser), só nível 1 usa `destructive`, estatuto no rodapé do painel. **Um leak de base CSS achado no craft:** o `ul { my-6 ml-6 list-disc }` do artigo público vaza para superfícies campaign — a lista de fatores precisou do reset `m-0 list-none p-0 [&>li]:mt-0` (mesmo remédio do B20). **Testes com taxa medida:** os padrões ancorados no artefato não são encenáveis por linhas do banco, então os int tests que precisam de propriedade de artefato (quartil superior do P6, município quieto do silêncio) alocam municípios pelo alocador das fixtures até um qualificar — slug explícito colidiria com o alocador (sequence com wrap, ~23% de colisão por run); taxas medidas com script sobre o artefato: 109/435 quartil superior, 205/435 quietos, e **reduto+coreBlock são só 13/435**, o que tirou P1 do alcance de int (predicado fica pinado nos 45 casos unit; a costura sinais→P1 foi verificada no browser com dados reais do dev, que têm sinais de adversário). Verbetes E18 `triagem-de-sugestoes` e `pauta-do-silencio`. First Load JS `/campanha` 268 → 277 kB (catálogo + ilha do card, medido pós-simplify). Suíte 453 (45 unit novos + 9 int novos) em ~84s; **as falhas rotativas de timeout na suíte cheia eram contenção de ambiente** (outra worktree Cursor subiu dev server no meio das runs — árvore limpa 70s e a minha 84s, ambas verdes com a máquina calma). Aikido 0 findings em todos os arquivos com lógica nova. **Adjacentes registrados no plano:** ligar o override do "não vá" do compositor de giros (E13) ao `resolveSuggestion`; fase 2 do catálogo (T/I/J, P4/P8–P12, K-C) com gatilho de 2 semanas de uso + 20 decisões; rota dedicada se >15 sugestões ativas recorrentes. **Pós-`/simplify` (mesma sessão):** o achado que manda era de perf — o dashboard varria `votePledge` **duas vezes** por request (o avaliador re-agregava o que `loadMunicipalityScope`, React `cache()` já pago pelo `getCampaignDashboardData`, segurava em `pledgeAggregates`); o caminho sem filtro agora reusa o scope e o caminho de 1 município mantém a agregação própria. Também: a janela de decisões caiu de 90d chutados para 30d **derivados** das constantes de supressão; o catálogo importava `ELECTION_YEAR_*` de `electionResults`, cujo 1º import é `bahiaTerritories` — três números arrastavam a tabela de territórios para o chunk do card (a lição do B14, de novo; anos locais com comentário); `triggerSummary` era copy morta (8 parágrafos que nada renderizava — deletado); `silence.totalCount` era derivado (bundle virou array); o filtro de tipo de sinal desceu para o `where` e matou o único cast do delivery; LQ 2022 reusa `classification.lq` memoizado; copy dos fatores alinhada ao canônico "do candidato" (a docstring do E10 já proibia variação do possessivo); card adotou `FieldSet`/`FieldLegend`/`FieldLabel` de `ui/field`; `DAY_MS` e `SUGGESTION_TEXT_MAX_LENGTH` unificados no catálogo; `formatSilenceAgeLabel` mudou para `municipalitySignal.ts`. **Correção de registro:** suítes são 685 unit / 453 int (o "453" acima era só a fase int). **Ficou com gatilho:** consolidar as leituras repetidas de pledges/lideranças da `OverviewTab` (3 loaders não coordenados, pré-existente); `formatDominanceAgainstOwnStandard` → `lib/` quando outro módulo lib precisar. **Rodada 2 do `/simplify` (pós-rebase):** o rebase trouxe B17/B29/B32+/B37/B40 sem matar premissa nenhuma (o card corretamente não adota a máquina de célula do B32+; o cache hit do scope sobreviveu) — mas entregou o 3º call site da extração adiada: `firstFormActionMessage` nasceu em `campaignFormFields.ts` e o card, o `RelationChipCell` (B37) e o `toMessageOnlyState` de apoiadores o consomem. **Um P2 real:** a `rationale` composta (rótulo do menu + nota) estourava o cap de 2000 da collection com nota máxima — create rejeitado com erro genérico e sem retry possível; clamp + caso int pinando. Também: falha anuncia num canal só (a linha `role="alert"` — o toast dizia duas vezes), `metrics` (~209 B/card) não serializa mais ao cliente (pick campo a campo no painel), `SUGGESTION_OUTCOMES` exportado do schema, janelas de adiamento derivadas por `Math.min/max` dos níveis, `metrics` 100% de `input`. Plano as-built: [`motor-de-sugestoes.md`](../../../docs/plans/motor-de-sugestoes.md). - **B40 entregue em código (2026-07-28):** login com biometria (WebAuthn) em `/campanha` — enrollment opt-in depois do login com senha (toast dismissível no shell, no molde do `InstallPwaToast`, + seção permanente "Acesso por biometria" em `/campanha/perfil`), botão de platform authenticator na tela de login e revogação por dispositivo. Vale para **todos os papéis, liderança inclusive** (é quem tem mais atrito: celular + senha), e **não abre chave de `Consent`** — o SO nunca libera a digital, guardamos só uma chave pública, então o art. 11 não entra e o lote da Onda 0 não reabre. Collection `campaignWebAuthnCredential` (migration `20260728_041958_add_campaign_webauthn_credentials`), 4 rotas JSON em `campanha/webauthn/` **fora de `(app)`** (duas são anônimas; o path `/campanha` do cookie já as cobre), `@simplewebauthn/server@13` no servidor e `browser@13` no cliente. **A lacuna que a auditoria abriu e que era o coração da entrega:** o Payload 3.82.0 **não tem caminho password-less** (`loginOperation` exige `password`), então `issueCampaignWebAuthnSession` forja a sessão só com exports públicos — `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign` → o `setCampaignAuthCookie` do **B39 ✓**, com TTL longo porque cadastrar o aparelho _é_ o opt-in de confiança. **A mina, confirmada em código:** `addSessionToUser` faz read-modify-write do documento **inteiro** do usuário, e carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` — gravar isso de volta apagaria `sessions`/`email` (derrubando **todas as outras sessões**) e expandiria `avatar` em objeto; daí `db.findOne` cru com o comentário nomeando o perigo, `user.updatedAt = null` como o próprio login do Payload faz, lock `campaign-webauthn-session:{id}` numa transação (reescrever o array inteiro faz dois logins simultâneos perderem sessão) e int spec provando que a sessão pré-existente sobrevive ao mint. **Duas armadilhas que a auditoria não previu, e as duas se escondem atrás de um caminho que "funciona":** (1) `getCampaignWebAuthnRelyingParty` derivava o RP da origem canônica e **tinha sucesso** num preview da Vercel — o botão aparecia e o browser estourava `SecurityError`, porque quem serve a página é outro host; agora a função compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem, que é o "a UI se esconde" que o plano prometia; (2) o challenge do cookie HMAC precisa ser **os mesmos bytes** que o `@simplewebauthn/server` gerou — a primeira versão gerava o seu ao lado do dele e **todo `register` respondia 400**, achado só pelo e2e. Também: deletar um `campaignUser` quebrava, porque a FK que o Payload emite para a relação obrigatória é `ON DELETE SET NULL` sobre coluna `NOT NULL` → `beforeDelete` em `CampaignUser` apagando as credenciais na mesma transação (cascade explícito, sem editar a migration à mão); `checkLoginPermission` lança o erro genérico do Payload, capturado e relançado em pt-BR para que a conta trancada saiba que é bloqueio e não falha de biometria; e o erro do botão vivia no `FieldError` **e** na região `aria-live`, anunciado duas vezes pelo leitor de tela (o `sr-only` ficou só com o estado pendente). **Medido:** `@simplewebauthn/browser` custa **3,0 kB** gzip contra o orçamento de 10 kB do plano, e o `/simplify` o tirou do First Load JS de vez — a metade sempre carregada (probe de capacidade + tipo de erro) virou `src/lib/campaignWebAuthnSupport.ts`, que não importa nada, e as três ilhas chegam à cerimônia por `import()` dentro do handler: `/campanha/login` caiu de 130 para **126 kB** e `/campanha`, onde o toast de enrollment monta em toda navegação autenticada, ficou **byte-idêntico** aos 268 kB de antes do item. e2e com **autenticador virtual por CDP** (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`, que também faz `isUserVerifyingPlatformAuthenticatorAvailable()` responder `true`) cobrindo cadastrar → sair → entrar com biometria → revogar; para o caso da credencial revogada o fixture ganhou `expectedRequestFailurePaths`, porque o teste provoca um 4xx de propósito e o guard global tratava o `console.error` do fetch como erro não tratado. Aikido 0 findings. Plano as-built: [`login-biometrico-campanha.md`](../../../docs/plans/login-biometrico-campanha.md). **O `/simplify` cobrou uma simplificação minha de volta, e essa é a lição transferível:** tirar a anotação `Promise` da action pareceu redundância removida e o `tsc` provou o contrário — `runCampaignFormAction` tipa o ramo de falha **sem a chave `status`**, então só o tipo anotado (onde ela é opcional) permite ao chamador estreitar por `status === "success"`; voltou com o motivo escrito ao lado. O resto do passe: `canWriteWebAuthnCredentials` virou `payloadAdminOnly` em vez de reescrever o predicado; a sonda de disponibilidade (duas cópias com o mesmo guard de cancelamento) virou `useCampaignBiometricsAvailable`; o card passou a usar `FieldError` como o botão — e por isso perdeu a mensagem de erro da região `sr-only`, pelo mesmo motivo de anúncio duplo já corrigido no botão; as três frases declaradas dos dois lados foram para `campaignAuthCopy.ts`, e a que importava era "Este aparelho já está cadastrado.", que o servidor trata como entrada do allowlist `safeMessages` por **string exata** enquanto o cliente a reproduzia à mão; `expectedRequestFailurePaths` virou opção do Playwright (`test.use` no topo em vez de `push` no meio do teste); e o int que dizia "a senha ainda funciona" passou a **logar com a senha**, em vez de reafirmar o e-mail que a asserção acima já cobria. Dois débitos ficaram de fora com motivo: o núcleo HMAC é ~35 linhas iguais às de `supporterImportToken.ts` (2 call sites, e extrair mexeria no caminho do token de import) e `postJson` era a 6ª cópia do mesmo fetch. **Essa segunda o `main` resolveu enquanto o item estava aberto** (B32+ ✓), então o rebase não foi só um merge: as 4 rotas nasceram de novo sobre `campaignJsonMutationRoute` e o transporte passou a ser `postCampaignJson` — não por gosto, porque o B32+ trouxe um **guard de convenção** que reprova um `route.ts` exportando `POST` fora da casca, e as quatro estavam nomeadas nele. Duas coisas a saber de quem adotar a casca num caminho não-célula: ela **sempre** parseia um corpo, e as duas rotas `*-options` não têm nenhum — daí `campaignWebAuthnNoBodySchema`, com o cliente postando `{}`, que é o preço honesto de ter a verificação de origem por construção em vez de por disciplina; e `postJson` sobrevive como casca de **uma linha** sobre `postCampaignJson` que só descarta o `ok`, porque o discriminante do envelope é que decide. **A tradução de `SyntaxError` que a primeira versão dessa casca tinha caiu no `/simplify` do fechamento, e o motivo generaliza para qualquer ilha destas três telas:** ela escrevia "Resposta inválida do servidor." num `Error` cru, e as três ilhas leem `message` **só** de um `CampaignWebAuthnError` — a frase era redigida para a pessoa e jogada no canal que descarta mensagens. Deletar em vez de re-tipar, porque o fallback de cada ilha ("…Use sua senha.") é a copy melhor para quem está em campo; nada muda na tela, e o comentário agora registra por que o ramo está ausente. Verificado no servidor de produção local, sem browser: cross-origin 403, sem sessão 401, corpo não-JSON no 400 compartilhado, e o corpo `{}` chegando ao handler. - **B17 entregue em código (2026-07-28):** seletor de colunas, e **nas sete superfícies** do sistema de listas (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios), não só em municípios — o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility`, devolvida inteira por `readCampaignColumnVisibility(listId)`, liga o controle e filtra), então o custo marginal por lista foi `label` nas colunas e uma prop. Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`. **Duas premissas do plano de 24/07 morreram.** (1) `localStorage` — e o motivo é de tipo, não de gosto: `CampaignTableColumn.cell` é **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `