B75: wizard mobile header chrome on Mandate Red bar.

Publish flow navigation from CampaignWizardShell via layout-level chrome so wizards no longer stack a second sticky header under the app bar.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 03:25:12 -03:00
parent c10fcc8ce4
commit 037785b280
22 changed files with 708 additions and 121 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.
- **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`.
- **B77 registrado (2026-07-30):** simplificar wizard ajuste de votos — três cenários numa tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm com highlight; substitui ritual `?cenario=` de B61/B76. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md).
- **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md).

View file

@ -1,6 +1,6 @@
# Header mobile do wizard (chrome no Mandate Red)
Status: rascunho
Status: entregue (2026-07-30)
Atualizado em: 2026-07-30
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — **B75**; chassis UX-1 / emenda a B59)
Impeccable: C — chrome novo no shell `(app)` durante `/campanha/acoes/*`; brief abaixo
@ -132,4 +132,12 @@ Componentes:
- `PRODUCT.md` / `DESIGN.md` — Mandate Red / Field Desk
- AGENTS.md — naming; sem Consent novo
## As-built (2026-07-30)
- `CampaignWizardChromeProvider` envolve o inset em `(app)/layout.tsx`; `CampaignMobileTopBar` troca modo app (sidebar+marca) ↔ modo wizard quando o shell publica chrome.
- `CampaignWizardShell` chama `useSetCampaignWizardChrome` (cleanup no unmount); mobile sem `<header>` sticky branco; desktop mantém caption de município no `<main>` (`md:block`).
- `wizardFlowTitleForSlug` deriva o título do catálogo B45 via `campaignWizardActionIdForSlug` + `wizardFlowTitleForActionId`.
- Skip encadeado (B63/B64/B70) usa prop `skip` no shell — slot direito do header no mobile; desktop adiado (débito no plano).
- Testes: unit `campaignMobileTopBar`, `campaignWizardShell`; e2e smoke `campaignWizardChrome.e2e.spec.ts`; prewarm `/campanha/acoes/atualizar-votos` no `setup.e2e`.
Qualidade de decisão: 4/5

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; **B77** simplificar ajuste votos; **B74** gap strip v2; **B75** header wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B70/B77/B78.
Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; **B77** simplificar ajuste votos; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B70/B77/B78.
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,7 +83,7 @@ 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) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B75** (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓ → **B77** (ajuste votos numa tela) /**B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → **B70** (A4 liderança).
- **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**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B75**~~ ✓ (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓ → **B77** (ajuste votos numa tela) /**B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → **B70** (A4 liderança).
- ~~**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)
@ -93,7 +93,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B60** ~~Etapa busca município nos wizards~~ — **entregue 2026-07-29** (`WizardMunicipalitySearchStep`; `searchStaffMunicipalityHits` + modo `wizard-municipality` no `POST /campanha/home-search`; select → `?municipio=<slug>`; stub B61 com caption sticky; chips recentes adiados — sem `recentVisits` no repo) · [plano](plans/busca-municipio-wizard.md)
- **B61** ~~Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning)~~ — **entregue 2026-07-29** (`WizardExpectedVotesStep`; `?cenario=`; batch POST expected-votes ao concluir otimista; stub B60 substituído só em `atualizar-votos`) · [plano](plans/ajuste-votos-wizard.md) · ~~regressão jump+warning → B76~~ ✓
- **B76** ~~Corrigir coerência do wizard de ajuste de votos~~ — **entregue 2026-07-30** (`useEffect` preserva `violationEditedScenario` no jump ao cenário violado; limpa ao editar draft/atalho; unit `wizardExpectedVotesStep.unit.spec.ts`) · Depende de B61 ✓ · [plano](plans/corrigir-coerencia-wizard-ajuste-votos.md) · UX de três passos → **B77**
- **B77** Simplificar wizard ajuste de votos — três cenários na mesma tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm (warning + highlight, sem `?cenario=` jump) · Depende de B61 ✓ / B76 ✓ · soft B75 · ~0,75–1d · Janela 1 · Paralelizável com B63/B64 · Impeccable B · [plano](plans/simplificar-ajuste-votos-wizard.md)
- **B77** Simplificar wizard ajuste de votos — três cenários na mesma tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm (warning + highlight, sem `?cenario=` jump) · Depende de B61 ✓ / B76 ✓ · soft ~~B75~~ ✓ · ~0,75–1d · Janela 1 · Paralelizável com B63/B64 · Impeccable B · [plano](plans/simplificar-ajuste-votos-wizard.md)
- ~~**B62** Simplificar modelo de sinal~~ — **entregue 2026-07-29** (sinal = tipo + texto; migration drop `signal_source`/`triangulated`; labels mesa sem "broker" — `Rede esfriou`/`Adversário apareceu`/`Alguém pediu algo`; E11 sem dimensão triangulado: presença = confirmado, **triage 1**; forms/lista/detalhe/dossiê alinhados; e2e stale-B43 corrigido de bônus) · [plano](plans/simplificar-modelo-sinal.md)
- **B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal) · Depende de B59 + B62 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md)
- **B64** Wizard mudar tendência (grid colorido ≠ atual + Drawer info + justificativa com prefill/Limpar/Salvar + “Pular” sempre) · Depende de B59 · soft B60 · soft B24 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-mudar-tendencia.md)
@ -109,8 +109,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B72** ~~Reduzir espaçamento horizontal da strip de ações (`gap-6` → `gap-4`)~~ — **entregue 2026-07-29** (`CampaignHomeActionStrip` `<ul>` `gap-4`; botões `w-[4.75rem]` e pan B67 ✓ inalterados) · Depende de B58 ✓ / B67 ✓ (soft) · ~0,25d · Janela 1 · Impeccable B · [plano](plans/espacamento-strip-acoes-inicio.md)
- **B73** Remover bottom navigation bar no mobile — paradigma UX-1 intenção-primeiro: apagar `CampaignBottomNav` + `getCampaignBottomNav`; sidebar Sheet (fechada por padrão, B38 ✓) como única nav estrutural; ajustar `pb-24` do scroll global; revalidar dock do Início (B65 ✓) · Depende de B43 ✓ · soft B65 ✓ / B38 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B66 / B71 / B74 · Impeccable B · desbloqueia **B79** · [plano](plans/remover-bottom-nav-mobile.md)
- **B74** Apertar mais o espaçamento da strip de ações (`gap-4` → `gap-3`, critique pode `gap-2`) — pós-B72 ainda largo no mobile · Depende de B72 ✓ · soft B67 ✓ · ~0,25d · Janela 1 · Paralelizável com B73 / B66 / B71 · Impeccable B · [plano](plans/espacamento-strip-acoes-inicio-v2.md)
- **B75** Header mobile do wizard — durante `/campanha/acoes/*`, o Mandate Red substitui sidebar+marca: título = fluxo (“Ajustar votos”), subtítulo = município; 1ª etapa só X; 2ª+ Voltar+X; encadeado X→“Pular &lt;fluxo&gt;”; context no `(app)` + shell sem sticky branco no mobile · Depende de B59 ✓ · soft B60 ✓ / B45 ✓ · ~1–1,25d · Janela 1 · Paralelizável com B61/B63/B64 (eles consomem o chrome) · Impeccable C · [plano](plans/header-mobile-wizard-campanha.md)
- **B78** Indicação de carregamento nas ações de navegação do wizard — `CampaignListPendingBoundary` no layout `acoes/`; Voltar/X/Pular/seleção de município dentro de `useCampaignListTransition`; corpo do passo dima (`CampaignListResults`); soft shimmer nos slots título/subtítulo do header Mandate Red (**B75**) · Depende de B59 ✓ · soft B75 / B60 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B77/B63/B64 · Impeccable B · [plano](plans/indicacao-carregamento-navegacao-wizard.md)
- ~~**B75** Header mobile do wizard~~ — **entregue 2026-07-30** (`CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` sem flash app-mode entre passos; `wizardFlowTitleForSlug`; entry=X / continue=Voltar+X / skip substitui X; e2e `campaignWizardChrome`) · Depende de B59 ✓ · [plano](plans/header-mobile-wizard-campanha.md)
- **B78** Indicação de carregamento nas ações de navegação do wizard — `CampaignListPendingBoundary` no layout `acoes/`; Voltar/X/Pular/seleção de município dentro de `useCampaignListTransition`; corpo do passo dima (`CampaignListResults`); soft shimmer nos slots título/subtítulo do header Mandate Red (**B75** ✓) · Depende de B59 ✓ · soft ~~B75~~ ✓ / B60 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B77/B63/B64 · Impeccable B · [plano](plans/indicacao-carregamento-navegacao-wizard.md)
- **B79** Chassis do bottom drawer de ações rápidas + busca global (mobile, não-modal, drag/snap; fora do Início e de `/acoes/*`) — preenche o buraco pós-**B73** · Depende de B73 · soft B47 ✓ / B45 ✓ / B60 ✓ · ~1,5–2d · Janela 1 · Impeccable C · **não cortável** · [plano](plans/chassis-bottom-drawer-acoes-rapidas.md)
- **B80** Ações rápidas — Municípios (lista + detalhe; detalhe = A1–A5 com `?municipio=` / skip B60) · Depende de B79 · soft B45 ✓ / B60 ✓ · ~0,5–0,75d · Janela 1 · Impeccable B · **não cortável** · [plano](plans/acoes-rapidas-municipios.md)
- **B81** Ações rápidas — Territórios · Depende de B79 · soft B21 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · **não cortável** · [plano](plans/acoes-rapidas-territorios.md)
@ -530,11 +530,11 @@ flowchart TD
B8F2 -.rank por ZE = débito aberto.-> B14n
```
Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloqueia B79**); **B74** (gap strip v2 — após ~~B72~~ ✓; paralelo a B73); **B75** (header mobile wizard — após ~~B59~~ ✓; soft nos wizards restantes B63/B64/B70/B77); **B78** (pending navegação wizard — após ~~B59~~ ✓; soft B75 para shimmer no header; paralelo a B77/B63/B64); **B77** (simplificar ajuste votos — após ~~B61~~ ✓/~~B76~~ ✓; paralelo a B63/B64); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B79**: **B80–B90** entre si (catálogos por vertical; todos dependem só do chassis). Após **B59**: ~~**B60**~~ ✓ ∥ ~~**B61**~~ ✓ ∥ **B64** (B75 soft). **B63** espera B59+B62 ✓. **B70** espera ~~B59+B69~~ ✓ (+ soft B75). 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: **B73** (remover bottom nav — paralelo a B74; **desbloqueia B79**); **B74** (gap strip v2 — após ~~B72~~ ✓; paralelo a B73); ~~**B75**~~ ✓ (header mobile wizard); **B78** (pending navegação wizard — após ~~B59~~ ✓; soft ~~B75~~ ✓ para shimmer no header; paralelo a B77/B63/B64); **B77** (simplificar ajuste votos — após ~~B61~~ ✓/~~B76~~ ✓; paralelo a B63/B64); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B79**: **B80–B90** entre si (catálogos por vertical; todos dependem só do chassis). Após **B59**: ~~**B60**~~ ✓ ∥ ~~**B61**~~ ✓ ∥ **B64** (~~B75~~ ✓). **B63** espera B59+B62 ✓. **B70** espera ~~B59+B69~~ ✓ (+ soft ~~B75~~ ✓). 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~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→ B79** (drawer ações+busca) **→ B80–B90** (catálogos; ∥ entre si) **→** **wizards ~~B59~~ ✓ → B75** (header mobile) **→ B78** (pending navegação) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓ → **B77** (ajuste votos 1 tela) ∥B64 · ~~B62~~ ✓ → B63 · ~~B69~~ ✓ → B70 (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 = chrome mobile B73/B79–B90 + polish Início B74 + busca B49+ + wizards B77/B78/B63–B64 + A4 ~~B69~~ ✓/B70.
**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~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→ B79** (drawer ações+busca) **→ B80–B90** (catálogos; ∥ entre si) **→** **wizards ~~B59~~ ✓ → ~~B75~~ ✓** (header mobile) **→ B78** (pending navegação) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓ → **B77** (ajuste votos 1 tela) ∥B64 · ~~B62~~ ✓ → B63 · ~~B69~~ ✓ → B70 (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 = chrome mobile B73/B79–B90 + polish Início B74 + busca B49+ + wizards B77/B78/B63–B64 + A4 ~~B69~~ ✓/B70.
**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.

View file

@ -5,5 +5,9 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
export default async function CampaignActionsLayout({ children }: { children: ReactNode }) {
await requireCampaignPageActor({ gate: 'staff' })
return <div className="mx-auto flex min-h-full w-full max-w-lg flex-col">{children}</div>
return (
<div className="mx-auto flex min-h-full w-full max-w-lg flex-col" data-wizard-route>
{children}
</div>
)
}

View file

@ -9,8 +9,10 @@ import {
type BiometricEnrollmentOffer,
} from '@/components/campaign/shell/BiometricEnrollmentToast'
import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
import {
SIDEBAR_COOKIE_NAME,
@ -69,28 +71,22 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden">
<SidebarTrigger className="text-primary-foreground" />
<div className="min-w-0 leading-tight">
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
<span className="block truncate text-xs text-primary-foreground/80">
Campanha · Bahia
</span>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
</header>
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0"
>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</div>
</header>
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
</header>
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0"
>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</div>
<CampaignBottomNav role={user.role} />
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
<CampaignBottomNav role={user.role} />
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</CampaignWizardChromeProvider>
</SidebarInset>
</SidebarProvider>
)

View file

@ -1,25 +1,36 @@
'use client'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useId, useRef, type ReactNode } from 'react'
import { useEffect, useId, useMemo, useRef, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import {
toCampaignWizardChromeState,
useSetCampaignWizardChrome,
type CampaignWizardChromeSkip,
} from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { cn } from '@/lib/utils'
export type CampaignWizardShellProps = {
flowTitle: string
stepTitle: string
isEntryStep: boolean
previousHref: string
dismissHref?: string
municipalityLabel?: string
skip?: CampaignWizardChromeSkip
trailingAction?: ReactNode
contentAlign?: 'start' | 'end'
children: ReactNode
}
export const CampaignWizardShell = ({
flowTitle,
stepTitle,
isEntryStep,
previousHref,
dismissHref = CAMPAIGN_HOME,
municipalityLabel,
skip,
trailingAction,
contentAlign = 'start',
children,
@ -27,40 +38,28 @@ export const CampaignWizardShell = ({
const titleRef = useRef<HTMLHeadingElement>(null)
const titleId = useId()
const chrome = useMemo(
() =>
toCampaignWizardChromeState({
flowTitle,
isEntryStep,
previousHref,
dismissHref,
municipalityLabel,
skip,
}),
[dismissHref, flowTitle, isEntryStep, municipalityLabel, previousHref, skip],
)
useSetCampaignWizardChrome(chrome)
useEffect(() => {
titleRef.current?.focus()
}, [stepTitle])
return (
<div className="flex min-h-full w-full flex-col">
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="relative flex min-h-11 items-center justify-center gap-2 px-2 pb-1 pt-[max(0.25rem,env(safe-area-inset-top))]">
<div
className="absolute left-0 top-1/2 -translate-y-1/2 md:hidden"
data-slot="wizard-mobile-back"
>
<Button variant="ghost" size="sm" className="min-h-11 gap-1 px-2" asChild>
<Link href={previousHref}>
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</Link>
</Button>
</div>
{municipalityLabel ? (
<p
className="max-w-[min(100%,14rem)] truncate px-12 text-center text-sm text-muted-foreground sm:max-w-md"
aria-label={`Município em atualização: ${municipalityLabel}`}
>
{municipalityLabel}
</p>
) : null}
{trailingAction ? (
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center">
{trailingAction}
</div>
) : null}
</div>
</header>
{trailingAction ? <div className="hidden justify-end md:flex">{trailingAction}</div> : null}
<main
aria-labelledby={titleId}
@ -69,6 +68,11 @@ export const CampaignWizardShell = ({
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
{municipalityLabel ? (
<p className="hidden max-w-prose truncate text-sm text-muted-foreground md:block">
{municipalityLabel}
</p>
) : null}
<h1
ref={titleRef}
id={titleId}

View file

@ -21,6 +21,7 @@ import {
WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER,
WIZARD_VOTES_SAVE_ERROR_MESSAGE,
WIZARD_VOTES_SAVED_MESSAGE,
wizardFlowTitleForSlug,
wizardVoteReturnToScenarioLabel,
} from '@/lib/campaignWizardCopy'
import { formatElectionNumber } from '@/lib/electionFormat'
@ -181,8 +182,11 @@ export const WizardExpectedVotesStep = ({
if (phase === 'saved') {
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle="Votos atualizados"
isEntryStep={false}
previousHref={CAMPAIGN_HOME}
dismissHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
>
<div className="flex flex-col gap-4" role="status" aria-live="polite">
@ -198,8 +202,11 @@ export const WizardExpectedVotesStep = ({
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardVoteStepTitle(currentScenario)}
isEntryStep={false}
previousHref={previousHref}
dismissHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
>
<div

View file

@ -19,6 +19,7 @@ import {
WIZARD_MUNICIPALITY_SEARCH_LABEL,
WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER,
WIZARD_MUNICIPALITY_STEP_TITLE,
wizardFlowTitleForSlug,
} from '@/lib/campaignWizardCopy'
import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch'
@ -86,7 +87,13 @@ export const WizardMunicipalitySearchStep = ({
query.isActive && results.status === 'success' && results.municipalities.length === 0
return (
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref={previousHref}>
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE}
isEntryStep
previousHref={previousHref}
dismissHref={previousHref}
>
<div className="flex flex-col gap-4">
<CampaignSearchInput
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}

View file

@ -1,6 +1,11 @@
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { wizardNextStepPlaceholder, wizardNextStepTitle } from '@/lib/campaignWizardCopy'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
wizardFlowTitleForSlug,
wizardNextStepPlaceholder,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
type WizardMunicipalitySelectedStubProps = {
actionSlug: string
@ -12,8 +17,11 @@ export const WizardMunicipalitySelectedStub = ({
municipalityName,
}: WizardMunicipalitySelectedStubProps) => (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>

View file

@ -0,0 +1,107 @@
'use client'
import { ArrowLeft, X } from 'lucide-react'
import Link from 'next/link'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { Button } from '@/components/ui/button'
import { SidebarTrigger } from '@/components/ui/Sidebar'
import {
WIZARD_APP_TOP_BAR_ARIA_LABEL,
WIZARD_DISMISS_ARIA_LABEL,
wizardFlowChromeAriaLabel,
wizardMunicipalityChromeAriaLabel,
} from '@/lib/campaignWizardCopy'
import { cn } from '@/lib/utils'
export const CampaignMobileTopBar = () => {
const chrome = useCampaignWizardChrome()
if (chrome) {
return (
<header
data-slot="campaign-mobile-top-bar"
data-mode="wizard"
aria-label={wizardFlowChromeAriaLabel(chrome.flowTitle)}
className={cn(
'flex min-h-14 shrink-0 items-center gap-2 bg-primary px-2 text-primary-foreground md:hidden print:hidden',
'pt-[max(0px,env(safe-area-inset-top))]',
)}
>
<div className="flex min-w-0 flex-1 items-center">
{chrome.stepKind === 'continue' && chrome.previousHref ? (
<Button
variant="ghost"
size="sm"
className="min-h-11 shrink-0 gap-1 px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
asChild
>
<Link href={chrome.previousHref}>
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</Link>
</Button>
) : (
<div className="size-11 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1 px-1 text-center leading-tight">
<span className="block truncate text-sm font-semibold">{chrome.flowTitle}</span>
{chrome.municipalityLabel ? (
<span
className="block truncate text-xs text-primary-foreground/80"
aria-label={wizardMunicipalityChromeAriaLabel(chrome.municipalityLabel)}
>
{chrome.municipalityLabel}
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center">
{chrome.skip ? (
<Button
variant="ghost"
size="sm"
className="min-h-11 max-w-[9rem] px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
asChild
>
<Link href={chrome.skip.href} title={chrome.skip.label}>
{chrome.skip.label}
</Link>
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
asChild
>
<Link href={chrome.dismissHref} aria-label={WIZARD_DISMISS_ARIA_LABEL}>
<X className="size-5" aria-hidden />
</Link>
</Button>
)}
</div>
</header>
)
}
return (
<header
data-slot="campaign-mobile-top-bar"
data-mode="app"
aria-label={WIZARD_APP_TOP_BAR_ARIA_LABEL}
className={cn(
'flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden',
'pt-[max(0px,env(safe-area-inset-top))]',
)}
>
<SidebarTrigger className="text-primary-foreground" />
<div className="min-w-0 leading-tight">
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
<span className="block truncate text-xs text-primary-foreground/80">Campanha · Bahia</span>
</div>
</header>
)
}

View file

@ -0,0 +1,95 @@
'use client'
import {
createContext,
useContext,
useLayoutEffect,
useMemo,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from 'react'
export type CampaignWizardChromeSkip = {
label: string
href: string
}
export type CampaignWizardChromeState = {
flowTitle: string
municipalityLabel?: string
stepKind: 'entry' | 'continue'
previousHref?: string
dismissHref: string
skip?: CampaignWizardChromeSkip
}
type CampaignWizardChromeContextValue = {
chrome: CampaignWizardChromeState | null
setChrome: Dispatch<SetStateAction<CampaignWizardChromeState | null>>
}
const CampaignWizardChromeContext = createContext<CampaignWizardChromeContextValue | null>(null)
export const CampaignWizardChromeProvider = ({ children }: { children: ReactNode }) => {
const [chrome, setChrome] = useState<CampaignWizardChromeState | null>(null)
const value = useMemo(() => ({ chrome, setChrome }), [chrome])
return (
<CampaignWizardChromeContext.Provider value={value}>
{children}
</CampaignWizardChromeContext.Provider>
)
}
const useCampaignWizardChromeContext = (): CampaignWizardChromeContextValue => {
const value = useContext(CampaignWizardChromeContext)
if (!value) {
throw new Error('CampaignWizardChromeProvider is required')
}
return value
}
export const useCampaignWizardChrome = (): CampaignWizardChromeState | null =>
useCampaignWizardChromeContext().chrome
type CampaignWizardChromeInput = {
flowTitle: string
isEntryStep: boolean
previousHref: string
dismissHref: string
municipalityLabel?: string
skip?: CampaignWizardChromeSkip
}
export const toCampaignWizardChromeState = ({
flowTitle,
isEntryStep,
previousHref,
dismissHref,
municipalityLabel,
skip,
}: CampaignWizardChromeInput): CampaignWizardChromeState => ({
flowTitle,
municipalityLabel,
stepKind: isEntryStep ? 'entry' : 'continue',
previousHref: isEntryStep ? undefined : previousHref,
dismissHref,
skip,
})
export const useSetCampaignWizardChrome = (state: CampaignWizardChromeState | null): void => {
const { setChrome } = useCampaignWizardChromeContext()
useLayoutEffect(() => {
setChrome(state)
}, [setChrome, state])
useLayoutEffect(
() => () => {
setChrome(null)
},
[setChrome],
)
}

View file

@ -22,8 +22,18 @@ export const CAMPAIGN_WIZARD_ACTION_SLUGS: Record<CampaignWizardActionId, string
const wizardActionSlugs = new Set(Object.values(CAMPAIGN_WIZARD_ACTION_SLUGS))
const campaignWizardSlugToId = Object.fromEntries(
Object.entries(CAMPAIGN_WIZARD_ACTION_SLUGS).map(([id, actionSlug]) => [
actionSlug,
id as CampaignWizardActionId,
]),
) as Record<string, CampaignWizardActionId>
export const isCampaignWizardActionSlug = (slug: string): boolean => wizardActionSlugs.has(slug)
export const campaignWizardActionIdForSlug = (slug: string): CampaignWizardActionId | undefined =>
campaignWizardSlugToId[slug]
export const isCampaignWizardActionId = (id: string): id is CampaignWizardActionId =>
id in CAMPAIGN_WIZARD_ACTION_SLUGS

View file

@ -10,7 +10,11 @@ import {
Users,
} from 'lucide-react'
import { campaignActionEntryHref, isCampaignWizardActionId } from '@/lib/campaignActionRoutes'
import {
campaignActionEntryHref,
isCampaignWizardActionId,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
@ -104,6 +108,11 @@ const leaderHomeActions: readonly CampaignHomeAction[] = [
},
]
export const wizardFlowTitleForActionId = (id: CampaignWizardActionId): string => {
const action = staffHomeActionsCoordinator.find((entry) => entry.id === id)
return action?.label ?? 'Continuar'
}
export const homeActionsForRole = (role: CampaignRole): readonly CampaignHomeAction[] => {
if (role === 'leader') return leaderHomeActions
if (!isStaffCampaignRole(role)) return []

View file

@ -1,8 +1,25 @@
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignWizardActionIdForSlug,
} from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForActionId } from '@/lib/campaignHomeActions'
/** Shared copy for UX-1 wizard steps (B60 municipality search, B61 vote adjustment). */
export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const
export const WIZARD_DISMISS_ARIA_LABEL = 'Sair da ação' as const
export const WIZARD_APP_TOP_BAR_ARIA_LABEL = 'Campanha Jorge Solla' as const
export const wizardMunicipalityChromeAriaLabel = (municipalityLabel: string): string =>
`Município em atualização: ${municipalityLabel}`
export const wizardFlowChromeAriaLabel = (flowTitle: string): string => `Ação: ${flowTitle}`
export const wizardFlowTitleForSlug = (actionSlug: string): string => {
const actionId = campaignWizardActionIdForSlug(actionSlug)
return actionId ? wizardFlowTitleForActionId(actionId) : 'Continuar'
}
export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const
export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as const

View file

@ -0,0 +1,55 @@
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
test.use({ viewport: { width: 390, height: 844 } })
test.describe('Wizard — header mobile (B75)', () => {
test('entry step shows wizard flow title and dismiss without sidebar trigger', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
await expect(topBar).toBeVisible()
await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await expect(page.getByRole('link', { name: 'Sair da ação' })).toBeVisible()
await expect(page.getByRole('link', { name: /Voltar/ })).toHaveCount(0)
await page.getByRole('link', { name: 'Sair da ação' }).click()
await page.waitForURL(/\/campanha\/?$/)
})
test('continue step shows back control and municipality in wizard header', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
timeout: 15000,
})
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible()
await expect(topBar.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
await expect(page.getByRole('link', { name: /Voltar/ })).toBeVisible()
await page.getByRole('link', { name: /Voltar/ }).click()
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\/?$/)
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
})
})

View file

@ -15,6 +15,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/demandas',
'/campanha/liderancas',
'/campanha/conceitos',
'/campanha/acoes/atualizar-votos',
'/campanha/convite/e2e-prewarm',
'/',
]) {

View file

@ -0,0 +1,17 @@
import { vi } from 'vitest'
export const stubMatchMedia = (matches = false): void => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
)
}

View file

@ -4,6 +4,7 @@ import {
CAMPAIGN_ACTIONS_HOME,
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignActionEntryHref,
campaignWizardActionIdForSlug,
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
@ -55,4 +56,9 @@ describe('campaignActionRoutes', () => {
expect(parseWizardMunicipioParam(undefined)).toBeUndefined()
expect(parseWizardMunicipioParam(' ')).toBeUndefined()
})
it('resolves wizard action id from slug', () => {
expect(campaignWizardActionIdForSlug('atualizar-votos')).toBe('update-votes')
expect(campaignWizardActionIdForSlug('inventado')).toBeUndefined()
})
})

View file

@ -0,0 +1,146 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import {
CampaignWizardChromeProvider,
useSetCampaignWizardChrome,
type CampaignWizardChromeState,
} from '@/components/campaign/shell/CampaignWizardChromeContext'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { stubMatchMedia } from '../helpers/matchMedia'
const ChromeProbe = ({ chrome }: { chrome: CampaignWizardChromeState | null }) => {
useSetCampaignWizardChrome(chrome)
return null
}
const renderTopBar = (chrome: CampaignWizardChromeState | null) =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe chrome={chrome} />
<CampaignMobileTopBar />
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
const renderWizardTopBar = (chrome: CampaignWizardChromeState) =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe chrome={chrome} />
<CampaignMobileTopBar />
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
describe('CampaignMobileTopBar', () => {
beforeEach(() => {
stubMatchMedia()
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
it('renders app mode with sidebar trigger when chrome is null', () => {
const { container } = renderTopBar(null)
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('app')
expect(screen.getByText('Jorge Solla')).toBeTruthy()
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
})
it('renders wizard entry mode with dismiss control and flow title', () => {
renderWizardTopBar({
flowTitle: 'Ajustar votos',
stepKind: 'entry',
dismissHref: '/campanha',
})
expect(screen.getByText('Ajustar votos')).toBeTruthy()
expect(screen.getByRole('link', { name: 'Sair da ação' }).getAttribute('href')).toBe(
'/campanha',
)
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
})
it('renders wizard continue mode with back link and municipality subtitle', () => {
renderWizardTopBar({
flowTitle: 'Ajustar votos',
municipalityLabel: 'Cairu',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
})
expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe(
'/campanha/acoes/atualizar-votos',
)
expect(screen.getByLabelText('Município em atualização: Cairu').textContent).toBe('Cairu')
expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy()
})
it('renders skip link instead of dismiss when chained flow defines skip', () => {
renderWizardTopBar({
flowTitle: 'Registrar sinal',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu',
dismissHref: '/campanha',
skip: {
label: 'Pular registro de sinal',
href: '/campanha/acoes/mudar-tendencia?municipio=cairu',
},
})
expect(screen.getByRole('link', { name: 'Pular registro de sinal' }).getAttribute('href')).toBe(
'/campanha/acoes/mudar-tendencia?municipio=cairu',
)
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
})
it('keeps wizard mode when chrome updates between steps', () => {
const { rerender } = render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
stepKind: 'entry',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
expect(screen.queryByText('Jorge Solla')).toBeNull()
rerender(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
municipalityLabel: 'Cairu',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('wizard')
expect(screen.queryByText('Jorge Solla')).toBeNull()
expect(screen.getByLabelText('Município em atualização: Cairu')).toBeTruthy()
})
})

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import {
WIZARD_DISMISS_ARIA_LABEL,
wizardFlowChromeAriaLabel,
wizardFlowTitleForSlug,
wizardMunicipalityChromeAriaLabel,
} from '@/lib/campaignWizardCopy'
describe('campaignWizardCopy', () => {
it('maps wizard slugs to home action flow titles', () => {
expect(wizardFlowTitleForSlug('atualizar-votos')).toBe('Ajustar votos')
expect(wizardFlowTitleForSlug('registrar-sinal')).toBe('Registrar sinal')
expect(wizardFlowTitleForSlug('inventado')).toBe('Continuar')
})
it('builds wizard chrome aria labels from shared copy', () => {
expect(wizardFlowChromeAriaLabel('Ajustar votos')).toBe('Ação: Ajustar votos')
expect(wizardMunicipalityChromeAriaLabel('Cairu')).toBe('Município em atualização: Cairu')
expect(WIZARD_DISMISS_ARIA_LABEL).toBe('Sair da ação')
})
})

View file

@ -1,33 +1,78 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '@/lib/campaignWizardCopy'
import { stubMatchMedia } from '../helpers/matchMedia'
const renderWizardShell = (props: React.ComponentProps<typeof CampaignWizardShell>) =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell {...props} />
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
describe('CampaignWizardShell', () => {
afterEach(() => {
cleanup()
beforeEach(() => {
stubMatchMedia()
})
it('renders mobile back link with visible Voltar label', () => {
const { container } = render(
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref="/campanha">
Corpo
</CampaignWizardShell>,
)
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const back = screen.getByRole('link', { name: /Voltar/ })
expect(back.getAttribute('href')).toBe('/campanha')
const mobileBackSlot = container.querySelector('[data-slot="wizard-mobile-back"]')
expect(mobileBackSlot?.contains(back)).toBe(true)
it('registers wizard chrome on the mobile top bar for continue steps', () => {
renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: 'Quantos votos?',
isEntryStep: false,
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
municipalityLabel: 'Cairu',
children: 'Corpo',
})
expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe(
'/campanha/acoes/atualizar-votos',
)
const topBar = document.querySelector(
'[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]',
)
expect(topBar).toBeTruthy()
expect(
within(topBar as HTMLElement).getByLabelText('Município em atualização: Cairu'),
).toBeTruthy()
})
it('registers entry chrome without back control', () => {
renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: WIZARD_MUNICIPALITY_STEP_TITLE,
isEntryStep: true,
previousHref: '/campanha',
dismissHref: '/campanha',
children: 'Corpo',
})
expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy()
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
})
it('labels main landmark with the step title', () => {
const { container } = render(
<CampaignWizardShell stepTitle="Pergunta" previousHref="/campanha">
Corpo
</CampaignWizardShell>,
)
const { container } = renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: 'Pergunta',
isEntryStep: true,
previousHref: '/campanha',
children: 'Corpo',
})
const main = within(container).getByRole('main')
const heading = within(container).getByRole('heading', { level: 1, name: 'Pergunta' })
@ -36,42 +81,52 @@ describe('CampaignWizardShell', () => {
it('moves focus to the step title when stepTitle changes', () => {
const { rerender } = render(
<CampaignWizardShell stepTitle="Passo 1" previousHref="/campanha">
Corpo
</CampaignWizardShell>,
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 1"
isEntryStep
previousHref="/campanha"
>
Corpo
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
rerender(
<CampaignWizardShell stepTitle="Passo 2" previousHref="/campanha">
Corpo
</CampaignWizardShell>,
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 2"
isEntryStep
previousHref="/campanha"
>
Corpo
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1, name: 'Passo 2' }))
})
it('omits municipality caption when label is absent', () => {
render(
<CampaignWizardShell stepTitle="Pergunta" previousHref="/campanha">
{null}
</CampaignWizardShell>,
)
it('shows municipality caption in main on desktop breakpoint classes', () => {
const { container } = renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: 'Pergunta',
isEntryStep: false,
previousHref: '/campanha/acoes/atualizar-votos',
municipalityLabel: 'Cairu',
children: null,
})
expect(screen.queryByLabelText(/Município em atualização/)).toBeNull()
})
it('shows municipality caption with accessible name', () => {
render(
<CampaignWizardShell
stepTitle="Pergunta"
previousHref="/campanha/acoes/atualizar-votos"
municipalityLabel="Cairu"
>
{null}
</CampaignWizardShell>,
)
const caption = screen.getByLabelText('Município em atualização: Cairu')
expect(caption.textContent).toBe('Cairu')
const caption = container.querySelector('main p')
expect(caption?.textContent).toBe('Cairu')
expect(caption?.className).toMatch(/md:block/)
})
})

View file

@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
const replace = vi.fn()
@ -29,6 +30,13 @@ const defaultProps = {
initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 },
}
const renderVotesStep = (props: React.ComponentProps<typeof WizardExpectedVotesStep>) =>
render(
<CampaignWizardChromeProvider>
<WizardExpectedVotesStep {...props} />
</CampaignWizardChromeProvider>,
)
describe('WizardExpectedVotesStep', () => {
afterEach(() => {
cleanup()
@ -36,16 +44,18 @@ describe('WizardExpectedVotesStep', () => {
})
it('shows violation banner on média immediately after pessimista breaks order', () => {
const { rerender } = render(
<WizardExpectedVotesStep {...defaultProps} currentScenario="pessimistic" />,
)
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
expect(replace).toHaveBeenCalledWith(expect.stringContaining('cenario=central'))
rerender(<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />)
rerender(
<CampaignWizardChromeProvider>
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
</CampaignWizardChromeProvider>,
)
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
expect(within(step).getByRole('alert').textContent).toMatch(/Pessimista/i)
@ -53,13 +63,15 @@ describe('WizardExpectedVotesStep', () => {
})
it('clears violation banner when média draft is edited', () => {
const { rerender } = render(
<WizardExpectedVotesStep {...defaultProps} currentScenario="pessimistic" />,
)
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
rerender(<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />)
rerender(
<CampaignWizardChromeProvider>
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
</CampaignWizardChromeProvider>,
)
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
expect(within(step).getByRole('alert')).toBeTruthy()