B66: retract home chrome on search focus with animated transition.

Emends B47 so focusing the input clears strip/summary before typing; hardens hit rows against blur-before-click.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 01:21:00 -03:00
parent 16852980b2
commit 31bd488c1e
8 changed files with 104 additions and 62 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B66 entregue em código (2026-07-30):** modo focado da busca no **focus** (emenda B47) — `homeSearchUiFocused` (`inputFocused || query.isActive`) já no contrato/hook; overlap B68 (sugestões no focus vazio). **Nesta entrega:** `CampaignHomeLayout` retrai summary+spacer (`data-slot="home-chrome"`) e actions com grid `0fr/1fr` + opacity 220ms (`motion-reduce:transition-none`) em vez de `hidden`; `md:flex-none` no chrome; `HomeSearchHitRow` `onMouseDown` preventDefault (blur→click); e2e focus sem digitar + regressão com digitação. Sem migration. Plano: [`modo-focado-busca-no-focus.md`](../../../docs/plans/modo-focado-busca-no-focus.md).
- **B61 entregue em código (2026-07-29):** etapa de ajuste de votos no wizard A1 — `WizardExpectedVotesStep` substitui o stub B60 em `atualizar-votos` com `?municipio=`; três passos explícitos média→pessimista→otimista via `?cenario=`; atalhos `2×`/±50/±100; validação client com jump+warning (`getWizardVoteViolation`); **um POST batch** ao concluir otimista (`POST /campanha/municipios/expected-votes`). Helpers em `wizardVoteEstimate.ts` + `wizardActionHref` estendido. Sem migration/Consent. Unit `wizardVoteEstimate`; e2e avanço de cenário + violação. Plano: [`ajuste-votos-wizard.md`](../../../docs/plans/ajuste-votos-wizard.md). Desbloqueia wiring A1 / **B64** (prefill votos).
- **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal).
- **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map<string, MunicipalityPotential>`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md).

View file

@ -1,7 +1,7 @@
# Modo focado da busca no focus (não na digitação)
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-30)
Atualizado em: 2026-07-30 — as-built: predicado `homeSearchUiFocused` (`inputFocused || query.isActive`) já no contrato/hook (overlap B68); `CampaignHomeLayout` retrai summary+spacer (`data-slot="home-chrome"`) e actions (grid `0fr/1fr` + opacity 220ms ease-out, `motion-reduce:transition-none`) em vez de `hidden`; `md:flex-none` no chrome para não crescer no desktop; `HomeSearchHitRow` `onMouseDown` preventDefault (blur→click); e2e focus sem digitar + regressão com digitação; unit layout/search atualizados. Sem migration.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B66 — UX-1 busca Início)
Impeccable: B — encaixe de motion/estado no chrome do Início (`CampaignHomeLayout` / `CampaignHomeStaffChrome`) sob tema `campaign`
Appetite: ~0,5–0,75 dia eng; estado `focused` ≠ `query.isActive` + transição CSS; sem migration

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B61**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/B63–B64.
Atualizado em: 2026-07-30 — ~~**B66**~~ ✓ busca no focus; ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B61**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/B63–B64.
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) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B60**~~ ✓/**B61**/**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) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B60**~~ ✓/**B61**/**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)
@ -99,7 +99,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B70** Wizard atualizar liderança (grid 2/3/4 + form curto + skip + Continuar dirty + focus) · Depende de B59 + B69 · soft B60 · ~1,25–1,5d · Janela 1 · Impeccable C · [plano](plans/wizard-atualizar-lideranca.md)
- ~~**B46** Posição dos botões de ação (thumb zone)~~ — **entregue 2026-07-29** (`CampaignHomeLayout`: coluna `min-h-full` + spacer `flex-1` só em mobile; `data-slot="home-actions"` / `home-search`; ordem flex `order-*` — mobile ações→busca, `md+` busca→ações); `/campanha` usa `CampaignPageShell` `min-h-full` + layout; decisão sticky vs coluna = coluna (opção B). Unit `campaignHomeLayout.unit.spec.ts`; e2e B45 intacto. Sem migration. Âncora inferior no mobile corrigida por **B65** ✓ (2026-07-29). · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.md)
- ~~**B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto)~~ — **entregue 2026-07-29** (`CampaignHomeStaffChrome` + `CampaignHomeSearch` + `useHomeSearchQuery`; debounce 250 ms; query ativa ≥2 chars; `HomeSearchContext` para B48+; staff-only via `isStaffCampaignRole`; Escape limpa; região `aria-live` sem hits ainda; sem migration) · [plano](plans/busca-global-inicio-input.md) · **emenda B66:** limpeza no **focus** (não só na digitação) + animação
- **B66** Modo focado da busca no **focus** (não na digitação) + animação de retração do chrome (strip/spacer/resumo) como se a barra expandisse — emenda a decisão B47 “expand = query”; hits continuam gated por limiar ≥2 · Depende de B47 ✓ · soft ~~B65~~ ✓ · ~0,5–0,75d · Janela 1 · Paralelizável com B49–B53 / B59 / B62 · Impeccable B · [plano](plans/modo-focado-busca-no-focus.md) · empty útil → **B68**
- **B66** ~~Modo focado da busca no **focus** (não na digitação) + animação de retração do chrome (strip/spacer/resumo) como se a barra expandisse — emenda a decisão B47 “expand = query”; hits continuam gated por limiar ≥2~~ — **entregue 2026-07-30** (`homeSearchUiFocused`; `CampaignHomeLayout` grid `0fr/1fr` + opacity 220ms + `motion-reduce`; `HomeSearchHitRow` blur→click; e2e focus sem digitar) · Depende de B47 ✓ · soft ~~B65~~ ✓ · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/modo-focado-busca-no-focus.md) · empty útil → **B68** ✓
- ~~**B65** Ancorar busca + ações do Início no limite inferior (mobile)~~ — **entregue 2026-07-29** (`h-full min-h-0` em `CampaignPageShell` + `CampaignHomeStaffChrome` + `CampaignHomeLayout`; `data-slot="home-dock"` agrupa strip + busca; spacer `flex-1` empurra o dock acima da bottom nav; `md+` inalterado; modo focado B47/B66 intacto; sem `fixed`/`mt-auto`) · Depende de B46 ✓ / B47 ✓ · [plano](plans/ancorar-busca-acoes-inicio-mobile.md)
- ~~**B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card)~~ — **entregue 2026-07-29** (`POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup`; word-start; assessor scoped; TIs via `loadTerritoryOverview`) · [plano](plans/busca-global-resultados-municipios.md)
- ~~**B68** Sugestões na busca aberta (empty state por papel)~~ — **entregue 2026-07-29** (`POST /campanha/home-search` com `mode: 'suggest'`; `loadHomeSearchSuggestions` + `rankHomeSearchSuggestMunicipalities`; assessor = carteira fria; CG/candidato = `priority === 'alta'` por déficit central + frescor; `homeSearchUiFocused` + seção **Sugestões**; `resultKind`; sem migration) · [plano](plans/sugestoes-busca-vazia-inicio.md)

View file

@ -2,6 +2,35 @@ import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
const homeChromeRetractionClass = (retracted: boolean) =>
cn(
'grid min-h-0 transition-[grid-template-rows,opacity] duration-[220ms] ease-out motion-reduce:transition-none',
retracted
? 'grid-rows-[0fr] opacity-0 pointer-events-none motion-reduce:opacity-0'
: 'grid-rows-[1fr] opacity-100 motion-reduce:opacity-100',
)
const HomeChromeRetractionShell = ({
retracted,
slot,
className,
children,
}: {
retracted: boolean
slot?: string
className?: string
children: ReactNode
}) => (
<div
data-slot={slot}
data-retracted={retracted || undefined}
className={cn(homeChromeRetractionClass(retracted), className)}
aria-hidden={retracted || undefined}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
)
export const CampaignHomeLayout = ({
actions,
searchSlot,
@ -11,27 +40,34 @@ export const CampaignHomeLayout = ({
actions: ReactNode
searchSlot?: ReactNode
summarySlot?: ReactNode
/** When true (input focused or query active), hide thumb-zone spacer and action strip. */
/** When true (input focused or query active), retract thumb-zone spacer and action strip. */
focused?: boolean
}) => (
<div className="flex h-full min-h-0 w-full flex-col">
{summarySlot ? (
<div className={cn('order-0 min-w-0', focused && 'hidden')} data-slot="home-summary">
{summarySlot}
<HomeChromeRetractionShell
retracted={focused}
slot="home-chrome"
className={!focused ? 'min-h-0 flex-1 md:flex-none' : undefined}
>
<div className="flex h-full min-h-0 flex-col">
{summarySlot ? (
<div className="min-w-0" data-slot="home-summary">
{summarySlot}
</div>
) : null}
<div aria-hidden className="min-h-0 flex-1 md:hidden" data-slot="home-thumb-spacer" />
</div>
) : null}
<div
aria-hidden
className={cn('min-h-0 flex-1 md:hidden', focused && 'hidden')}
data-slot="home-thumb-spacer"
/>
</HomeChromeRetractionShell>
<div data-slot="home-dock" className="flex min-w-0 flex-col">
<div
data-slot="home-actions"
className={cn('order-1 min-w-0 md:order-2', focused && 'hidden')}
<HomeChromeRetractionShell
retracted={focused}
slot="home-actions-chrome"
className="order-1 md:order-2"
>
{actions}
</div>
<div data-slot="home-actions" className="min-w-0">
{actions}
</div>
</HomeChromeRetractionShell>
{searchSlot ? (
<div
data-slot="home-search"

View file

@ -23,6 +23,10 @@ export const HomeSearchHitRow = ({
}) => (
<Link
href={href}
onMouseDown={(event) => {
// Combobox/list precedent: RelationChipCell — keep input focused until navigation (B66).
event.preventDefault()
}}
className={cn(
'flex min-h-11 w-full items-center gap-3 py-2.5 text-left text-foreground',
'rounded-md outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',

View file

@ -31,7 +31,10 @@ test.describe('Início — busca global (B47)', () => {
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
})
test('staff focused search hides action strip after debounce', async ({ campaign, page }) => {
test('staff focused search hides action strip on input focus (B66)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -42,6 +45,26 @@ test.describe('Início — busca global (B47)', () => {
await expect(actionsSlot).toBeVisible()
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await page.getByLabel('Buscar na campanha').focus()
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
await page.getByLabel('Buscar na campanha').blur()
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
})
test('staff typing in search keeps action strip hidden after debounce', 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)
const actionsSlot = page.locator('[data-slot="home-actions"]')
await expect(actionsSlot).toBeVisible()
await page.getByLabel('Buscar na campanha').fill('ca')
await expect(actionsSlot).toBeHidden({ timeout: 5000 })

View file

@ -40,18 +40,20 @@ describe('CampaignHomeLayout', () => {
expect(container.querySelector('[data-slot="home-dock"]')).not.toBeNull()
})
it('renders mobile thumb-zone spacer above home-dock', () => {
it('renders mobile thumb-zone spacer inside home-chrome above home-dock', () => {
const { container } = render(<CampaignHomeLayout actions={<p>Actions block</p>} />)
const dock = container.querySelector('[data-slot="home-dock"]')
const spacer = dock?.previousElementSibling
expect(spacer?.getAttribute('data-slot')).toBe('home-thumb-spacer')
const chrome = container.querySelector('[data-slot="home-chrome"]')
const spacer = chrome?.querySelector('[data-slot="home-thumb-spacer"]')
expect(chrome).not.toBeNull()
expect(spacer).not.toBeNull()
expect(spacer?.getAttribute('aria-hidden')).toBe('true')
expect(spacer?.className).toContain('flex-1')
expect(spacer?.className).toContain('md:hidden')
expect(chrome?.nextElementSibling?.getAttribute('data-slot')).toBe('home-dock')
})
it('hides spacer, summary, and actions when focused but keeps search', () => {
it('retracts spacer, summary, and actions when focused but keeps search', () => {
const { container } = render(
<CampaignHomeLayout
actions={<p>Actions block</p>}
@ -61,11 +63,18 @@ describe('CampaignHomeLayout', () => {
/>,
)
expect(container.querySelector('[data-slot="home-thumb-spacer"]')?.className).toContain(
'hidden',
)
expect(container.querySelector('[data-slot="home-summary"]')?.className).toContain('hidden')
expect(container.querySelector('[data-slot="home-actions"]')?.className).toContain('hidden')
const homeChrome = container.querySelector('[data-slot="home-chrome"]')
expect(homeChrome?.getAttribute('data-retracted')).toBe('true')
expect(homeChrome?.className).toContain('grid-rows-[0fr]')
expect(homeChrome?.className).toContain('opacity-0')
const actionsChrome = container.querySelector('[data-slot="home-actions-chrome"]')
expect(actionsChrome?.getAttribute('data-retracted')).toBe('true')
expect(actionsChrome?.className).toContain('grid-rows-[0fr]')
expect(container.querySelector('[data-slot="home-thumb-spacer"]')).not.toBeNull()
expect(container.querySelector('[data-slot="home-summary"]')).not.toBeNull()
expect(container.querySelector('[data-slot="home-actions"]')).not.toBeNull()
expect(container.querySelector('[data-slot="home-search"]')).not.toBeNull()
expect(container.querySelector('[data-slot="home-dock"]')).not.toBeNull()
})

View file

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
@ -15,36 +14,6 @@ const providerValue = {
uiFocused: true,
}
describe('CampaignHomeLayout focused mode', () => {
it('hides actions, thumb spacer, and summary when focused', () => {
const { container } = render(
<CampaignHomeLayout
focused
actions={<p>Actions block</p>}
searchSlot={<p>Search</p>}
summarySlot={<p>Summary block</p>}
/>,
)
expect(container.querySelector('[data-slot="home-thumb-spacer"]')).toBeNull()
expect(container.querySelector('[data-slot="home-actions"]')?.className).toContain('hidden')
expect(container.querySelector('[data-slot="home-summary"]')?.className).toContain('hidden')
})
it('shows summary when not focused', () => {
render(
<CampaignHomeLayout
actions={<p>Actions block</p>}
summarySlot={<p data-testid="home-summary-stub">Summary stub</p>}
/>,
)
expect(
screen.getByTestId('home-summary-stub').closest('[data-slot="home-summary"]'),
).not.toBeNull()
})
})
describe('CampaignHomeSearch', () => {
it('renders search input and results region', () => {
render(