Merge pull request #144 from fsolla/cursor/0ce71e2d

B106–B108 — planos polish mobile busca/wizard
This commit is contained in:
Francisco Solla 2026-08-01 15:34:29 -03:00 committed by GitHub
commit 3babd78d73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 400 additions and 0 deletions

View file

@ -0,0 +1,133 @@
# Início mobile — Voltar no header + Android back + skeleton das sugestões
Status: registrado
Atualizado em: 2026-08-01
Issue: #141
Priority: P1
Model: composer-2.5
Impeccable: B — encaixe em `CampaignMobileTopBar` + chrome da busca do Início (`uiFocused`)
Appetite: ~0,75–1d eng; contexto de chrome + history layer + skeleton; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity under pressure; Feel the action) / `DESIGN.md` · B66 modo focado · B68 sugestões · tema `data-theme='campaign'`.
Na implementação (`work-issue`): craft compacto → critique → polish (Android WebView / Chrome back + iOS Safari).
Brief compacto:
- **Persona / contexto:** assessor/CG no Início mobile toca a busca; chrome retraí (B66); hoje o hamburger do header ainda sugere “abrir menu”, não “sair da busca”.
- **Job principal:** no modo focado, o canto esquerdo do header vira **Voltar** (fecha a expansão); o botão Voltar do Android faz o mesmo — não navega para a rota anterior; enquanto as sugestões carregam, a região mostra skeleton shimmer.
- **Estratégia de cor:** Restrained — ícone/botão ghost no Mandate Red (paridade wizard B75); skeleton `bg-muted` / `animate-pulse` do kit.
- **Edit where you see:** não — chrome de descoberta.
- **Anti-goals:** teleportar o input para o header; Sheet full-screen; aplicar o mesmo history layer ao bottom drawer (B91/B105) neste item; redesenhar sugestões.
### Wireframe (texto)
```text
┌─ header app (Mandate Red) — busca NÃO focada ───────┐
│ [☰] Jorge Solla / Campanha · Bahia [🔔] │
└─────────────────────────────────────────────────────┘
┌─ header app — busca focada (uiFocused) ─────────────┐
│ [←] Jorge Solla / Campanha · Bahia [🔔] │
└─────────────────────────────────────────────────────┘
┌─ dock busca + região resultados ────────────────────┐
│ ┌─ Buscar… ──────────────────────────────────────┐ │
│ │ … │ │
│ └────────────────────────────────────────────────┘ │
│ ░░░░░ skeleton row ░░░░░ (enquanto suggest load) │
│ ░░░░░ skeleton row ░░░░░ │
│ ░░░░░ skeleton row ░░░░░ │
│ (depois) hits B68 / busca tipada │
└─────────────────────────────────────────────────────┘
Fora do frame: strip/resumo retraídos (B66); drawer B91 N/A no Início.
```
## Dados → decisão → apresentação
Dados: N/A — chrome + pending visual; hits/sugestões já existem (B68). Sem métrica nova.
## Contexto
**B66 ✓** retrai strip/resumo ao `uiFocused` (`homeSearchUiFocused`), mas `CampaignMobileTopBar` em modo `app` continua com `SidebarTrigger`. **B68 ✓** carrega sugestões no empty; a região fica vazia até `success` — só `aria-busy`, sem skeleton.
O provider da busca no Início vive **dentro** da página (`CampaignHomeStaffChrome` → `CampaignGlobalSearchProvider`); o top bar está no layout `(app)` **acima** do scroll. Não há `pushState`/`popstate` no `src/` hoje — o Voltar do Android sai da rota.
Pedido de produto (2026-08-01): no modo expandido, Voltar à esquerda no header (no lugar do menu); Android back recolhe a busca; skeleton shimmer no empty enquanto sugestões carregam.
## Objetivos
- Com `uiFocused` no Início mobile: substituir `SidebarTrigger` por botão **Voltar** (`ArrowLeft` + aria “Fechar busca” / “Voltar”) que chama o mesmo collapse que blur+Escape (clear focus; se query vazia, sai do modo focado — alinhado B66).
- Ao entrar em `uiFocused`, empilhar um entry de history (`pushState` com marca estável); `popstate` / back do SO **só** recolhe a busca se o entry for nosso; não dispara navegação Next para a rota anterior.
- Enquanto suggest/search estiver `loading`/`isFetching` sem hits ainda renderizados: 3–5 rows skeleton (altura ~hit row), `aria-busy` mantido; sumir ao `success`/`error`.
- Desktop (`md+`): top bar já hidden — comportamento inalterado; history layer só mobile ou só quando top bar troca o trigger.
- Sem migration / Consent / mudança de contrato JSON de busca.
- Pins: unit no helper de history (push/pop/idempotência); unit/RTL do top bar app-mode com chrome “search-focused”; e2e leve opcional se estável.
## Decisões travadas
- **Contexto leve de chrome no layout (irmã do wizard chrome), não subir o `HomeSearchProvider` inteiro.** O top bar só precisa de `{ focused, collapse }`. Home (e só Início) publica; top bar consome. **Rejeitado:** prop-drill via layout RSC; ler DOM `data-home-focused`; duplicar estado de foco no top bar.
- **History = camada só do modo focado da busca do Início.** Marca tipo `teqo:home-search-focus` (nome final no craft). Collapse via back **não** limpa query tipada se produto preferir só blur focus — **recomendação:** igual Escape/`clear` do B66 (query some e chrome restaura) para um gesto único “sair da busca”. **Rejeitado:** history também no drawer B91 neste item; `beforeunload` hacks.
- **Skeleton reutiliza `Skeleton` shadcn + shape das hit rows; sem lib de shimmer.** Extrair helper/componente mínimo se **B107** (wizard) for o 2º call site no mesmo ciclo — gate 2026-08-01 pediu skeleton no wizard também. **Rejeitado:** spinner centrado; skeleton só no input.
- **Escopo de Voltar/history = Início (`/campanha`), não drawer global.** Drawer tem snap/peek próprios (B100/B105). **Rejeitado:** unificar “qualquer busca focada” neste PR.
- **i18n:** ids `homeSearchChrome` / `collapseHomeSearch`; copy pt-BR no aria do Voltar.
## Questões em aberto
- **Voltar com query tipada: limpa query ou só blur?** **Opções:** A limpa (paridade Escape) | B só tira foco e mantém texto (chrome pode continuar focado via `query.isActive`). **Recomendação:** **A** — um gesto = sair da busca. _(assumido — validar com produto)_
- **Quantas rows de skeleton?** **Opções:** A 3 | B 5. **Recomendação:** **A** (viewport curto com teclado). _(assumido)_
## Abordagem proposta
```mermaid
flowchart LR
Focus["input focus / uiFocused"] --> Pub["HomeSearchChrome publish"]
Pub --> Bar["CampaignMobileTopBar: ☰ → Voltar"]
Focus --> Hist["pushState marca"]
Back["Android back / Voltar"] --> Pop["popstate ou collapse()"]
Pop --> Clear["clear + uiFocused=false"]
Fetch["suggest loading"] --> Skel["HomeSearchSuggestSkeleton"]
Skel --> Hits["success → HomeSearchHitRow"]
```
Componentes:
- **`CampaignHomeSearchChromeContext`** (ou nome alinhado ao wizard): provider no `(app)/layout` junto aos outros chromes; `useSetHomeSearchChrome` / `useHomeSearchChrome` espelhando o padrão B75.
- **`CampaignGlobalSearchMount` / `CampaignHomeSearch` / controller:** ao mudar `uiFocused`, publish + sync history (push ao true, replace/back ao false sem loop).
- **`CampaignMobileTopBar` (modo app):** se chrome.searchFocused → botão Voltar; senão `SidebarTrigger`.
- **`HomeSearchSuggestSkeleton`** (inline no grupo de resultados / results shell): rows quando `isFetching && !hits`; reuso em Início; wizard município **fora** deste item (pode citar como soft follow-up).
- **Helper puro** `homeSearchFocusHistory.ts` em `lib/`: serialize marca, `shouldHandlePopstate`, unit-pinned.
- **Migration:** Sem migration.
## Dependências
- Duras: nenhuma. Soft: B66 ✓, B68 ✓. Paralelo a B102/B103/B105 (drawer) — não serializa schema; evitar editar os mesmos arquivos do drawer no mesmo PR se outro agente estiver neles.
## Não escopo
- Bottom drawer busca (B91/B100/B105) — Voltar/history/skeleton do drawer.
- Remover título “Sugestões” → **B103**.
- Wizard município sticky/título/**skeleton do passo** → **B107** (pode reusar o bloco de rows deste item). Autofocus/votos → **B108**.
## Rabbit holes
- **“Já que o top bar lê foco, movo a busca para o header.”** **Mitigação:** só swap do trigger; input fica no dock (B65/B66).
- **History API × App Router (bfcache, soft nav).** **Mitigação:** marca + guard idempotente; testes unit do helper; não `router.back()` para collapse.
- **Skeleton shared com API inchada.** **Mitigação:** um componente de N rows pinado; sem tema/variant factory.
## Adiado com gatilho
- **Mesmo Voltar/history no drawer.** Revisitar quando B105 estiver estável e produto pedir paridade.
## Referências
- GitHub Issue #141
- `src/components/campaign/shell/CampaignMobileTopBar.tsx`
- `src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx`
- `src/components/campaign/dashboard/CampaignGlobalSearchMount.tsx`
- `src/lib/campaignHomeSearchContract.ts`
- [modo-focado-busca-no-focus.md](modo-focado-busca-no-focus.md) (B66) · [sugestoes-busca-vazia-inicio.md](sugestoes-busca-vazia-inicio.md) (B68)
- `PRODUCT.md` / `DESIGN.md`
Qualidade de decisão: 5/5

View file

@ -0,0 +1,136 @@
# Wizard — autofocus do primeiro input + destaque do último cenário focado
Status: registrado
Atualizado em: 2026-08-01
Issue: #143
Priority: P1
Model: composer-2.5
Impeccable: B — encaixe em `CampaignWizardShell` + steps com input + `VoteEstimateScenarioInputs` compact
Appetite: ~0,5–0,75d eng; foco/a11y + CSS de active scenario; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Feel the action; Clarity under pressure) / `DESIGN.md` · B59 shell · B61 votos · tema `campaign`.
Na implementação: craft compacto → critique → polish (teclado mobile ao abrir cada step).
Brief compacto:
- **Persona / contexto:** assessor avança steps do wizard no celular; espera digitar na hora sem tocar de novo no campo.
- **Job principal:** ao abrir um step **com input**, o primeiro campo já está focado (e selecionado se numérico); no ajuste de votos, o input **visualmente** destacado é o **último focado**, não o central fixo — alinhado aos atalhos (+50, 2×) que já seguem `focusedScenario`.
- **Estratégia de cor:** Restrained — reusar `border-primary/50 bg-primary/5 ring-primary/15` no ativo; muted nos demais.
- **Edit where you see:** sim no sentido de affordance do cenário ativo (já existe strip); não abre editor novo.
- **Anti-goals:** autofocus em steps só de escolha (grid de tendência/tipo) sem input de texto; mudar validação `getWizardVoteViolation`; spreadsheet mode.
### Wireframe (texto)
```text
┌─ step município (após B107 sem h1) ─────────────────┐
│ ┌─ Nome do município… │← foco + teclado ao abrir │
│ hits… │
└─────────────────────────────────────────────────────┘
┌─ step votos estimados ──────────────────────────────┐
│ [strip marker sob o ativo] │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 80 │ │ 100 │ │ 120 │ ← destaque = last focused │
│ └─────┘ └─────┘ └─────┘ │
│ (ex.: foco em otimista → anel primary nele, │
│ não no central) │
│ [+50] [2×] … aplicam ao focusedScenario │
└─────────────────────────────────────────────────────┘
```
## Dados → decisão → apresentação
Dados: N/A — affordance de edição; números já existentes.
## Contexto
`CampaignWizardShell` faz `titleRef.focus()` em todo `stepTitle` change — útil para screen readers em steps de escolha, mas **rouba** o foco do primeiro input.
`WizardExpectedVotesStep` já mantém `focusedScenario` (default `central`) e passa `activeScenario` / `onFocusScenario` para atalhos e strip (`markerMode: 'active-only'`). Porém em `VoteEstimateScenarioInputs` **compact**, a classe primary está hardcoded em `scenario === 'central'`, então o meio parece sempre “o” ativo mesmo quando o foco (e os botões) estão noutro cenário.
Pedido de produto (2026-08-01): autofocus do primeiro input em cada step com input; no votos, destaque = último focado.
## Objetivos
- Steps com `<input>` / textarea / search: ao montar (ou ao mudar de step), focar o primeiro campo editável; em campos numéricos de votos, manter `select()` no focus (já existe).
- Shell **não** foca o `h1` quando o step declara que vai autofocusar um input (ou quando `stepTitle` é null — B107).
- Compact votes: highlight primary segue `activeScenario` (último `onFocus`), não `central` fixo; erro (`errorScenarios`) continua a ganhar da highlight de foco.
- Default inicial inalterado: monta com `central` focado + `autoFocusScenario="central"` → meio destacado até o usuário focar outro.
- Sem migration / mudança de schema de pledge.
- Pins: unit na regra de classe compact (`activeScenario` vs central); RTL leve de autofocus no municipality step se barato.
## Decisões travadas
- **Um item para autofocus + highlight de votos.** Mesmo eixo “foco = sinal do controle ativo”; appetite cabe. **Rejeitado:** duas Issues cosméticas.
- **Opt-in por step (`autoFocusFieldId` / `focusMode: 'title' | 'first-input'`), não heurística DOM genérica no shell.** Cada step sabe seu primeiro input. **Rejeitado:** `querySelector('input')` no shell (frágil com hidden fields).
- **Highlight compact = `activeScenario`, em todos os call sites compact (wizard + lista B42).** Consistência; lista já passa foco. **Rejeitado:** prop `highlightCentralAlways` legacy.
- **Steps só de botões/grid (tendência, tipo de sinal): mantêm foco no título (ou primeiro radio/button se já existir).** Fora do “com input”. **Rejeitado:** forçar foco no primeiro botão do grid neste item (pode ser follow-up a11y).
- **i18n:** ids existentes; sem copy nova obrigatória.
## Questões em aberto
- **Município: `autoFocus` nativo vs `useEffect` + ref após paint?** **Opções:** A `autoFocus` no `CampaignSearchInput` | B effect no step. **Recomendação:** **A** se o shell parar de focar o h1; B se race com navegação pending. _(assumido — craft)_
- **Labeled variant (não compact) também destaca por foco?** **Opções:** A só compact | B ambos. **Recomendação:** **A** — pedido é o wizard/strip compact; labeled já tem labels por campo. _(assumido)_
## Abordagem proposta
```mermaid
flowchart LR
Shell["CampaignWizardShell focusMode"]
Mun["MunicipalitySearch autoFocus"]
Votes["ExpectedVotes autoFocus central"]
Note["TrendNote / SignalBody / Leadership autoFocus"]
Compact["VoteEstimateScenarioInputs highlight=activeScenario"]
Shell -->|"title ou none"| Mun
Shell --> Votes --> Compact
Shell --> Note
```
Componentes:
- **`CampaignWizardShell`:** prop `contentFocus?: 'title' | 'none'` (default `'title'` para não regredir grids); quando `'none'`, skip `titleRef.focus()`. Steps com input passam `'none'`.
- **`WizardMunicipalitySearchStep`:** `contentFocus="none"` + `autoFocus` no search input (coordena com B107 sem h1).
- **`WizardExpectedVotesStep` / `WizardTrendNoteStep` / `WizardSignalBodyStep` / `WizardLeadershipStep`:** `contentFocus="none"` + autofocus no primeiro campo.
- **`VoteEstimateScenarioInputs` compact:** trocar `scenario === 'central'` por `scenario === activeScenario` na classe primary (erro destrutivo prevalece).
- **Testes:** unit da classe compact; smoke se houver spec do wizard votos.
- **Migration:** Sem migration.
## Dependências
- Soft: B59 ✓, ajuste votos wizard ✓. Coordena com **B107** (shell sem h1 no município) — se B107 landar antes, `contentFocus` no município é trivial; se este landar antes, funciona com h1 ainda presente desde que shell não foque o título.
- `serializes`: nenhum schema; preferir não editar `CampaignWizardShell` em paralelo com B107 no mesmo instante — se ambos claimed, **B107 primeiro** (título) ou um PR único se o mesmo agente pegar os dois.
## Não escopo
- Remover título / sticky busca → **B107**.
- Voltar/skeleton Início → **B106**.
- Mudar defaults de cenário (pessimista vs central) ou atalhos.
## Rabbit holes
- **Autofocus + leitores de tela (WCAG 2.4.3).** **Mitigação:** steps de escolha continuam no título; só steps de formulário movem foco para o campo — padrão de wizards mobile.
- **Autofocus em toda navegação client do wizard dispara teclado em loop.** **Mitigação:** focus só no mount do step / mudança de `stepTitle`+slug, não a cada re-render de pending.
## Adiado com gatilho
- **Foco no primeiro controle de steps só-grid.** Revisitar com auditoria a11y se VoiceOver reclamar do h1 sem atalho para o grid.
- **Sincronizar highlight da variant labeled.** Se a lista desktop compactar tudo em compact.
## Referências
- GitHub Issue #143
- `src/components/campaign/shared/CampaignWizardShell.tsx`
- `src/components/campaign/shared/WizardMunicipalitySearchStep.tsx`
- `src/components/campaign/shared/WizardExpectedVotesStep.tsx`
- `src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx`
- `src/components/campaign/municipality/WizardTrendNoteStep.tsx`
- `src/components/campaign/municipality/WizardSignalBodyStep.tsx`
- `src/components/campaign/leadership/WizardLeadershipStep.tsx`
- [chassis-wizard-campanha.md](chassis-wizard-campanha.md) (B59) · [ajuste-votos-wizard.md](ajuste-votos-wizard.md)
- `PRODUCT.md` / `DESIGN.md`
Qualidade de decisão: 5/5

View file

@ -0,0 +1,131 @@
# Wizard município — sem título + busca sticky + skeleton
Status: registrado
Atualizado em: 2026-08-01
Issue: #142
Priority: P1
Model: composer-2.5
Impeccable: B — encaixe em `WizardMunicipalitySearchStep` + `CampaignWizardShell` (título opcional)
Appetite: ~0,5–0,75d eng; markup/CSS + shell opt-out do h1 + skeleton idle; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity under pressure) / `DESIGN.md` · B60/B75/B95 · tema `campaign`.
Na implementação: craft compacto → critique → polish (teclado aberto + lista longa).
Brief compacto:
- **Persona / contexto:** CG/assessor no passo 1 do wizard no celular; o header vermelho já diz o fluxo (“Ajustar votos”); o placeholder é “Nome do município…”.
- **Job principal:** cair direto na busca (sem h1 “Em qual município?”); ao rolar hits, a barra de busca **permanece** logo abaixo do header; enquanto suggest/search carrega, skeleton shimmer na região de resultados.
- **Estratégia de cor:** inalterada; sticky com `bg-background` para não transparizar hits; skeleton `bg-muted` / `animate-pulse`.
- **Edit where you see:** não — layout do passo.
- **Anti-goals:** sticky do header vermelho (já fora do scroll); redesign da hit row; mudar ranking B92–B94.
### Wireframe (texto)
```text
┌─ header wizard (Mandate Red) ───────────────────────┐
│ Ajustar votos [X] │
└─────────────────────────────────────────────────────┘
┌─ scroll content ────────────────────────────────────┐
│ ┌─ Buscar município (sticky) ────────────────────┐ │ ← cola sob o header
│ │ Nome do município… │ │
│ └────────────────────────────────────────────────┘ │
│ ░░░ skeleton · skeleton · skeleton ░░░ (loading) │
│ Cairu · Baixo Sul (após success) │
│ (rola; busca sticky fica) │
└─────────────────────────────────────────────────────┘
Sem h1 “Em qual município?”. Gap shell→busca mínimo no mobile.
```
## Dados → decisão → apresentação
Dados: N/A — layout/chrome; hits inalterados.
## Contexto
**B95 ✓** aproxima o título do header e remove bullets; o h1 **“Em qual município?”** (`WIZARD_MUNICIPALITY_STEP_TITLE`) permanece. `CampaignWizardShell` sempre renderiza o `h1` e foca nele no mount.
No mobile, a busca + lista rolam juntos dentro de `CampaignContentScroll` — a barra sobe e some sob o header ao escanear sugestões/hits.
Pedido de produto (2026-08-01): remover o título; diminuir gap ao header; busca sticky/fixed sempre sob o header; skeleton enquanto o empty/suggest (ou search) carrega — se tem load, tem load indicator.
## Objetivos
- Passo município **sem** `h1` “Em qual município?” (copy pode permanecer no módulo para sr-only/`aria-label` da região se útil; UI visível some).
- Gap visual header→busca no mobile menor que o stack atual (sem h1 + `pt` já `pt-3` do B95; critique pode `pt-2` / `gap` só neste passo).
- Barra de busca **sticky** no scrollport do conteúdo (`sticky top-0 z-10` + fundo opaco + padding-bottom curto), sempre visível sob o top bar vermelho.
- Lista de resultados rola por baixo da busca sticky.
- Enquanto `results.status === 'loading'` / `resultsBusy` sem hits: 3 rows skeleton (paridade B106), `aria-busy` mantido; sumir em `success`/`error`.
- Sem migration / mudança de ranking / autofocus (autofocus → **B108**).
- Pin: unit/RTL se classes sticky/`stepTitle` opcional forem pináveis; e2e não obrigatório.
## Decisões travadas
- **`stepTitle` opcional no shell (`string | null`): omitir `h1` quando vazio/null.** Passo município passa `null`. Outros passos inalterados. **Rejeitado:** CSS `hidden` no h1 (ainda no a11y tree / foco); fork de shell só para município.
- **Sticky da busca, não `position: fixed` no viewport.** Sticky relativo ao `CampaignContentScroll` alinha sob o header sem calcular `env(safe-area)` + altura do top bar à mão. **Rejeitado:** `fixed` + spacer mágico (frágil com teclado).
- **Skeleton no idle/loading deste passo (produto 2026-08-01).** Paridade com B106; reutilizar o mesmo bloco de rows se B106 extrair helper — 2º call site justifica shared mínimo. **Rejeitado:** só `aria-busy` sem visual; spinner centrado.
- **Item novo (B107), não reabrir B95.** B95 manteve o título de propósito; produto agora remove. **Rejeitado:** editar só o as-built B95 sem Issue.
- **i18n:** `WIZARD_MUNICIPALITY_STEP_TITLE` pode virar sr-only / aria da região; identificadores ingleses intactos.
## Questões em aberto
- **Título some só no mobile ou em todo breakpoint?** **Opções:** A some sempre | B some só `md:hidden` (desktop mantém h1). **Recomendação:** **A** — contexto do fluxo já está no chrome/desktop trailing; placeholder basta. _(assumido — gate: aprovado implícito no lote)_
## Abordagem proposta
```mermaid
flowchart TB
Shell["CampaignWizardShell stepTitle=null → sem h1"]
Step["WizardMunicipalitySearchStep"]
Stick["wrapper sticky top-0 bg-background"]
Input["CampaignSearchInput"]
Skel["skeleton rows se loading"]
List["ul hits / suggest"]
Shell --> Step
Step --> Stick --> Input
Step --> Skel
Step --> List
```
Componentes:
- **`CampaignWizardShell.tsx`:** `stepTitle?: string | null`; se ausente, não renderiza `h1` / não foca título; `aria-labelledby` cai para id do flow no chrome ou `aria-label` no `main` via `flowTitle`.
- **`WizardMunicipalitySearchStep.tsx`:** `stepTitle={null}`; envolver input (e label) em container sticky; lista fora do sticky; render skeleton quando busy sem hits.
- **Skeleton rows:** reusar helper/componente nascido em **B106** (`HomeSearchSuggestSkeleton` ou equivalente em `shared`/`dashboard`) — se B107 landar antes, inline 3× `Skeleton` e B106 extrai no polish.
- **`campaignWizardCopy.ts`:** título pode permanecer exportado para aria; documentar.
- **Migration:** Sem migration.
## Dependências
- Soft: B60 ✓, B75 ✓, B95 ✓. Nenhuma dura.
- Coordenar com **B108** (autofocus): sem h1, o shell não deve “roubar” foco — B108 formaliza; neste item, ao omitir h1, **não** chamar `titleRef.focus()` (já coberto pela ausência do nó).
## Não escopo
- Autofocus do input → **B108**.
- Destaque de cenários de votos → **B108**.
- Voltar + skeleton do **Início** → **B106** (shared de rows ok).
- Ranking/geo/continuity → B92–B94.
## Rabbit holes
- **Sticky + teclado virtual (iOS) empurra visualViewport.** **Mitigação:** sticky simples v1; se critique mostrar jump, medir — não `visualViewport` polyfill neste appetite.
- **Tornar todo passo “title-less”.** **Mitigação:** só município; outros passos mantêm pergunta no h1.
## Adiado com gatilho
- **`position: sticky` no dock do Início.** Só se produto pedir paridade (hoje a busca já está no fundo).
## Referências
- GitHub Issue #142
- `src/components/campaign/shared/WizardMunicipalitySearchStep.tsx`
- `src/components/campaign/shared/CampaignWizardShell.tsx`
- `src/lib/campaignWizardCopy.ts`
- [polimento-busca-municipio-wizard.md](polimento-busca-municipio-wizard.md) (B95) · [busca-municipio-wizard.md](busca-municipio-wizard.md) (B60) · [header-mobile-wizard-campanha.md](header-mobile-wizard-campanha.md) (B75)
- `PRODUCT.md` / `DESIGN.md`
Qualidade de decisão: 5/5