` — são 435 strings mortas no payload RSC de toda visita ao Início. Deleta os dois acessores órfãos (ver Questões em aberto) e, com eles, `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` (verificado: nem o card nem o script os leem — o script só usa `municipalitySlug` e `neighborhoods`), o que também apaga o filtro `entry.city === 'Salvador'` provavelmente-sempre-verdadeiro no int. Exporta `polygonRingsOf` de `municipalityProximity.ts:109` (3º call site: o spec novo e `tests/helpers/featureBounds.ts:16` reescrevem o mesmo branch Polygon/MultiPolygon) e alinha `featureBounds.ts` ao `BahiaGeometryFeature` — hoje é o único ainda tipado em `BahiaMunicipalityFeature`. Troca `BahiaGeometryFeature` (que carrega `GeoJsonProperties`, ou seja `any`) por um `type PolygonalFeature = { geometry: Polygon | MultiPolygon }` que declara o requisito real dos três helpers, e introduz `BahiaFeature` para as 4 grafias de `Feature` em `bahiaGeometriesTypes.ts`. No script: a chave resolvida por alias é calculada duas vezes com o mesmo fallback (`:329-330` e `:355-360`) e o `die` do guard é construído sobre a **segunda** — uma divergência faria o guard passar sobre um artefato que o primeiro passe montou errado; calcular uma vez num `Map` acima do passe 1. Fecha com `polygonCount` (`:156`, dois branches inalcançáveis depois do `die`), `[await shp(buffer)].flat()` (`:248`, lê como erro), o `url` que `ensureCachedBinary` devolve e ninguém desestrutura, e a referência morta ao branch `none|navigate|zones` em `docs/plans/municipio-mais-proximo.md:141`.
+- **Migration**: sem migration, sem collection, sem server action. F1 reescreve um artefato commitado (esperado e revisável no diff); F2–F4 não tocam dado.
+
+## Dependências
+
+- Nenhuma dura de outro item aberto. F1 é dependência **suave** de **B5 F2** (cache/factory dos scripts de geometria): quem fizer F1 cria o primeiro módulo compartilhado em `scripts/lib/`, e o B5 F2 deve pousar nele em vez de abrir um segundo.
+- Reusa: `scripts/build-bahia-geometries.mjs` (o irmão que define o padrão), `bahiaGeometriesTypes.ts`, `municipalityProximity.ts`, `municipalityCatalog.ts`, o laço de contrato de `tests/int/bahiaGeometries.int.spec.ts`.
+
+## Não escopo
+
+- **Rank competitivo por ZE** e **resolver a ZE exata no card "Onde estou"** — os dois débitos de produto do B8 F2, com gatilho no [plano do B8](poligonos-pracas-zona.md). Este lote é custo e coerência, não capacidade nova.
+- **Bloco de helpers de CLI dos scripts** (`die`, `sha256`, `cacheDir`, `ensureCached*`, `writeJson` — hoje na 4ª–6ª cópia) — **B5 F2** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)), cujo contador de call sites já foi atualizado com este script (5º de `ensureCached*`, e o primeiro segundo consumidor real de `GEOMETRIES_CACHE_DIR`, o que valida a decisão de `cacheDir` por chamada que aquele plano tomou). O `ensureCachedBinary` daqui devolve o buffer cru em vez de `JSON.parse` — a variante que faltava para `ensureCachedDownload` ser superconjunto das quatro.
+- **Genérico para o trio `*GeometryModule`** — adiado com gatilho abaixo; renomear um acessor público a três malhas não paga.
+- **`normalizeNeighborhood` e `IBGE_TO_TRE_NEIGHBORHOOD` no script** — ficam locais de propósito: a regra do parêntese é o que transforma `"Periperi (parte ao norte da Rua das Pedrinhas)"` em `PERIPERI`, e `normalizeMunicipalityKey` não faz isso. É chave de bairro, não de município, e o script é o único consumidor.
+
+## Rabbit holes
+
+- **"Já que estou mexendo na simplificação, melhoro a qualidade das duas malhas."** Se alguém "só completar": os três artefatos são rebuildados, o diff fica ilegível e a malha estadual (que todo mundo baixa) engorda. **Mitigação:** F1 tem critério binário — pontos/feature das zonas cai para a ordem do irmão, e `bahia-municipalities.topo.json` sai **byte-idêntico** ou com mudança justificada no plano.
+- **"Já que a chave está em três lugares, crio um resolvedor único."** Se alguém "só completar": o resolvedor precisa aceitar registro de catálogo **e** saco de propriedades GeoJSON, e vira wrapper raso sobre um `??`. **Mitigação:** F2 é só teste; mudança de runtime aqui exige evidência nova.
+- **"Já que estou no `loadLayerFeatures`, limpo o modo `territory` morto."** O branch não tem consumidor de produção (`MunicipalityMapPanel` fixa `mode="municipality"` e o B21 tirou o painel de TI do Início), mas o corte v1 do **E12** nomeia "sem modo de mapa TI" como adiado, ou seja, o artefato tem consumidor futuro nomeado. **Mitigação:** F4 deleta os **acessores** órfãos, não a malha nem o modo; a decisão sobre o modo `territory` pertence ao E12.
+
+## Adiado com gatilho
+
+- **`rankZonesBySharedBoundary` é O(U × N)** — reconstrói `arcIndexes(other.arcs)` para cada vizinho candidato de cada polígono não atribuído. Medido como irrelevante: centenas de milhares de inserts em `Set`, milissegundos, ofuscados pelo download e pelo `shp()` da malha estadual. Revisitar quando: uma segunda cidade entrar no dissolve (o `U × N` cresce com o mosaico, não com as zonas). Hoje, mexer nisso é queimar entrega em ganho zero.
+- **Helper `createCampaignUser` para os e2e** — a criação inline de `campaignUser` está na 19ª cópia (`campaignZoneMap.e2e.spec.ts:1618-1629` é a mais nova), e o int já tem o helper (`tests/helpers/campaignFixtures.ts:542-559`). Pré-existente em `main` e inerte, portanto fora deste lote. Revisitar quando: o próximo spec e2e nascer — ele paga o helper em `campaignE2EFixtures.ts` e migra os vizinhos que tocar.
+- **Genérico para `MunicipalityZoneGeometryModule`/`MunicipalityGeometryModule`/`TerritoryGeometryModule`** com `getFeature(key)` uniforme. Revisitar quando: **a 4ª malha** entrar — a três, o rename atravessa `municipalityProximity.ts`, dois int specs e um e2e por ganho estético.
+- **Um único laço sobre `municipalities` em `municipalityMapData.ts`** (hoje quatro, cada um chamando `mapKeyForMunicipality`). Revisitar quando: o bundle ganhar um 5º campo por município — abaixo disso, juntar o laço histórico com o de cenários custa legibilidade e economiza microssegundos.
+
+## Explicitamente fora (descartes deste triage)
+
+- **`geometriesByZone.set(slug, [...anterior, geometry])` copia o array a cada insert** (`:375`, O(K²) com K ≈ 30) — `push` tem o mesmo tamanho e não é quadrático, mas o custo é indistinguível de zero; entra de graça se alguém já estiver naquela linha.
+- **O laço de rank olhando Salvador 19 vezes** (`municipalityMapData.ts:144`, ~54 lookups extra por request) e **`mapKeyForMunicipality` chamado 2× por município por ano** — os próprios revisores concluíram que não vale tocar.
+- **`@types/shpjs`** — `tsconfig.json` só inclui `**/*.ts(x)`, então `scripts/*.mjs` nunca é type-checked e o pacote compra hover no editor. Há precedente idêntico (`@types/topojson-server`); é consistência, não defeito.
+- **`as unknown as MunicipalityZoneTopology`** em `bahiaMunicipalityZoneGeometries.ts:10` — byte a byte o padrão dos dois módulos irmãos (import de JSON que o TS não estreita).
+- **`baseKeys` ser cinto-e-suspensório com `interactiveKeys`** — hoje `scopedKeys` nunca contém `2927408`, então o polígono-base já seria não-interativo pelo caminho antigo. Mantido de propósito: `baseKeys` codifica um invariante que o **componente** possui ("polígono de malha-base nunca é endereçável") em vez de um que o chamador por acaso fornece, e é o que mantém a base fora de `pathByKeyRef`, que o `fitMapToHighlights` novo lê. O que falta é uma asserção dizendo **qual** dos dois mecanismos é load-bearing; se F2 sobrar tempo, é uma linha no e2e (`total: 436, interactive: 435` passa sob qualquer um dos dois).
+- **`tests/int/municipalityMapData.int.spec.ts:73` reescrevendo a regra da chave à mão** em vez de chamar `mapKeyForMunicipality` — é o trabalho do teste: expectativa independente.
+- **Shoelace duplicado no int spec** (`bahiaGeometries.int.spec.ts:45-66`) — `featureCentroid` precisa da área **assinada** e dos momentos no mesmo passe, então compartilhar custaria um segundo passe ou uma forma de retorno estranha. F4 compartilha só o `polygonRingsOf`; a soma de áreas fica duplicada de propósito.
+
+## Referências
+
+- `docs/roadmap.md` (Fill-ins abertos — **B8+**)
+- [poligonos-pracas-zona.md](poligonos-pracas-zona.md) — o pai do lote (B8 F2 ✓), incl. os dois débitos de produto com gatilho
+- [escala-dry-pos-b2.md](escala-dry-pos-b2.md) — B5 F2, dono do bloco de helpers de CLI que F1 encosta
+- [mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) — B2, dono do padrão de artefato estático
+- [municipio-mais-proximo.md](municipio-mais-proximo.md) — B14 ✓, dono da memoização de rejeição que F3 preserva (e da referência morta que F4 corrige)
+- `scripts/build-municipality-zone-geometries.mjs` + `scripts/build-bahia-geometries.mjs` — F1, F4
+- `tests/int/bahiaGeometries.int.spec.ts` (`:29` budget, `:149` laço de contrato) — F1, F2
+- `src/lib/bahiaMapStyle.ts` (`:15`, `:95-109`) + `tests/unit/bahiaMapStyle.unit.spec.ts` — F2
+- `src/components/campaign/map/BahiaMap.tsx` (`:73-90` duas malhas) + `MunicipalityMapPanel.tsx` (`:594-601` ressalva) — F3
+- `src/utilities/municipalityMapNavigation.ts` + `src/lib/municipalityProximity.ts` + `src/lib/bahiaGeometriesTypes.ts` + `tests/helpers/featureBounds.ts` — F4
+- AGENTS.md — geometrias B2/B8 F2, gate por entrega, `overrideAccess: false` com `user`
+- `.cursor/rules/engineering-standards.mdc` — "dead code dies immediately" (F4), depth check (F1/F2)
diff --git a/docs/plans/poligonos-pracas-zona.md b/docs/plans/poligonos-pracas-zona.md
index 597a0348..a1f9c426 100644
--- a/docs/plans/poligonos-pracas-zona.md
+++ b/docs/plans/poligonos-pracas-zona.md
@@ -170,6 +170,10 @@ Atribuição em dois passes: (1) nome citado pela resolução; (2) **maior front
- **S9 — Double gate** `view.kind === 'zona'` na página + card `null`. Defesa em profundidade; não simplificar só por DRY.
- **S10 — `key={neighborhood}` no card.** Seguro com exclusividade Salvador testada.
+## Débitos do `/simplify` da F2 → B8+
+
+Os relatórios dos três revisores chegaram **depois** do merge; o cleanup barato entrou antes (ver "Já resolvido"), e os quatro achados maiores viraram o fill-in **B8+**: [escala-dry-pos-b8f2.md](escala-dry-pos-b8f2.md) — a malha de 19 zonas com a densidade de vértices da malha estadual (as duas constantes de simplificação são relativas ao bbox), o buraco de teste da map key, a falha de chunk das zonas derrubando o mapa inteiro (mais a ressalva de aproximação sumindo no modo comparação), e o que a entrega orfanou.
+
## Adiado com gatilho
- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Gatilho disparou** na F2 (o painel foi alterado) e foi reavaliado: as duas listas continuam pequenas e com estrutura diferente, então extrair peça compartilhada agora seria abstração por coincidência visual. Segue adiado, agora com gatilho único: **R6** citar a duplicação em ≥2 superfícies de zona.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index ba728fd6..a29ac2a5 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-26 (**B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
+Atualizado em: 2026-07-26 (**B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -58,7 +58,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **B38 (2026-07-26)** — sidebar recolhido em tablet: `CampaignSidebar` com `collapsible="offcanvas"`, `SidebarTrigger` no inset `md+`, `defaultOpen` a partir do cookie `sidebar_state`, colapso padrão 768–1023 via `CampaignSidebarViewportDefault` quando não há cookie; mobile Sheet inalterado. Plano: [sidebar-recolhido-tablet.md](plans/sidebar-recolhido-tablet.md).
- **B13 (2026-07-26)** — escala relativa no mapa do Início: o coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71%. Três escalas relativas em classes discretas — **quantis** (novo default, degradando para 3 classes com escopo < 10 municípios e declarando isso na legenda), **padrão próprio (LQ)** nas mesmas âncoras do E10 e **posição no município** — mais uma camada opcional de bolhas por válidos projetados, coloridas pela classe do E10. "Posição no município" não existia em lugar nenhum: entrou no **artefato TSE commitado (v3)** como `federalRankByIbgeCode` (colocação entre todos os candidatos com votos na geografia, 2014/2018/2022; 534 → 623 KB, orçamento 700 KB), não em query de request. `computeAggregateTerritorialClass` classifica um grupo de slugs como um território só (o mapa é por `ibgeCode` e Salvador são 19 slugs) somando insumos e rodando o **mesmo** classificador — o E12 herda o helper. As âncoras do E10 saíram para `src/lib/territorialClassAnchors.ts` para não arrastar o artefato ao bundle do cliente; a matemática das escalas vive em `src/lib/mapScaleClasses.ts`. Legenda discreta com faixas reais, readout que nunca mostra a classe sem o porquê, dois conceitos novos em `/campanha/conceitos`. Rank indisponível em 2026 (dado TSE); sem migration; bundle client de `/campanha` inalterado (o payload RSC cresceu ~63,6 KB — **B13+ F4**). Plano: [escala-relativa-mapa.md](plans/escala-relativa-mapa.md); débitos do `/simplify`: [escala-dry-pos-b13.md](plans/escala-dry-pos-b13.md).
-- **B8 F2 (2026-07-26)** — polígonos das 19 ZE de Salvador e re-keying do coroplético. Salvador era **um** polígono carregando os votos da cidade, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu". Novo artefato commitado `bahia-municipality-zones.topo.json` (72 KB, ~20 KB gzip, 19 features) gerado por `pnpm build:municipality-zone-geometries` a partir do `BA_bairros_CD2022` do IBGE (`shpjs` como devDependency — o recorte só existe em SHP), dissolvendo bairro→zona pela **RA 02/2017 do TRE-BA**: 170/170 polígonos atribuídos, 19/19 zonas, curadoria em 3 tabelas explícitas no script (aliases, bairro sem polígono próprio, bairro que a resolução parte) mais um passe de **maior fronteira compartilhada** para os 5 que a resolução nunca nomeia — regra validada contra os 4 que ela nomeia sob outro rótulo. A chave do mapa virou **map key** (`slug` na zona, `ibgeCode` no resto): bundle, rollups, navegação e classe territorial re-keyed, e o ramo `kind: 'zones'` do readout morreu. O polígono municipal de Salvador ficou desenhado por baixo como **base sem dado e sem pointer**, porque duas malhas simplificadas independentemente não fecham na mesma costa. `competitiveRank` continua por cidade (o artefato só tem `federalRankByIbgeCode`, o que a legenda já dizia — int pina que as 19 dividem uma posição). Sem migration, sem collection, sem action. Plano: [poligonos-pracas-zona.md](plans/poligonos-pracas-zona.md).
+- **B8 F2 (2026-07-26)** — polígonos das 19 ZE de Salvador e re-keying do coroplético. Salvador era **um** polígono carregando os votos da cidade, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu". Novo artefato commitado `bahia-municipality-zones.topo.json` (72 KB, ~20 KB gzip, 19 features) gerado por `pnpm build:municipality-zone-geometries` a partir do `BA_bairros_CD2022` do IBGE (`shpjs` como devDependency — o recorte só existe em SHP), dissolvendo bairro→zona pela **RA 02/2017 do TRE-BA**: 170/170 polígonos atribuídos, 19/19 zonas, curadoria em 3 tabelas explícitas no script (aliases, bairro sem polígono próprio, bairro que a resolução parte) mais um passe de **maior fronteira compartilhada** para os 5 que a resolução nunca nomeia — regra validada contra os 4 que ela nomeia sob outro rótulo. A chave do mapa virou **map key** (`slug` na zona, `ibgeCode` no resto): bundle, rollups, navegação e classe territorial re-keyed, e o ramo `kind: 'zones'` do readout morreu. O polígono municipal de Salvador ficou desenhado por baixo como **base sem dado e sem pointer**, porque duas malhas simplificadas independentemente não fecham na mesma costa. `competitiveRank` continua por cidade (o artefato só tem `federalRankByIbgeCode`, o que a legenda já dizia — int pina que as 19 dividem uma posição). Sem migration, sem collection, sem action. Plano: [poligonos-pracas-zona.md](plans/poligonos-pracas-zona.md); débitos do `/simplify` (relatórios chegaram depois do merge): [escala-dry-pos-b8f2.md](plans/escala-dry-pos-b8f2.md).
## Próximos — Campanha (`/campanha`)
@@ -136,6 +136,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**B16+** escala/DRY pós-B16~~ — **absorvido pelo Pass 2 W1-D1 (2026-07-25)**: `useOptimistic`, facet por slugs, hrefs via serializador canônico, facets no mesmo `Promise.all` · [plano](plans/escala-dry-pos-b16.md)
- **E10+** escala/DRY pós-E10 — quatro fases do `/simplify` da classificação territorial: **F1** a lista lê `municipality` 2× por request (`listQuery` + `loadMunicipalityScope`, mesmo `where`, select superset) — **fecha o "Adiado com gatilho" do A11**, cujo gatilho ("3º path derivado no loader") disparou com `classe` sendo o 4º (`votos`, `deficit`, `frescor`); **F2** `computeAllMunicipalityPotentials()` recomputa 435 linhas puras do artefato por request (memo de processo, como `bahiaElectionAggregates`/`municipalityVoteRank`); **F3** um `TooltipProvider` por tooltip (~30 na lista) anula o agrupamento de delay do Radix — hoist para o layout de `(app)`; **F4** os 4 multi-filtros do mobile saem da mesma `municipalityFilterDefinitions` que os single-select vizinhos já usam · ~0,75–1d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e10.md)
- **B13+** escala/DRY pós-B13 — quatro fases do `/simplify` da escala relativa no mapa, as duas primeiras de **coerência** e não de custo: **F1** a razão do LQ é escrita duas vezes (painel cliente `:210` × classificador `server-only` `:108`, separação **deliberada** — o classificador arrasta o artefato de 600 KB) sem nenhum teste travando a concordância, que é a rede de segurança do **E15**; **F2** no readout do mapa a posição vale para a cidade inteira enquanto a classe é calculada sobre o escopo do usuário, então dois assessores veem classes diferentes para o mesmo polígono de Salvador — declarar a geografia, sem mudar o cálculo; **F3** `pnpm build:election-aggregates` varre a mesma fatia estadual do TSE 3× por ano (~225k docs extras) porque `loadFederalVotesByCityZoneAndCandidate` só precisa de `party` no `select` para ser superconjunto estrito dos outros dois; **F4** `competitiveRankByYear` soma ~57 dos ~63,6 KB que o B13 pôs no payload RSC, pagos mesmo por quem nunca abre a escala — tupla `[rank, candidates]` corta ~metade e responde ao "profiling antes de agir" que o ledger pediu em 2026-07-23 · ~0,5–0,75d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b13.md)
+- **B8+** escala/DRY pós-B8 F2 — quatro fases do `/simplify` dos polígonos das ZE, aberta pelo único achado **medido e já commitado**: a malha das 19 zonas tem 9.120 pontos de arco contra 9.139 da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature), porque `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia** — o bbox de Salvador é ~26× mais estreito, então a mesma "grade de 10⁴" vale ~3,9 m no chão contra ~103 m no estado (Salvador é um `Polygon` de 35 pontos na malha municipal); **F1** calibra as duas malhas pela mesma escala de **chão** — e é a escala, não a contagem de dígitos, que fica compartilhada entre os scripts, porque extrair as constantes como estão cimentaria o defeito — trocando o tripwire de bytes (`MAX_TOPO_BYTES`, calibrado para malha estadual) por um de pontos/feature; **F2** fecha o buraco de teste da **map key**, escrita em três formas (`mapKeyForMunicipality` sobre o catálogo × `featureMapKey` sobre propriedades GeoJSON × a identidade `baseKeys` do `BahiaMap`) que, se discordarem, pintam cinza **sem erro em lugar nenhum** — hoje nada roda `featureMapKey` sobre um feature real fora do e2e; **F3** para de derrubar o mapa inteiro quando o chunk das zonas falha (`Promise.all` + memoização da rejeição = ponto único de falha novo, quando antes a malha municipal sozinha bastava) e tira a ressalva "é aproximado, não é o limite oficial do TSE" de dentro do gate `!comparisonActive`, onde ela desaparece enquanto a malha aproximada continua pintada; **F4** deleta o que a entrega orfanou e o knip não vê (`getTerritoryFeature` perdeu o último consumidor de produção **neste commit**), colapsa a união de navegação de 3 estados num `string | undefined` e tira 435 strings mortas (`name`) do payload RSC do Início · ~0,75–1d eng · Impeccable A · sem migration (F1 reescreve o artefato commitado) · cortável · [plano](plans/escala-dry-pos-b8f2.md)
- **E12+** escala/DRY pós-E12 — duas fases condicionais do `/simplify` da camada TI: **F1** extrair peers/sub-linhas do Metropolitano para `lib/` quando surgir 4º call site ou regra mais rica (hoje constantes + benchmark intra-TI compartilham `territoryOverview.ts`); **F2** memo de peer-group no benchmark T4 só com evidência de duplicação no detalhe · ~0,25–0,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e12.md)
- **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md)
- **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md)