fix(S12): deep link #novidades aterrissa no formulário no mobile

A home é dona do container de scroll interno (h-dvh overflow-y-auto, body
overflow-hidden), então a navegação nativa por fragmento não aterrissa no
alvo; no mobile o reflow do h-dvh + imagens eager do hero deslocam o layout
após o scroll inicial.

Novo componente cliente CampaignHashScroll (renderiza null) montado no
layout da home: retenta o hash inicial até a seção entrar no viewport
(parando na primeira interação do usuário) e ouve hashchange para âncoras
in-page. scrollIntoView sem behavior herda scroll-smooth/motion-reduce.

E2E novos: describe mobile (load direto, CTA, reduced-motion) + load direto
desktop em describe próprio (evita corrida no beforeAll de consent).
This commit is contained in:
Francisco Solla 2026-08-23 13:31:02 -03:00
parent 729ce6fed4
commit 2a568017cb
6 changed files with 259 additions and 1 deletions

View file

@ -4,9 +4,11 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`.
**E1 — extração do hook compartilhado de busca assíncrona com debounce (2026-08-23):** a 3ª cópia do efeito de busca async (requestId + `setTimeout(250)` + loading/failed) — introduzida pela C143 em `DemandResponsibleMultiSelect` (demand), e presente em `ResponsibleMultiSelect` (C90) e `AsyncSearchCombobox` — vira o hook `useAsyncSearchOptions({ open, query, search, isQueryReady? }) → { options, loading, failed }` (`src/hooks/useAsyncSearchOptions.ts`, novo; constante `ASYNC_SEARCH_DEBOUNCE_MS` exportada). Os 3 componentes consomem o hook e mantêm só o que é seu (grupos por typeLabel, avatares, headings de sugestões do município, `pinnedOptions`, filtro de selecionados, mensagens de vazio); semântica de query vazia preservada por consumidor (Demand busca sempre → sugestões; ResponsibleMultiSelect reseta via `isContactSearchQueryReady`; combobox com gate configurável — o efeito sync `selected←value` fica no componente). Fix pontual: server actions intocadas (continuam props — hook client-safe por injeção), sem schema/migration/access/Consent. Testes: spec unit novo `tests/unit/useAsyncSearchOptions.unit.spec.ts` (renderHook + fake timers, 9 casos: debounce/trim, coalesce, gate reset, closed, failed + retry, in-flight loading, out-of-order, quirk gate→not-ready com resposta stale pinado — comportamento herdado dos 3 originais, registrado na decisão D3 do impl plan). Impl plan `docs/plans/extrair-hook-busca-assincrona-debounce-impl.md`. Verificação: unit 2428 + int 815 verdes, e2e activity/demand (campaignActivity, campaignRegisterDemand, campaignDemandVisibility) verdes em re-run (1 flake de cold-compile pré-existente isolado), tsc/lint/format/cycles/build verdes; knip com o erro de load pré-existente (exit 0); fora de escopo: `WizardMunicipalitySearchStep` (4ª variante, AbortController — defer com gatilho no plano).
**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`.
**S12 — âncora `#novidades` quebra no mobile — deep link não leva ao formulário (2026-08-23):** o deep link `jorgesolla1313.com.br/#novidades` (usado para direcionar tráfego orgânico/pago/WhatsApp para a captura) abria a home de campanha no topo no celular: a home é dona do container de scroll interno (`h-dvh overflow-y-auto`, body `overflow-hidden`), então a navegação nativa por fragmento não aterrissa no alvo, e no mobile o reflow do `h-dvh` + imagens eager do hero deslocam o layout após o scroll inicial. Fix contido: novo componente cliente `CampaignHashScroll` (renderiza null, montado no layout da home) que (1) no load direto com hash retenta o scroll até a seção entrar no viewport (rAF + timeouts, parando na primeira interação do usuário — wheel/touch/pointer/teclas de scroll) e (2) ouve `hashchange` para âncoras in-page (CTA do hero, `#bandeiras`, back/forward) sem tocar em `CampaignHero` (continua server component). `el.scrollIntoView()` sem `behavior` herda `scroll-smooth`/`motion-reduce:scroll-auto` do container — `prefers-reduced-motion` respeitado com zero JS. Sem mudança de visual, schema, formulário S9 ou modelo de scroll de outras rotas. E2E novos: describe mobile 390×844 (load direto, CTA, reduced-motion via `emulateMedia`) + load direto desktop (antes sem cobertura), com o teste de load direto desktop em describe próprio para não criar corrida no `beforeAll` de consent do describe de captura (purge+create não seguro sob workers paralelos). Verificação: lint/typecheck/unit + e2e `campaignNewsletter` e `campaignHomePixel` verdes locais; `gate:push` na entrega; PR em main com auto-merge.
**C142 — interface do /campanha respeita o perfil de permissão do assessor (2026-08-21):** a entrega fechada parte do C141 (que definiu os eixos `visibility`/`editing` no `campaignUser` e os gates server-side). Com o C142, a UI de todos os rascunhos staff do `/campanha` obedece de ponta a ponta o perfil de edição do assessor: com `editing = somente_leitura` não há nenhum controle de escrita em lugar nenhum (listas, detalhes, tabs, wizards); com `editing = tudo` o assessor enxerga/edita tudo o que vê; com `carteira` mantém o comportamento por carteira. Discriminação das entregas: (1) núcleo puro `src/lib/campaignAdvisorProfile.ts` com `advisorEditingScope()`/`rowEditingAllowed()` (retorna `none` para combinação incoerente `editing=tudo` + `visibility≠tudo`) e `gate: 'writable'` em `requireCampaignPageActor` (redireciona para `/campanha`); (2) todas as células compartilhadas (CampaignInlineEditableCell, MunicipalityRelationEditor, PhonesFieldEditor, DemandDescriptionEditor/Card/WorkflowCard, MunicipalityV2\*Cells, MunicipalityGoalAccountCard, ActivityTaskChecklist, SuggestionCard, MunicipalityPledgesPanel, etc.) ganharam o prop `readOnly`; (3) páginas staff — municípios (lista desktop+mobile, detalhe com tabs Overview/Updates/Leaderships/Suggestions, edição), pessoas (invite e criação gated), lideranças, dobradinhas, demandas, atividades, apoiadores, organizações — escondem os botões de criação (`Nova demanda`, `Nova atividade`, `Planejar giro`, `Novo`/`Importar CSV`, `Nova organização`, `Nova liderança`, `Nova dobradinha`) com `editingScope !== 'none'`; (4) home: `homeActionsForRole(role, editingScope)` filtra os 4 wizards para `somente_leitura` (sobra só o atalho de navegação); (5) `shouldMountQuickActionsFab` não monta o FAB para `somente_leitura`; (6) registry de quick actions (`resolveQuickActionsForPath`) recebe `editingScope` e filtra; (7) e2e novo `tests/e2e/campaignPermissionProfile.e2e.spec.ts` cobre os 3 perfis (somente_leitura sem controles, carteira com carteira, tudo com tudo) usando `createCampaignUser` com `visibility`/`editing`. Verificação: tsc/lint/format/knip/cycles/units (2419) + `gate:fast` verdes; PR em main com auto-merge (rebase); nada muda para coordinator/candidate/leader.

View file

@ -0,0 +1 @@
**S12 — âncora `#novidades` quebra no mobile — deep link não leva ao formulário (2026-08-23):** o deep link `jorgesolla1313.com.br/#novidades` (usado para direcionar tráfego orgânico/pago/WhatsApp para a captura) abria a home de campanha no topo no celular: a home é dona do container de scroll interno (`h-dvh overflow-y-auto`, body `overflow-hidden`), então a navegação nativa por fragmento não aterrissa no alvo, e no mobile o reflow do `h-dvh` + imagens eager do hero deslocam o layout após o scroll inicial. Fix contido: novo componente cliente `CampaignHashScroll` (renderiza null, montado no layout da home) que (1) no load direto com hash retenta o scroll até a seção entrar no viewport (rAF + timeouts, parando na primeira interação do usuário — wheel/touch/pointer/teclas de scroll) e (2) ouve `hashchange` para âncoras in-page (CTA do hero, `#bandeiras`, back/forward) sem tocar em `CampaignHero` (continua server component). `el.scrollIntoView()` sem `behavior` herda `scroll-smooth`/`motion-reduce:scroll-auto` do container — `prefers-reduced-motion` respeitado com zero JS. Sem mudança de visual, schema, formulário S9 ou modelo de scroll de outras rotas. E2E novos: describe mobile 390×844 (load direto, CTA, reduced-motion via `emulateMedia`) + load direto desktop (antes sem cobertura), com o teste de load direto desktop em describe próprio para não criar corrida no `beforeAll` de consent do describe de captura (purge+create não seguro sob workers paralelos). Verificação: lint/typecheck/unit + e2e `campaignNewsletter` e `campaignHomePixel` verdes locais; `gate:push` na entrega; PR em main com auto-merge.

View file

@ -0,0 +1,99 @@
# Impl: Âncora #novidades quebra no mobile — deep link não leva ao formulário
Status: rascunho
Atualizado em: 2026-08-23
Issue: #780
Intenção: docs/plans/ancora-novidades-mobile.md
Appetite restante: herdado — fix contido em 1 componente cliente novo + 1 spec; sem corte
## Leitura da intenção
- **Outcome:** abrir `jorgesolla1313.com.br/#novidades` no celular leva o visitante à seção do formulário de captura; o CTA "Receba novidades" do hero rola até a seção no mobile; desktop sem regressão; respeita `prefers-reduced-motion`. Cada visita que não chega ao formulário é audiência perdida na janela eleitoral (tráfego orgânico/pago/WhatsApp apontará para essa URL).
- **O que NÃO negociar:** contrato de URL pública `#novidades`; layout/visual da home (anti-goal); modelo de scroll das rotas `(frontend)` (container interno com `overflow-hidden` no body — fora de escopo removê-lo); fluxo do formulário S9 (consent fail-closed, transação, copy); invariantes AGENTS (identificadores em inglês, copy pt-BR, sem abstração <3 call sites, direção lib → utilities → components → app).
- **O que reavaliar:** (a) a hipótese de que o click no CTA é interceptado pelo Next e "não aterrissa" — o e2e atual prova que no desktop o `<a href="#novidades">` puro JÁ funciona (CampaignHero.tsx:104), então o click nativo + `hashchange` é o caminho, não uma interceptação a combater; (b) a hipótese de que o problema é só o load direto — no mobile o colapso é o `h-dvh` (URL bar) + imagens `loading="eager"` do hero deslocando o layout após o scroll inicial do browser; (c) Opção A (rolar à seção) vs B (rolar ao formulário) — mantida A (a seção é o contrato publicado; o formulário fica na mesma tela).
## Abordagem recomendada
```mermaid
flowchart LR
A[Load direto /#novidades no mobile] --> B[CampaignHashScroll<br/>montado no layout da home]
B --> C{location.hash no mount?}
C -->|sim| D[retry-until-in-viewport<br/>rAF + timeouts; stop em interação do usuário]
D --> E[el.scrollIntoView - behavior auto]
C -->|não| F[aguarda hashchange]
F --> G[CTA hero / #bandeiras / back-forward<br/>disparam hashchange]
G --> E
E --> H[Container da home rola<br/>scroll-smooth · motion-reduce:scroll-auto]
H --> I[Seção #novidades com formulário visível]
```
**Opções consideradas:** A | B | C | D | E
**Recomendação:** A — um componente cliente `CampaignHashScroll` (renderiza `null`) montado dentro do container de scroll do layout da home; no mount com hash inicial faz retry-until-in-viewport (o layout móvel assenta depois: colapso do `h-dvh` + imagens `eager` do hero); um listener de `hashchange` cobre o CTA do hero, `#bandeiras` e back/forward sem tocar em `CampaignHero` (que continua `<a href="#novidades">` puro, server component). O scroll usa `el.scrollIntoView()` sem `behavior` — `behavior: auto` herda o `scroll-behavior` do container, então `scroll-smooth` rege no normal e `motion-reduce:scroll-auto` já respeita reduced-motion (0 JS extra de a11y). É o mesmo mecanismo que já funciona no desktop (scroll nativo do fragmento) e o mesmo padrão de `scrollIntoView` de CampaignStorySection:16 com o defer de rAF de CampaignAISidebarShell:96 — sem mecanismo paralelo: o componente é o dono único da preocupação.
**Rejeitadas:**
- **B (onClick + scrollIntoView no CTA do hero, padrão CampaignStorySection):** cobre o clique mas NÃO cobre o deep link de load direto (aceite 1 falha) — exigiria um segundo mecanismo para o mount; e `CampaignHero` é server component, o onClick forçaria torná-lo cliente. O `hashchange` cobre o clique com zero mudança no hero.
- **C (mudar o modelo de scroll global — tirar `overflow-hidden` do body, scroll no document):** fora de escopo declarado da intenção; afeta TODAS as rotas `(frontend)`; o container interno é o modelo histórico escolhido para o comportamento mobile (dvh/URL bar) — reverter é arriscado na janela eleitoral.
- **D (solução só CSS — `scroll-padding`/`scroll-behavior` no html/body):** o window/document não rola (body `overflow-hidden`, layout.tsx:82); CSS de âncora nativa não aterrissa no container aninhado nem resolve o deslocamento pós-load das imagens.
- **E (Next `<Link>`/`router.push` com hash):** mudaria o CTA para cliente; `router.push` com hash no App Router tem histórico de não rolar containers aninhados (o Next atua no document); troca um mecanismo que parcialmente funciona por outro com o mesmo risco.
### Componentes / mudanças
- **NOVO `CampaignHashScroll`** (`src/components/CampaignHashScroll.tsx`): `'use client'`, renderiza `null`. Responsabilidades: (1) deep link no mount — resolve o alvo por `getElementById(hash)` e agenda tentativas (rAF + timeouts progressivos, parando quando o alvo entra no viewport ou na primeira interação de scroll/wheel/touch do usuário — one-time listeners); (2) `hashchange` → resolve e rola o alvo (cobre CTA do hero, `#bandeiras` do footer/form, back/forward). Sempre `el.scrollIntoView()` sem `behavior` (herda `scroll-smooth`/`motion-reduce:scroll-auto` do container). `scroll-mt-4` existente da seção (CampaignNewsletterSection.tsx:17) já serve de offset — `scrollIntoView` respeita `scroll-margin`. Depth check: não existe módulo de scroll-por-hash no repo (achado 5) — o componente é o dono; lógica não vai para lib/hook (1 call site).
- **`src/app/(frontend)/(home)/layout.tsx`**: monta `<CampaignHashScroll />` como filho do div de scroll (linha 17–23); nenhuma mudança no modelo de scroll nem nas classes.
- **`src/components/CampaignHero.tsx`**: sem mudança — CTA continua `<a href="#novidades">` puro (server component); o `hashchange` disparado pelo clique nativo aciona o scroll.
- **`tests/e2e/campaignNewsletter.e2e.spec.ts`**: novo describe mobile + testes de load direto e reduced-motion (detalhe em "Fases").
- **Migration:** sem migration (nenhuma mudança de schema).
- **Access / Consent:** sem mudança — fluxo S9 intacto (o formulário nem é tocado).
- **UI:** sem mudança visual (anti-goal da intenção); `scroll-mt-4` existente faz o papel de offset.
### Dados → forma (se aplicável)
Sem dados novos; a única "forma" é o **alvo do scroll** (pergunta 3 de data-presentation aplicada à âncora): **Opção A (seção `#novidades`)** — é o contrato de URL publicado e mantido no aceite; o heading dá contexto de onde o visitante chegou e o formulário começa na mesma tela (a seção tem heading + form, ~um viewport de 844px). **Rejeitada B (formulário interno):** id interno de formulário não é contrato público; rolar direto ao form perderia o contexto do heading; e o aceite diz "a seção do formulário". `block: 'start'` (default) com o `scroll-mt-4` existente, não `center` (o `center` de CampaignStorySection serve ao vídeo, não a uma seção de captura).
## Fases verificáveis
1. **Tracer (deep link no mount)** — criar `CampaignHashScroll` com o caminho do hash inicial (retry-until-in-viewport mínimo: rAF + 2–3 timeouts), montar no `(home)/layout.tsx`. Verificação manual em dev mobile (devtools iPhone, `/#novidades`) + desktop. Gate: `pnpm lint` + `pnpm typecheck`.
2. **Interação + robustez** — listener `hashchange` (CTA mobile/desktop, `#bandeiras`, back/forward) e stop em interação do usuário. Verificação manual: clique no CTA mobile; voltar no histórico. Gate: `pnpm lint` + `pnpm typecheck`.
3. **E2E + gates** — no `campaignNewsletter.e2e.spec.ts`: describe mobile com `test.use({ viewport: { width: 390, height: 844 } })` (padrão campaignHomeActions:149) com (a) load direto `/?e2e=<suffix>#novidades` → `expect(section).toBeInViewport()`; (b) CTA hero → URL `/#novidades` → seção no viewport; (c) mesmo teste (a) com `page.emulateMedia({ reducedMotion: 'reduce' })`; e no describe desktop atual, (d) load direto `/#novidades` (regressão de cobertura hoje inexistente). Manter o teste existente do CTA desktop intacto. Rodar `pnpm test:e2e campaignNewsletter --project=campaign` e a suíte desktop; fechar com `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não criar utility/lib/hook genérico de hash-scroll (1 call site — Depth check).
- Não tocar em `#bandeiras`/`#formulario`/`#detalhes`: o `hashchange` genérico os cobre de graça na home, mas sem teste dedicado — fora do pedido.
- Não simular collapse de URL bar no e2e (comportamento real de browser, indeterminístico) — o viewport mobile pinna o contrato; o colapso é mitigado pelo retry, não testado.
- Não mudar `CampaignHero`, `CampaignNewsletterForm`/`CampaignNewsletterSection`, footer nem outras rotas `(frontend)`.
- Não mexer no modelo de scroll global (`overflow-hidden` do body).
## Riscos e mitigação
- **Next interceptar o clique em âncora pura e não disparar `hashchange`** (comportamento version-dependent do App Router): o e2e do CTA mobile+desktop pinna o contrato; se falhar, estender o MESMO componente com um listener de click em `a[href^="#"]` (sem mecanismo paralelo — continua um dono).
- **Retry loop brigar com o scroll do usuário** (visitante começa a rolar durante o deep link): stop em scroll/wheel/touchstart (one-time) + teto de tentativas — o retry nunca luta com o usuário.
- **Flakiness de timing no e2e**: `toBeInViewport` auto-retenta e o retry do componente torna o aterrissar determinístico (não depende de um único timeout).
- **Imagens `eager` do hero terminarem de carregar depois do scroll inicial**: coberto pelo retry-until-in-viewport (re-tenta após os shifts de layout, incluindo o do `h-dvh`).
- **Regressão desktop do CTA**: o teste existente permanece na suíte; o novo teste de load direto desktop cobre o caminho que hoje não tem nenhuma cobertura.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: deep link `/#novidades` no mobile leva à seção do formulário; CTA do hero no mobile rola até a seção; desktop sem regressão; `prefers-reduced-motion` respeitado (via `motion-reduce:scroll-auto` herdado + e2e com `emulateMedia`)
- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês; copy pt-BR intocada; sem abstração <3 call sites (1 componente, 1 mount); direção lib → utilities → components → app (componente em `src/components`, montado no app)
- [ ] Testes de domínio previstos: e2e mobile (load direto + CTA + reduced-motion) e desktop (load direto novo + CTA existente); sem unit/int novos — nenhum access/write path muda
---
### Self-score decision-quality: 5/5
1. **Decisões caras têm rejeitadas?** Sim — abordagem (A–E com motivo por rejeitada), alvo de scroll (A vs B) e o não-tocar em `CampaignHero`/modelo global estão documentados com porquês.
2. **Abordagem cabe no appetite?** Sim — 1 componente novo (renderiza null, ~40 linhas), 1 linha no layout, 1 spec: dentro do appetite herdado da S12, sem corte.
3. **Rabbit holes nomeados?** Sim — lib/hook genérico, âncoras fora do pedido, simulação de URL bar, modelo de scroll global, formulário S9.
4. **Depth check?** Sim — reusa o padrão `scrollIntoView` (CampaignStorySection:16), o defer rAF (CampaignAISidebarShell:96), o `scroll-mt-4` existente e o padrão de viewport mobile dos e2e (campaignHomeActions:149); não cria utility nova para 1 call site.
5. **Intenção preservada?** Sim — o aceite de produto (deep link mobile → seção, CTA → seção, desktop ok, reduced-motion) permanece intacto; a engenharia só entrega o scroll no container dono, sem reescrever layout, formulário ou modelo de scroll.
---
## Débitos deferidos (triage do /simplify, 2026-08-23)
- **Duplicação de asserções de âncora entre specs** (teste mobile CTA vs `frontend.e2e.spec.ts`): defer — absorver como variação de viewport quando o `frontend.e2e.spec.ts` ou um teste de âncora for mexido.
- **`[type]`/`artigos` herdam o modelo de scroll que causou o S12**: defer — gatilho: adicionar âncora in-page nesses layouts (nota de 1 linha já no JSDoc do `CampaignHashScroll`).
- **Retry fixo 5×150ms é premissa não observada**: defer — gatilho: hero ganhar imagens não-eager / regressão de CLS observada no deep link.
- **Teste reduced-motion com asserção fraca**: defer — mesclar com o item da duplicação de spec se um dia registrar.
- **Pin e2e do `#bandeiras`/back-forward**: descartado — decisão travada no impl plan (fora do escopo do fix contido).

View file

@ -1,3 +1,4 @@
import { CampaignHashScroll } from '@/components/CampaignHashScroll'
import { MetaPixel } from '@/components/MetaPixel'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import React from 'react'
@ -19,6 +20,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
className="relative h-dvh w-full scroll-smooth overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink) motion-reduce:scroll-auto"
>
{pixelId ? <MetaPixel pixelId={pixelId} /> : null}
<CampaignHashScroll />
{children}
</div>
)

View file

@ -0,0 +1,110 @@
'use client'
import { useEffect } from 'react'
const MAX_ATTEMPTS = 5
const RETRY_DELAY_MS = 150
const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', ' '])
/**
* S12 — the campaign home owns its scroll container (`h-dvh overflow-y-auto`
* on the (home) layout; the body is `overflow-hidden`), so the browser's
* native fragment navigation — which scrolls the document — never lands on a
* hash target. On a direct `/#novidades` load the mobile layout settles only
* after the `h-dvh` reflow and the hero's eager images, kicking the page back
* to the top. (`[type]`/`artigos` share the same scroll-container model; if
* they ever gain in-page anchors they inherit this mechanism.)
*
* Owner of hash scrolling on the home: on mount it retries the initial hash
* until the target is in view (stopping at the first user interaction), and a
* `hashchange` listener covers in-page anchors (hero CTA, footer `#bandeiras`,
* back/forward). `el.scrollIntoView()` without `behavior` inherits the
* container's `scroll-smooth` / `motion-reduce:scroll-auto`, so
* `prefers-reduced-motion` is honored with zero extra JS. Renders nothing.
*/
export const CampaignHashScroll = () => {
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined
let frame: number | undefined
let attempt = 0
const clearRetry = () => {
if (timer !== undefined) clearTimeout(timer)
if (frame !== undefined) cancelAnimationFrame(frame)
}
// Returns true when a scroll was issued and the retry should continue;
// false when there is nothing to do (no hash, unknown target, or the
// target is already in view).
const scrollToHashIfNeeded = (): boolean => {
const hash = window.location.hash
if (hash.length < 2) return false
let target: HTMLElement | null = null
try {
target = document.getElementById(decodeURIComponent(hash.slice(1)))
} catch {
return false
}
if (!target) return false
const rect = target.getBoundingClientRect()
if (rect.top >= -8 && rect.top < window.innerHeight - 8) return false
target.scrollIntoView()
return true
}
const retry = () => {
clearRetry()
if (attempt >= MAX_ATTEMPTS || !scrollToHashIfNeeded()) return
attempt += 1
// The mobile layout settles after the `h-dvh` reflow and the hero's
// eager images; re-check after the frame has painted the scroll.
frame = requestAnimationFrame(() => {
timer = setTimeout(retry, RETRY_DELAY_MS)
})
}
// Never fight the user: stop retrying at the first real interaction.
// Programmatic `scrollIntoView` does not fire these, so the retry itself
// never cancels itself. Reinstalled whenever a `hashchange` starts a new
// retry, so every loop runs guarded.
const stopRetrying = () => {
clearRetry()
window.removeEventListener('wheel', stopRetrying)
window.removeEventListener('touchstart', stopRetrying)
window.removeEventListener('pointerdown', stopRetrying)
window.removeEventListener('keydown', stopOnScrollKey)
}
const stopOnScrollKey = (event: KeyboardEvent) => {
if (SCROLL_KEYS.has(event.key)) stopRetrying()
}
const installInteractionGuards = () => {
window.addEventListener('wheel', stopRetrying, { passive: true })
window.addEventListener('touchstart', stopRetrying, { passive: true })
window.addEventListener('pointerdown', stopRetrying, { passive: true })
window.addEventListener('keydown', stopOnScrollKey)
}
const handleHashChange = () => {
attempt = 0
retry()
installInteractionGuards()
}
window.addEventListener('hashchange', handleHashChange)
installInteractionGuards()
retry()
return () => {
clearRetry()
window.removeEventListener('hashchange', handleHashChange)
window.removeEventListener('wheel', stopRetrying)
window.removeEventListener('touchstart', stopRetrying)
window.removeEventListener('pointerdown', stopRetrying)
window.removeEventListener('keydown', stopOnScrollKey)
}
}, [])
return null
}

View file

@ -197,3 +197,47 @@ test.describe('Campaign home novidades capture', () => {
// refusal precedent is a handled 4xx). The refusal path is pinned by the
// int spec `submitCampaignNewsletter.int.spec.ts` (exact message + no rows).
})
/**
* S12 — the home owns its scroll container (body `overflow-hidden`), so the
* native fragment navigation lands on the section only through
* `CampaignHashScroll`. These tests pin the deep-link contract on mobile
* (where the `h-dvh` reflow + eager hero images used to kick the page back to
* the top) and the direct-load path on desktop (previously uncovered).
*/
test.describe('Campaign home novidades hash scroll', () => {
const scrollSuffix = Date.now()
test.use({ viewport: { width: 390, height: 844 } })
test('direct #novidades deep link lands on the section (mobile)', async ({ page }) => {
await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`)
const section = page.locator('[data-home-section="newsletter"]')
await expect(
section.getByRole('heading', { name: 'Receba as novidades da campanha' }),
).toBeVisible()
await expect(section).toBeInViewport()
})
test('hero CTA scrolls to the newsletter section (mobile)', async ({ page }) => {
await page.goto(`/?e2e=scroll-${scrollSuffix}`)
await page.locator('[data-cta="secondary"]').click()
await expect(page).toHaveURL(/#novidades$/)
await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport()
})
test('deep link respects prefers-reduced-motion (mobile)', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`)
await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport()
})
})
test.describe('Campaign home novidades hash scroll — desktop', () => {
const desktopSuffix = Date.now()
test('direct #novidades deep link lands on the section (desktop)', async ({ page }) => {
await page.goto(`/?e2e=scroll-${desktopSuffix}#novidades`)
await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport()
})
})