# Impl: Escala/DRY: chrome comum dos passos finais do wizard (pós-B195) Status: aprovado Atualizado em: 2026-08-11 Issue: #619 (D11) Intenção: docs/plans/escala-dry-pos-wizard-steps.md Appetite restante: herdado (~0,5–1 dia eng) — não estoura ## Leitura da intenção - **Outcome:** os 4 passos finais do wizard de `/campanha/acoes` param de duplicar (a) submit bar + pendência e (b) campos do cadastro de demanda, sem mudar comportamento, copy, toast/push/retorno ou a11y pinada por e2e. - **O que NÃO negociar:** copy intocada (`Salvando…`, sr-only de cada passo, labels de CTA, `WIZARD_*`/`CAMPAIGN_DEMAND_*` continuam onde estão e com os mesmos valores); e2e de foco/a11y verdes (`contentFocus`, aria-live, label do botão); não renomear arquivos de steps (histórico de PRs); não unificar os corpos dos steps; nada de migration/schema. - **O que reavaliar (hipóteses da intenção):** 1. `WizardStepFormChrome` como "form + pending chrome + submit bar" — o 4º step (`WizardExpectedVotesStep`) **não é form**: usa `useTransition` + `fetch` + CTA `type="button"`, sem `useActionState`. Forçá-lo a form mudaria comportamento (Enter passaria a salvar). A extração precisa de dois modos, não de um form só. 2. "Campos de demanda parametrizado por prefixo de id + `showMunicipality`" — a diferença entre os dois cadastros é maior que prefixo: município fixo (hidden input + search já vinculado) vs selecionável (gating do combobox, placeholder locked, reset de atividade, copy condicional, grid 2 colunas). ## Abordagem recomendada ```mermaid flowchart LR A[WizardStepFormChrome
shared/] --> B[WizardRegisterDemandStep] A --> C[WizardUpdateBodyStep] A --> D[WizardTrendNoteStep] A --> E[WizardExpectedVotesStep] F[DemandFields
demand/] --> B F --> G[DemandForm /demandas/nova] ``` **`WizardStepFormChrome`** — dono do chrome de passo final: wrapper (`
` ou `
`), `aria-busy`/`data-pending`, submit bar com CTA (Spinner + `Salvando…` + label), região sr-only `aria-live` com `pendingAnnouncement`. Dois modos **por união discriminada** (type-safe, sem `as`/`variant` genérico): `{ action }` (3 steps form) | `{ onCtaClick }` (votos, preserva `useTransition`/fetch). Props de forma: `leadingSubmit?` (Limpar do trend), `submitBarClassName?` (flex-wrap do trend), `ctaClassName?` (largura). **`DemandFields`** — dono dos campos do cadastro de demanda: Tipo (`NativeSelect`), Atividade (`AsyncSearchCombobox`), descrição (`Textarea`), erros via `fieldError`. Prop `municipality?` opcional: ausente = modo wizard (stacked, combobox sempre pronto, search já vinculado pelo passo); presente = modo `/demandas/nova` (grid 2 colunas Tipo+Município, gating `isQueryReady`/`queryTooShortMessage`, placeholder button locked, reset de atividade na troca de município, copy condicional da descrição). O gating/reset/copy condicional só disparam em modo município — no wizard o DOM sai idêntico ao de hoje. **Opções consideradas:** - **Chrome:** A) um componente com união discriminada `action | onCtaClick` — **recomendada**; B) só-form nos 3 steps e votos mantém o bloco duplicado — rejeitada: o débito do /simplify renasce no passo 4 com o mesmo conhecimento; C) `variant`/`as` genérico — rejeitada: mesma cobertura com API mais frouxa e modos não checados por tipo. - **Campos:** A) `DemandFields` com `municipality?` opcional — **recomendada**; B) campos comuns sem município + DemandForm mantém o select/gating próprios — rejeitada: o Field de atividade (combobox vs placeholder locked) é inseparável do estado do município; deixar metade fora re-duplica; C) DemandForm vira dono e o wizard o consome inteiro — rejeitada: layout (`max-w-2xl gap-4` vs `gap-6`), ids, `disabled={isPending}` e estado diferem; mudaria o DOM e o comportamento do wizard. ### Componentes / mudanças - **`WizardStepFormChrome`** (`src/components/campaign/shared/WizardStepFormChrome.tsx`): novo. Props comuns `isPending`, `pendingAnnouncement`, `ctaLabel`, `children`, `leadingSubmit?`, `submitBarClassName?`, `ctaClassName?`; união `{ action: CampaignFormAction } | { onCtaClick: () => void }`. Renderiza `` ou `
` com `className="flex flex-col gap-6"`, `aria-busy={isPending || undefined}`, `data-pending`; bar default `flex items-center justify-end`; CTA `min-h-11` + `min-w-[7rem]` default + `