diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 15605d9b..1eaa6073 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -8,7 +8,8 @@ alwaysApply: false
## Status
- **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).
-- **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 com pan preservado; 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). Próximo chassis: **B48** resultados busca.
+- **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).
- **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`). Próximo: **B49** lideranças (mesmo payload); **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.~~
diff --git a/docs/plans/restaurar-pan-strip-acoes-inicio.md b/docs/plans/restaurar-pan-strip-acoes-inicio.md
index 5821c36a..be834c27 100644
--- a/docs/plans/restaurar-pan-strip-acoes-inicio.md
+++ b/docs/plans/restaurar-pan-strip-acoes-inicio.md
@@ -1,7 +1,7 @@
# Restaurar pan/scroll na strip de ações (scrollbar oculta)
-Status: rascunho
-Atualizado em: 2026-07-29
+Status: entregue (2026-07-29)
+Atualizado em: 2026-07-29 — as-built: `pointer-fine` drag-to-scroll em `CampaignHomeActionStrip` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8); scrollbar oculta mantida; `onClickCapture` suprime navegação após arraste; touch `pan-x` nativo inalterado; fecha Adiado drag desktop de B44.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B67** — UX-1 chassis)
Impeccable: B — encaixe em `CampaignHomeActionStrip` / `CampaignHomeActionButton`
Appetite: ~0,25–0,5 dia eng; overflow + drag-to-scroll; sem migration
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 666216ad..acceb79e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-29 — ~~**B56**~~ ✓ resumo no Início; **B67** restaurar pan na strip; **B68** sugestões na busca aberta; **B66**; **B59** sticky; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B59–B63.
+Atualizado em: 2026-07-29 — ~~**B56**~~ ✓ resumo no Início; ~~**B67**~~ ✓ pan na strip; **B68** sugestões na busca aberta; **B66**; **B59** sticky; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B59–B63.
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)
@@ -83,12 +83,12 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
### Demais itens abertos
-- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → **B67** (pan strip — regressão pós-B58) → **B65** (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** **B59** → **B60**/**B61**/**B64** · **B62** → **B63**.
+- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → **B65** (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** **B59** → **B60**/**B61**/**B64** · **B62** → **B63**.
- ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md)
- ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
- ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md)
- **B58** Polimento visual da strip de ações do Início — ~~remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado)~~ — **entregue 2026-07-29** (catálogo + strip + botão; e2e atualizado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md) · **regressão de pan → B67**
-- **B67** Restaurar pan/scroll na strip de ações (scrollbar continua oculta; wheel + toque-e-puxar + **click+arrasta** no desktop — fecha Adiado B44) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B65/B66/B59 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md)
+- **B67** Restaurar pan/scroll na strip de ações — **entregue 2026-07-29** (scrollbar oculta mantida; `pointer-fine` click+arrasta + touch pan nativo; limiar 8px; unit drag vs click) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md)
- **B59** Chassis do wizard (shell de passo + seta Voltar só mobile; desktop = browser; **município sticky** no limite superior central, semi-discreto, após escolha) · Depende de B45 ✓ · ~0,75–1d · Janela 1 · Impeccable C · [plano](plans/chassis-wizard-campanha.md)
- **B60** Etapa busca município nos wizards (UX da busca do Início, só municípios; select → avança) · Depende de B59 · soft B48 · ~1d · Janela 1 · Impeccable B · [plano](plans/busca-municipio-wizard.md)
- **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/ajuste-votos-wizard.md)
@@ -214,7 +214,7 @@ flowchart TD
B44n["B44 ✓ Botão ação
+ strip horizontal"]
B45n["B45 ✓ Catálogo ações
por persona"]
B58n["B58 ✓ Polish strip
ações Início"]
- B67n["B67 Restaurar pan
strip ações"]
+ B67n["B67 ✓ Restaurar pan
strip ações"]
B46n["B46 ✓ Thumb zone
ações Início"]
B47n["B47 ✓ Busca global
input + modo focado"]
B66n["B66 Focus limpa tela
+ animação busca"]
@@ -438,11 +438,11 @@ flowchart TD
B8F2 -.rank por ZE = débito aberto.-> B14n
```
-Paralelizáveis agora: **B67** (pan strip — regressão; quick win); **B65** (âncora inferior mobile); **B66** (focus limpa busca — melhor após B65); **B68** (sugestões empty — após B48 ✓; melhor após B66); **B59** (chassis wizard — desbloqueia B60/B61/B63/B64); **B62** (simplificar sinal — paralelo a B59); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B59**: **B60** ∥ **B61** ∥ **B64**. **B63** espera B59+B62. Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap.
+Paralelizáveis agora: **B65** (âncora inferior mobile); **B66** (focus limpa busca — melhor após B65); **B68** (sugestões empty — após B48 ✓; melhor após B66); **B59** (chassis wizard — desbloqueia B60/B61/B63/B64); **B62** (simplificar sinal — paralelo a B59); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B59**: **B60** ∥ **B61** ∥ **B64**. **B63** espera B59+B62. Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap.
### Sequência por janela (só pendentes)
-**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → B67** (pan strip) **→ B65** (âncora inferior) **→ B66** (focus limpa busca) **→ B68** (sugestões empty) **→** **wizards B59** → B60∥B61∥B64 · B62 → B63 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = pan B67 + polish busca B65/B66/B68 + busca B49+ + wizards B59–B64. **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; ~~**B38**~~ ✓ sidebar recolhido em tablet (entregue 2026-07-26 — `offcanvas` + trigger `md+` + cookie/`matchMedia`); **B39** (lembrar de mim / sessão longa — o default de 2 h está derrubando sessão no onboarding; ~0,5 dia, entra cedo) e, logo atrás se folga, ~~**B40**~~ ✓ (biometria WebAuthn — entregue 2026-07-28); ~~**B41**~~ ✓ (scroll horizontal + sticky na lista de municípios — entregue 2026-07-27) e ~~**B42**~~ ✓ (polimento mobile da mesma lista — Drawer em Tendência/Assessores/Votos estimados + card compacto e clicável; entregue 2026-07-27); **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); ~~**B32**~~ ✓ (editar Status de apoio na célula da mesma lista — entregue 2026-07-26; fecha o adiado do próprio B29); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); ~~**B17**~~ ✓ (seletor de colunas) / ~~**B18**~~ ✓ (filtros salvos + atalho sidebar) entregues 2026-07-28; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14 ✓** (atalho geo entregue 2026-07-26); Onda 0 jurídica em paralelo (externa).
+**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ B65** (âncora inferior) **→ B66** (focus limpa busca) **→ B68** (sugestões empty) **→\*\* **wizards B59** → B60∥B61∥B64 · B62 → B63 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = polish busca B65/B66/B68 + busca B49+ + wizards B59–B64.
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
@@ -452,7 +452,7 @@ Paralelizáveis agora: **B67** (pan strip — regressão; quick win); **B65** (
### Cortes seguros / não cortáveis
-**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; **B67** pan na strip de ações (B58 ✓ escondeu a barra e o pan sumiu — cortar = ações fora da dobra inacessíveis no mobile); **B65** âncora inferior mobile (B46 ✓ não ancorou — cortar = thumb zone continua falhando no celular); **B66** focus limpa busca (cortar = strip compete com o gesto de buscar até digitar — emenda explícita de produto); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); **B62** simplificar sinal (schema — cortar = wizard B63 e B26 seguem exigindo Fonte/Triangulado); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção).
+**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); **B65** âncora inferior mobile (B46 ✓ não ancorou — cortar = thumb zone continua falhando no celular); **B66** focus limpa busca (cortar = strip compete com o gesto de buscar até digitar — emenda explícita de produto); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); **B62** simplificar sinal (schema — cortar = wizard B63 e B26 seguem exigindo Fonte/Triangulado); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção).
**Cortes seguros** (se o prazo apertar, nesta ordem): **B68** sugestões na busca aberta (digitação B48 continua; cortar = empty mudo no focus até folga); **B64** wizard tendência standalone (A1 ainda pode embutir tendência via B24 ✓ / passo curto; cortar = sem grid colorido + prefill até folga); **B63** wizard sinal standalone (A1 ainda pode embutir select pós-B62; cortar = sem grid/Drawer de tipos até folga); **B57** delta 7d (Início continua com total+cobertura do B56; Δ era futuro de propósito); **B56** resumo no Início (Quadro já tem os mesmos agregados — cortar = Início sem briefing numérico); **B55** ações WA/share na busca (linha continua clicável); **B54** grid/cap (empilhar sempre); **B51**/**B53**/**B52**/**B50**/**B49** grupos secundários da busca (nessa ordem — Municípios B48 fica); **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); ~~**E14** níveis~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); ~~**B40** login biométrico~~ (entregue 2026-07-28 — senha + B39 ✓ continuam, e cortar teria custado só o atalho de digital/Face ID; era o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); ~~**B18** filtros salvos~~ ✓ entregue (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); ~~**B17** seletor de colunas~~ ✓ entregue (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28 — o mapa B8 F2 ✓ continua abrindo cada ZE); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins.
diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
index 536b5867..3a93e406 100644
--- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
+++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
@@ -1,12 +1,34 @@
'use client'
+import { useCallback, useRef, type MouseEvent, type PointerEvent } from 'react'
+
import {
CampaignHomeActionButton,
type CampaignHomeActionButtonProps,
} from '@/components/campaign/dashboard/CampaignHomeActionButton'
import { TooltipProvider } from '@/components/ui/tooltip'
+import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
import { cn } from '@/lib/utils'
+/** Horizontal pan threshold (px) — below this, child link/button clicks still fire. */
+export const HOME_ACTION_STRIP_DRAG_THRESHOLD_PX = 8
+
+type DragSession = {
+ pointerId: number
+ startX: number
+ startScrollLeft: number
+ dragging: boolean
+}
+
+const setScrollerDragging = (el: HTMLDivElement | null, dragging: boolean) => {
+ if (!el) return
+ if (dragging) {
+ el.dataset.dragging = 'true'
+ } else {
+ delete el.dataset.dragging
+ }
+}
+
export const CampaignHomeActionStrip = ({
actions,
ariaLabel = 'Ações rápidas',
@@ -15,22 +37,116 @@ export const CampaignHomeActionStrip = ({
actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[]
ariaLabel?: string
className?: string
-}) => (
-
-
-
- {actions?.map(({ id, ...button }) => (
- -
-
-
- ))}
-
-
-
-)
+}) => {
+ const isCoarsePointer = useCoarsePointer()
+ const scrollerRef = useRef(null)
+ const dragRef = useRef(null)
+ const suppressClickRef = useRef(false)
+
+ const endDragSession = useCallback((pointerId: number) => {
+ const session = dragRef.current
+ if (!session || session.pointerId !== pointerId) return
+
+ const el = scrollerRef.current
+ if (el && typeof el.hasPointerCapture === 'function' && el.hasPointerCapture(pointerId)) {
+ el.releasePointerCapture(pointerId)
+ }
+
+ if (session.dragging) {
+ suppressClickRef.current = true
+ }
+
+ setScrollerDragging(el, false)
+ dragRef.current = null
+ }, [])
+
+ const onPointerDown = useCallback(
+ (event: PointerEvent) => {
+ if (isCoarsePointer || event.button !== 0) return
+
+ const el = scrollerRef.current
+ if (!el) return
+
+ dragRef.current = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startScrollLeft: el.scrollLeft,
+ dragging: false,
+ }
+ suppressClickRef.current = false
+ if (typeof el.setPointerCapture === 'function') {
+ el.setPointerCapture(event.pointerId)
+ }
+ },
+ [isCoarsePointer],
+ )
+
+ const onPointerMove = useCallback((event: PointerEvent) => {
+ const session = dragRef.current
+ if (!session || session.pointerId !== event.pointerId) return
+
+ const el = scrollerRef.current
+ if (!el) return
+
+ const deltaX = event.clientX - session.startX
+
+ if (!session.dragging) {
+ if (Math.abs(deltaX) < HOME_ACTION_STRIP_DRAG_THRESHOLD_PX) return
+ session.dragging = true
+ setScrollerDragging(el, true)
+ }
+
+ el.scrollLeft = session.startScrollLeft - deltaX
+ event.preventDefault()
+ }, [])
+
+ const onPointerEnd = useCallback(
+ (event: PointerEvent) => {
+ endDragSession(event.pointerId)
+ },
+ [endDragSession],
+ )
+
+ const onClickCapture = useCallback((event: MouseEvent) => {
+ if (!suppressClickRef.current) return
+ event.preventDefault()
+ event.stopPropagation()
+ suppressClickRef.current = false
+ }, [])
+
+ const finePointerPanProps = isCoarsePointer
+ ? {}
+ : {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp: onPointerEnd,
+ onPointerCancel: onPointerEnd,
+ onClickCapture,
+ }
+
+ return (
+
+
+
+ {actions?.map(({ id, ...button }) => (
+ -
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx
index af55a922..95604bff 100644
--- a/tests/unit/campaignHomeActionButton.unit.spec.tsx
+++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx
@@ -6,7 +6,10 @@ import {
CampaignHomeActionButton,
type CampaignHomeActionButtonProps,
} from '@/components/campaign/dashboard/CampaignHomeActionButton'
-import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
+import {
+ CampaignHomeActionStrip,
+ HOME_ACTION_STRIP_DRAG_THRESHOLD_PX,
+} from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { TooltipProvider } from '@/components/ui/tooltip'
import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
import { CAMPAIGN_LONG_PRESS_MS } from '@/lib/campaignLongPress'
@@ -150,6 +153,20 @@ describe('CampaignHomeActionButton', () => {
})
describe('CampaignHomeActionStrip', () => {
+ const manyActions = Array.from({ length: 8 }, (_, index) => ({
+ label: `Ação ${index + 1}`,
+ icon: BarChart3,
+ }))
+
+ const getScroller = (container: HTMLElement) =>
+ container.querySelector('[aria-label="Ações rápidas"]') as HTMLDivElement
+
+ const mockScrollerOverflow = (scroller: HTMLDivElement) => {
+ Object.defineProperty(scroller, 'scrollWidth', { value: 800, configurable: true })
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true })
+ scroller.scrollLeft = 0
+ }
+
it('renders a horizontal scroller with list semantics', () => {
const { container } = render(
{
/>,
)
- const scroller = container.querySelector('[aria-label="Ações rápidas"]')
+ const scroller = getScroller(container)
expect(scroller?.className).toContain('overflow-x-auto')
expect(scroller?.className).toContain('scrollbar-width:none')
expect(scroller?.querySelector('ul[role="list"]')).toBeTruthy()
})
+
+ it('scrolls horizontally on pointer-fine drag past the threshold', () => {
+ const { container } = render()
+ const scroller = getScroller(container)
+ mockScrollerOverflow(scroller)
+
+ const startX = 120
+ const dragDelta = HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 20
+
+ fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 1 })
+ fireEvent.pointerMove(scroller, {
+ button: 0,
+ clientX: startX - dragDelta,
+ pointerId: 1,
+ })
+ expect(scroller.scrollLeft).toBe(dragDelta)
+ expect(scroller.dataset.dragging).toBe('true')
+
+ fireEvent.pointerUp(scroller, { pointerId: 1 })
+ expect(scroller.dataset.dragging).toBeUndefined()
+ })
+
+ it('does not pan on coarse pointer — touch uses native overflow scroll', () => {
+ resetCampaignCoarsePointerForTests()
+ matchMediaMock.mockImplementation((query: string) => ({
+ matches: query.includes('coarse'),
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+
+ const onClick = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const scroller = getScroller(container)
+ mockScrollerOverflow(scroller)
+
+ fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 4 })
+ fireEvent.pointerMove(scroller, { button: 0, clientX: 50, pointerId: 4 })
+ fireEvent.pointerUp(scroller, { pointerId: 4 })
+ expect(scroller.scrollLeft).toBe(0)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('suppresses child click after a drag gesture', () => {
+ const onClick = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const scroller = getScroller(container)
+ mockScrollerOverflow(scroller)
+
+ fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 2 })
+ fireEvent.pointerMove(scroller, { button: 0, clientX: 100, pointerId: 2 })
+ fireEvent.pointerUp(scroller, { pointerId: 2 })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('allows child click when movement stays below the drag threshold', () => {
+ const onClick = vi.fn()
+ const { container } = render(
+ ,
+ )
+ const scroller = getScroller(container)
+ mockScrollerOverflow(scroller)
+
+ const startX = 80
+ fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 3 })
+ fireEvent.pointerMove(scroller, {
+ button: 0,
+ clientX: startX - HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 2,
+ pointerId: 3,
+ })
+ fireEvent.pointerUp(scroller, { pointerId: 3 })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts
index 74c560cc..9fca5228 100644
--- a/tests/unit/codebaseConventions.unit.spec.ts
+++ b/tests/unit/codebaseConventions.unit.spec.ts
@@ -565,7 +565,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/electionCandidateOptions.ts', 1],
['src/utilities/territory/loadTerritoryOverview.ts', 1],
['src/utilities/municipality/municipalityCandidateComparison.ts', 2],
- ['src/utilities/municipality/municipalityElectoralBaseline.ts', 9],
+ ['src/utilities/municipality/municipalityElectoralBaseline.ts', 8],
['src/utilities/onda0Provision.ts', 4],
['src/utilities/stateDeputyData.ts', 1],
['src/utilities/visit/visitPlannerData.ts', 1],