teqo-backup/docs/plans/header-mobile-wizard-campanha.md
Francisco Ignácio Fontoura Solla 9e19f53715 docs: mark B75 delivered in roadmap after rebase merge.
Fold parallelizáveis/janela updates and simplify deferrals into the B75 plan.
2026-07-30 03:27:12 -03:00

14 KiB
Raw Permalink Blame History

Header mobile do wizard (chrome no Mandate Red)

Status: entregue (2026-07-30) Atualizado em: 2026-07-30 Item do roadmap: docs/roadmap.md (Trilha B — B75; chassis UX-1 / emenda a B59) Impeccable: C — chrome novo no shell (app) durante /campanha/acoes/*; brief abaixo Appetite: ~1–1,25d eng; provider + header condicional + shell sem chrome duplicado no mobile; sem migration Responsável: —

Design (Impeccable)

Âncoras: PRODUCT.md (Clarity under pressure; Feel the action) / DESIGN.md (Mandate Red mobile top bar) · tema data-theme='campaign'.

Na implementação (implement-roadmap-item): shape → craft → critique → polish.

Brief compacto:

  • Persona / contexto: CG/assessor no celular entre Zaps; entrou numa ação do Início; o header vermelho do app (sidebar + “Jorge Solla”) compete com Voltar + título do passo que hoje moram no conteúdo (CampaignWizardShell).
  • Job principal: durante o wizard, o header Mandate Red vira navegação do fluxo (sair / voltar / pular encadeado + título da ação + município), sem segundo sticky no scroll.
  • Estratégia de cor: Restrained — mesmo bg-primary / text-primary-foreground do header mobile atual; ícones/links herdam contraste do header (não inventar barra branca no topo).
  • Edit where you see: não — chrome de fluxo.
  • Anti-goals: dois headers sticky (app + shell); sidebar acessível no meio do ritual; X e “Pular” ambíguos; desktop ganhando chrome vermelho; Sheet full-screen no lugar de rota (B59).

Dados → decisão → apresentação

Dados: N/A — chrome; conteúdo numérico nas etapas B60–B64 / B70.

Contexto

B59 ✓ entregou CampaignWizardShell com:

  • sticky branco no topo do conteúdo: Voltar (md:hidden) | caption município | trailingAction;
  • h1 = pergunta do passo no <main>.

O layout (app) sempre renderiza acima disso o header vermelho mobile (SidebarTrigger + “Jorge Solla” / “Campanha · Bahia”) em layout.tsx. Em /campanha/acoes/<slug> o usuário vê dois chrome empilhados — o vermelho é irrelevante ao ritual e come viewport.

Pedido de produto (2026-07-30):

Estado Esquerda Centro (título / subtítulo) Direita
1ª etapa do fluxo — fluxo (ex. “Ajustar votos”); subtítulo município só depois de escolhido X (sair do wizard → Início / origem)
2ª+ etapa Voltar fluxo + município (se houver) X
Fluxo encadeado (ex. sinal depois de votos no A1) Voltar (se não for 1ª do subfluxo) título do subfluxo atual “Pular <fluxo>” no lugar do X

A pergunta do passo (“Em qual município?”, grid de tipos…) permanece no conteúdo (h1), não no header.

Skip de B63/B64/B70 (“Pular registro de sinal…”) migra para este slot direito no mobile quando a origem for encadeada; no entry standalone o direito é X.

Objetivos

  • Em viewport < md, rotas /campanha/acoes/*: substituir o header app (sidebar + marca) por header de wizard Mandate Red com a tabela acima.
  • Em md+: header slim atual (só SidebarTrigger) inalterado; shell pode manter caption de município discreta ou só no conteúdo — sem Voltar duplicado (browser). Recomendação: no desktop, sem barra vermelha de wizard; h1 + caption opcional no conteúdo como hoje.
  • Remover do CampaignWizardShell o sticky branco de Voltar/município/trailingAction no mobile (evita chrome duplo); no desktop, trailingAction de skip pode ficar no conteúdo até o wiring A1 — ou sumir se só mobile importa (recomendação: skip só no header mobile; desktop = link textual no conteúdo se B63+ precisar).
  • Provider/contrato tipado para o shell publicar chrome (flowTitle, municipalityLabel?, stepKind: 'entry' | 'continue', previousHref?, dismissHref, skip?: { label, href }).
  • a11y: X com nome “Fechar” / “Sair da ação”; Voltar com verbo visível; “Pular …” contém o nome do fluxo; foco no h1 do passo preservado (B59).
  • Guardrails: sem migration / Consent / action de escrita; print:hidden; staff gate de acoes/layout intacto.

Decisões travadas

  • Arquitetura: chrome condicional no layout (app) + context publicado pelo shell (não um segundo header solto, não portal ad-hoc). Extrair o header mobile atual para um componente client (CampaignMobileTopBar) que, se houver chrome de wizard ativo, renderiza o modo wizard; senão o modo app. CampaignWizardChromeProvider envolve só acoes/layout.tsx (ou o (app) inteiro com default null). O shell registra o chrome via useCampaignWizardChromeSet / efeito; cleanup no unmount. Rejeitado: A) layout route group paralelo (wizard) fora de (app) (duplica auth/sidebar/Toaster/PWA; caro de manter); B) portal createPortal no document sem contrato (frágil com RSC/scroll); C) só CSS hidden no header app sem substituir (some a affordance de sair); D) Sheet full-screen (B59 já rejeitou).
  • Título do header = nome da ação/fluxo (catálogo B45), não a pergunta do passo. Fonte: produto 2026-07-30. Rejeitado: repetir stepTitle no header (some hierarquia; B59 já separou pergunta × contexto).
  • Subtítulo = município só após escolha (B60). Ausente na 1ª etapa de busca. Rejeitado: slug cru; “Município” genérico.
  • 1ª etapa: só X à direita (sem Voltar). X → dismissHref (default Início). Rejeitado: Voltar na 1ª (= mesmo que X, confunde); hamburger + X.
  • 2ª+: Voltar à esquerda + X à direita. Voltar = previousHref (passo anterior). Rejeitado: só gesture edge; esconder X após a 1ª (usuário precisa abortar no meio).
  • Encadeado: X vira “Pular <fluxo>”. Quando skip está definido, o slot direito é o skip (não X+Pular). Fonte: produto 2026-07-30 + planos B63/B64/B70. Rejeitado: X e Pular juntos (dois escapes); Pular no conteúdo e X no header (split mental).
  • Sidebar inacessível durante o wizard mobile (trigger some com o chrome app). Sair = X ou concluir. Rejeitado: manter SidebarTrigger no canto (reabre domínio-primeiro no meio do ritual — alinhado a B73).
  • i18n: ids CampaignWizardChrome, flowTitle, dismissHref, skipLabel; copy pt-BR (“Pular registro de sinal”, nomes do catálogo).

Questões em aberto

  • Desktop: caption município no conteúdo ou só no header inexistente? Opções: A caption muted acima do h1 no md+ | B só contexto na URL/query. Recomendação: A — paridade com B59 desktop; header vermelho wizard é mobile-only. (assumido)
  • stepKind derivado de previousHref vs prop explícito? Opções: A prop isEntryStep | B previousHref == null ⇒ entry. Recomendação: A — Voltar e dismiss não são o mesmo href na 1ª (dismiss=Início; não há previous); entry ≠ “sem previous”. (assumido)

Abordagem proposta

flowchart TB
  AppLayout["(app)/layout.tsx"] --> TopBar["CampaignMobileTopBar"]
  TopBar -->|chrome null| AppMode["SidebarTrigger + marca"]
  TopBar -->|chrome set| WizMode["Voltar? + títulos + X|Pular"]
  AcoesLayout["acoes/layout Provider"] --> Shell["CampaignWizardShell"]
  Shell -->|setChrome| Ctx["CampaignWizardChromeContext"]
  Ctx --> TopBar
  Shell --> Main["h1 stepTitle + children"]

Componentes:

  • CampaignWizardChromeContext (src/components/campaign/shell/ ou shared/): tipo CampaignWizardChromeState | null; provider; useSetCampaignWizardChrome(state) com sync em useEffect + clear on unmount (evitar chrome órfão na navegação Início←ações).
  • CampaignMobileTopBar: client; lê context; modo app vs wizard; tokens bg-primary / foreground; min-h-14; safe-area top; print:hidden; md:hidden no modo app e no modo wizard (desktop inalterado).
  • (app)/layout.tsx: trocar o <header className="… bg-primary … md:hidden"> inline por <CampaignMobileTopBar /> (provider precisa ancestrar o top bar — subir o provider para (app)/layout com default null, ou nestar top bar dentro de um client shell que também envolve children). Preferência: client island CampaignAppChrome wrapping header+children slot pattern só o necessário; evitar marcar o layout RSC inteiro como client.
    • Padrão prático: SidebarInset recebe <CampaignWizardChromeProvider> no (app)/layout (provider leve) + CampaignMobileTopBar + {children} — provider no layout RSC via componente client que só passa children.
  • acoes/layout.tsx: sem mudança de gate; opcional data-wizard-route se útil a testes.
  • CampaignWizardShell: props novas flowTitle, isEntryStep, dismissHref, skip?: { label, href }; no mobile não renderiza sticky branco; chama setChrome; h1 + children permanecem; md+ pode mostrar caption município se municipalityLabel.
  • Copy de fluxo: reusar labels de campaignHomeActions.ts (ex. “Ajustar votos”) via mapa slug → flowTitle em campaignWizardCopy.ts ou campaignActionRoutes.ts — uma fonte, não string solta por página.
  • Testes: unit do top bar (entry / continue / skip); atualizar campaignWizardShell.unit.spec.tsx (back some do shell no mobile — testar via context ou data-slot no top bar); e2e smoke: abrir ação → header sem sidebar → X volta Início; 2ª etapa → Voltar.
  • Migration: Sem migration, sem collection, sem server action.

Dependências

  • Dura: B59 ✓ (shell + rotas /campanha/acoes). Soft: B60 ✓ (município no subtítulo); B45 ✓ (labels de fluxo); planos B63/B64/B70 (passam a alimentar skip no header em vez de trailingAction no sticky branco). Soft B73 (remove bottom nav — não bloqueia).
  • Desbloqueia polish mobile dos wizards B61/B63/B64/B70 (sem este item, cada um herda chrome quebrado).

Não escopo

  • Orquestração completa A1 “Quer também…?” (wiring de cadeia) — este item só hospeda o chrome quando a cadeia existir; a máquina de passos continua nos planos A1/B63+.
  • Remoção da bottom nav → B73. Gap da strip → B74.
  • Redesign do header desktop slim.
  • Persistência de rascunho (Adiado B59).

Rabbit holes

  • Route group (wizard) fora de (app). Explode auth/PWA/sidebar. Mitigação: context no inset.
  • State machine global de wizard. Mitigação: chrome burro; hrefs por etapa.
  • Animar troca app↔wizard header. Mitigação: swap imediato; motion só se critique pedir e com prefers-reduced-motion.

Adiado com gatilho

  • Skip no desktop no mesmo visual do header. Revisitar se B63+ shippar e a mesa reclamar que “Pular” sumiu no notebook (hoje browser + conteúdo bastam).
  • Deep-link “voltar ao Zap” com dismiss ≠ Início. Revisitar se entry points fora do Início (E11 → wizard) pedirem dismissHref dinâmico — o prop já existe; falta produto.

Referências

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 (useLayoutEffect para publish + cleanup só no unmount; /simplify corrigiu flash app-mode entre passos); 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.

Explicitamente fora (pós-/simplify, auto-confirm close-delivery)

  • Split read/write context do provider — defer até medição de re-render no inset ao trocar passo (setChrome hoje re-renderiza filhos do provider).
  • Mover provider só para acoes/layout — defer; arquitetura maior que o appetite do B75.
  • Mapa estático de títulos fora de campaignHomeActions — defer até bundle do wizard medir custo do import Lucide na cadeia wizardFlowTitleForSlug.
  • Skip no desktop — já adiado; link textual no conteúdo quando B63+ precisar.

Qualidade de decisão: 4/5