Merge pull request #555 from fsolla/C106-atualizacoes-mobile-feed-sem-moldura-omnibox-sticky-sem

C106 — Atualizações mobile: feed sem moldura (omnibox sticky sem label, criar no header, cards edge-to-edge)
This commit is contained in:
Francisco Solla 2026-08-10 04:31:17 -03:00 committed by GitHub
commit 1937688fc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 249 additions and 3 deletions

View file

@ -9,6 +9,12 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-09):** **C103** (Issue #504) — o formulário de criação rápida da agenda ficou **usável de ponta a ponta no celular**: no narrow (mesmo breakpoint 640px do calendário), o sheet de criação abre **do topo** (`swipeDirection="up"` na primitiva `Drawer` — que já suportava top sheet; zero mudança em `Drawer.tsx`) preenchendo `100dvh-1rem`, o conteúdo **rola** num scroll container e o rodapé ("Mais detalhes" + "Salvar") é **fixo** — verificado em smoke com teclado aberto (viewport 280px: sheet encolhe via `dvh`, conteúdo rola 187/187px e o Salvar permanece visível). O form ganhou `variant: 'popover' | 'sheet'`: no sheet, **labels viram `sr-only`** (a11y e `getByLabelText` intactos), placeholders dão contexto ("Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"), obrigatórios marcados com asterisco (título/município no placeholder, Início no trigger), campos empilhados **sem gaps com `divide-y`** e inputs/triggers sem borda (lista iOS do canvas). O **seletor de data/hora abre como bottom sheet aninhado** no mobile (`ActivityDateTimeField` ganhou `isNarrow`/`label`/`required`: conteúdo compartilhado Calendar+selects renderizado em `Drawer` com header "Início — <data>" + "Pronto"; desktop continua popover). Props backward-compatible: `placeholder`/`className` no `StrictCombobox`, `labelClassName`/`emptyText`/`triggerClassName` no `ResponsibleMultiSelect`. Decisão do smoke: swipe handle **removido** do top sheet (primitiva o posiciona no rodapé, contradizendo o gesto de swipe-up; continua nos bottom sheets). E2E mobile novo (viewport 390×844): sheet do topo (`y ≤ 1`), Salvar visível sem scroll, picker em bottom sheet com Pronto, evento aparece após salvar; unit narrow cobre placeholders/asterisco/Pronto. Desktop visualmente inalterado (B181 segue na fila para o clip do popover ≤720px). Sem migration/access/Consent/schema. Intenção [`agenda-mobile-form-criacao-usavel.md`](plans/agenda-mobile-form-criacao-usavel.md); impl [`agenda-mobile-form-criacao-usavel-impl.md`](plans/agenda-mobile-form-criacao-usavel-impl.md).
**Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. \*\*Pe...
**Recently resolved (2026-08-09):** **C106** (Issue #517) — feed de atualizações `/campanha/atualizacoes` sem moldura no mobile: label "Filtrar atualizações" vira sr-only (continua no a11y tree), o filtro perde caixa/borda e fica **sticky** sob a top bar com a linha separadora (`border-bottom`), cards edge-to-edge (`-mx-4`) sem borda/arredondamento com divisor de linha. **Mecanismo herdado do C101** (CSS de página, não prop no chassis): `CampaignUpdatesFeed.css` com media query + `:has()` zerando o padding-top do scroll container (padrão `activity-agenda-filter-strip`); o chassis B127 ganhou só um hook estável `data-slot="campaign-omnibox-field"` (sem mudança de comportamento, reutilizável pelo B184). "Nova atualização" (texto+outline) some do mobile e vira **icon no header via `SetCampaignHeaderAction`** (slot C94/C95; `md:hidden` + botão `hidden md:inline-flex` — gate de resolução 100% CSS, mesmo modal C89). Empty state: copy única "Ajuste o filtro ou registre um novo fato de campo." (descreve a ação, vale nas duas resoluções). **Bug pré-existente corrigido no caminho:** o `@layer base` editorial aplica `my-6 ml-6 list-disc [&>li]:mt-2` a todo `ul` — o feed era a única lista campaign em `<ul>` (indent de 24px + 8px por li; precedente do patch: `[data-theme='campaign'] p { margin-top: 0 }`); a lista neutraliza na própria classe. **Achado registrado:** páginas dinâmicas (searchParams awaitado) têm um nó `#S:0` hidden **transitório** durante o streaming (cópia da página; some em ~1–5s; só no build de produção) — os e2e aguardam o stream assentar (`settleStream`). Sem migration/access/Consent. Intenção [`atualizacoes-mobile-sem-moldura.md`](plans/atualizacoes-mobile-sem-moldura.md); impl [`atualizacoes-mobile-sem-moldura-impl.md`](plans/atualizacoes-mobile-sem-moldura-impl.md).
**Recently resolved (2026-08-10):** **C106** (Issue #517) — feed de atualizações `/campanha/atualizacoes` sem moldura no mobile: **cards edge-to-edge** (`-mx-4` no ul) sem moldura/arredondamento com uma linha separadora (`border-b last:border-b-0` — padrão exato dos cards do B184) + **empty state com copy única** "Ajuste o filtro ou registre um novo fato de campo." (descreve a ação, vale nas duas resoluções; o texto antigo citava o botão "+ Nova atualização"). O filtro sticky/sem label/moldura e o icon "Nova atualização" no header **foram entregues pelo irmão B184/C107 em `main`** (`campaignListOmniboxFormClassName` + chassis borderless < md + `campaignMobileHeaderIconClassName`, id `campaign-updates-new`) — no rebase, a versão do irmão venceu o conflito em `CampaignUpdatesFilters.tsx` (edit the owner, não twin; a hipótese original de CSS de página no padrão C101 foi descartada na execução). **Bug pré-existente corrigido no caminho:** o `@layer base` editorial aplica `my-6 ml-6 list-disc [&>li]:mt-2` a todo `ul` — o feed era a única lista campaign em `<ul>` (indent de 24px + 8px por li; precedente do patch: `[data-theme='campaign'] p { margin-top: 0 }`); a lista neutraliza na própria classe. **Achado registrado:** páginas dinâmicas (searchParams awaitado) têm um nó `#S:0` hidden **transitório** durante o streaming (cópia da página; some em ~1–5s; só no build de produção) — os e2e aguardam o stream assentar (`settleStream`). E2E novo `tests/e2e/campaignUpdatesMobile.e2e.spec.ts` (mobile: label oculta, form sticky + linha, icon abre o bottom sheet, cards edge-to-edge; empty state; regressão desktop). Sem migration/access/Consent. Intenção [`atualizacoes-mobile-sem-moldura.md`](plans/atualizacoes-mobile-sem-moldura.md); impl [`atualizacoes-mobile-sem-moldura-impl.md`](plans/atualizacoes-mobile-sem-moldura-impl.md).
1c6b3d6e (feat(C106): cards do feed edge-to-edge com linha + empty state (padrão B184/C107 compartilhado))
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md).
f37b8bcd (feat(C106): atualizações mobile sem moldura — omnibox sticky, criar no header, cards edge-to-edge)
**Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).

View file

@ -0,0 +1,85 @@
# Impl: C106 — Atualizações mobile: feed sem moldura (omnibox sticky sem label, criar no header, cards edge-to-edge)
Status: em execução
Atualizado em: 2026-08-09
Issue: #517
Intenção: docs/plans/atualizacoes-mobile-sem-moldura.md
Appetite restante: herdado (~0,5–1 dia eng; um encaixe em página existente)
## Leitura da intenção
- **Outcome:** no mobile, o feed `/campanha/atualizacoes` perde o chrome: label do filtro some, filtro fica sem moldura e sempre visível (sticky sob a top bar, separado do feed por uma linha), "Nova atualização" vira icon no header (mesmo modal), cards sem borda/arredondamento edge-to-edge separados por uma linha. Desktop e demais listas inalterados.
- **O que NÃO negociar:** chips continuam dentro da omnibox (mesmo comportamento); modal de criação inalterado (mesmo conteúdo/fluxo, só muda o trigger); desktop do feed inalterado; demais listas `/campanha` inalteradas (B184 é pedido próprio); sem redesenho de shell.
- **O que reavaliar:** a hipótese "prop/variant ou estilo local no chassis" da intenção. **Em `main` o mecanismo irmão já existe** (C101 agenda, `ActivityAgenda.css`): CSS de página com classe própria no wrapper do filtro + `:has()` zerando o padding-top do scroll container + sticky/edge-to-edge em media query — sem prop no chassis. Seguimos esse mecanismo real; o chassis ganha só um hook estável.
## Abordagem recomendada
```mermaid
flowchart LR
P[atualizacoes/page.tsx] --> F[CampaignUpdatesFilters]
F --> S[form + campaignListOmniboxFormClassName<br/>padrão B184 compartilhado]
S --> O[CampaignListOmnibox<br/>campo borderless < md]
F --> H[SetCampaignHeaderAction<br/>icon + md:hidden]
F --> M[CampaignUpdatesCreateModal<br/>inalterado]
P --> L[CampaignUpdatesFeed<br/>cards edge-to-edge B184]
```
**Opções consideradas:** A) CSS escopado de página + hook `data-slot` no chassis (padrão C101); B) prop `variant="frameless"` no `CampaignListOmnibox`; C) chassis sem moldura global.
**Recomendação (revisada na execução):** **C** — o irmão B184/C107 chegou a `main` **antes** do merge deste item e estabeleceu o mecanismo canônico: `campaignListOmniboxFormClassName` (form sticky/edge-to-edge/borderless < md, `md:` restaura desktop) + campo do chassis borderless < md + `campaignMobileHeaderIconClassName` + "Limpar" como X circular no input — usado por municípios, atividades, demandas, assessores, agenda. Seguir o padrão compartilhado (edit the owner, don't twin): o filtro de atualizações usa a classe do form do C107 (já em `main` via conflito), o icon do header usa os tokens compartilhados; **C106 entrega só o que o irmão não cobriu**: cards edge-to-edge com linha (padrão B184 de cards) + copy do empty state + e2e.
**Rejeitadas:** A — viraria um twin do padrão que o B184 acabou de estabelecer (CSS de página + data-slot não existem mais no chassis); B — duplica decisão de visual por lista.
### Componentes / mudanças
- **`CampaignUpdatesFilters`** (`src/components/campaign/municipality/CampaignUpdatesFilters.tsx`): **zero mudanças locais** — a versão do C107 (já em `main`) aplica `campaignListOmniboxFormClassName` no form e registra o icon no header via `SetCampaignHeaderAction id="campaign-updates-new"` com `campaignMobileHeaderIconClassName`; o modal C89 fica onde está.
- **`CampaignUpdatesFeed`** (`src/components/campaign/municipality/CampaignUpdatesFeed.tsx`):
- ul: `-mx-4 my-0 flex list-none flex-col [&>li]:mt-0 md:mx-0 md:gap-4` (edge-to-edge mobile; `my-0 list-none [&>li]:mt-0` neutralizam o leak do `@layer base` editorial `ul { my-6 ml-6 list-disc [&>li]:mt-2 }` — o feed é a única lista campaign em `<ul>`);
- li: `rounded-none border-b border-border last:border-b-0 p-4 md:rounded-xl md:border md:last:border-b` (padrão exato dos cards do B184 em `MunicipalityListMobileCards`);
- empty state: copy única "Ajuste o filtro ou registre um novo fato de campo." (descreve a ação, funciona nas duas resoluções).
- **Migration:** nenhuma. **Access/Consent:** nenhum. **UI:** Impeccable B — shape→craft direto, critique no gate; reusa slot de header action (C94/C95), chassis B127, padrão de cards B184, `CampaignListResults`.
### Decisões de engenharia
1. **Mecanismo mobile sem moldura:** A) CSS de página + hook `data-slot` (padrão C101) | B) prop no chassis | C) **padrão compartilhado B184/C107 já em `main`** (form class + chassis borderless + tokens de header). **→ C (revisado)** — o irmão chegou primeiro; adotar o owner em vez de criar twin.
2. **Trigger de criar no mobile:** A) `SetCampaignHeaderAction` + `md:hidden` (CSS-only) | B) gate JS `useIsMobile()` | C) FAB/quick action (padrão agenda). **→ A** — reusa o slot C94/C95; gate de resolução é CSS puro (sem flash de hidratação); o pedido é explicitamente "icon no header", não FAB.
3. **Copy do empty state:** A) copy única sem referência a botão | B) copy mobile-aware. **→ A** — recomendação da própria intenção; descreve a ação em vez do trigger.
4. **"Limpar" no mobile:** o padrão B184 do chassis já trocou o texto por X circular dentro do campo (mecanismo compartilhado — fora do escopo deste item, herdado do padrão).
5. **Shell gap / sticky:** resolvido pelo próprio `campaignListOmniboxFormClassName` (`sticky top-0 z-20 -mx-4 border-b bg-background px-4 py-2` + `md:static ...`), sem mudança no shell.
### Dados → forma
N/A — item de acabamento visual; nenhum dado/KPI/mapa muda.
## Fases verificáveis
1. **Tracer** — `data-slot` no chassis + `CampaignUpdatesFeed.css` + wrapper do strip; validar sticky/frameless/edge-to-edge no viewport mobile no dev server.
2. **UI completa** — header icon + trailing desktop-only + classes dos cards + copy do empty state.
3. **E2E + gates** — `campaignUpdatesMobile.e2e.spec.ts` (padrão `campaignAgendaMobile.e2e.spec.ts`, fixture `CampaignE2EFixture`): mobile = label oculta, botão texto oculto, icon no header abre o bottom sheet, cards sem borda, filtro visível após scroll; desktop = regressão (label e botão presentes, sem icon no header). `pnpm gate:fast`; push via `pnpm push`; PR `--base main` + `Closes #517` + auto-merge + `gh pr checks --watch --required`.
## Rabbit holes / Não escopo (engenharia)
- "Já que é sem moldura, melhora as outras listas" (B184 é o irmão; demais = pedido próprio).
- Remover/trocar o "Limpar" (isso é o B184, com X circular — não pedido aqui).
- Redesenhar chips/sugestões do omnibox; mudar conteúdo/fluxo do modal.
- `MunicipalityUpdateFeed` (feed do detalhe do município) — superfície diferente.
- Prop/variant novo no chassis; tocar no mecanismo C101 da agenda.
## Riscos e mitigação
- **Geometria do sticky** (pinar 1rem abaixo da top bar): receita C101 (`:has` + padding-top zero) já validada em prod — conferir visualmente no viewport mobile.
- **Regressão em outras listas:** CSS escopado por classe nova + media query; chassis só ganha `data-slot`; coberto por e2e desktop da própria superfície e gates.
- **`isPending`/transição da lista** interage com o sticky: sem mudança de comportamento (o wrapper não altera o fluxo); o e2e cobre rolagem com filtro visível.
- **Icon duplicado no desktop:** `md:hidden` no header action + `hidden md:inline-flex` no botão — exatamente um visível por resolução.
## Achados da execução (registrados)
- **O irmão B184/C107 chegou a `main` antes do merge deste item** e tornou o mecanismo canônico: `campaignListOmniboxFormClassName` + campo borderless < md no chassis + `campaignMobileHeaderIconClassName` + X circular de limpar. A hipótese original (CSS de página no padrão C101) viraria um twin — a execução adotou o padrão compartilhado (edit the owner). O conflito de rebase em `CampaignUpdatesFilters.tsx` foi resolvido mantendo a versão do irmão.
- **Leak da base `ul` editorial no campaign:** `@layer base` do styles.css aplica `my-6 ml-6 list-disc [&>li]:mt-2` a todo `ul` — o feed de atualizações é a única lista campaign em `<ul>`, então o leak só aparecia aqui (margem esquerda de 24px + 8px por li). O feed agora neutraliza na própria lista (`my-0 list-none [&>li]:mt-0`); o `[data-theme='campaign'] p { margin-top: 0 }` já era o precedente desse patch pontual. Efeito colateral no desktop: feed alinhado ao filtro (o indent de 24px era artefato do leak, não desenho).
- **Artefato transitório de streaming (`#S:0` hidden):** páginas dinâmicas (searchParams awaitado) podem exibir temporariamente uma cópia hidden da página durante o stream; some sozinho (~1–5s). Não é regressão (existe só no build de produção, pré-existente no padrão de streaming). Os e2e esperam o stream assentar antes de assertar (`settleStream`).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (7 bullets: label oculta; filtro sem moldura/sticky/linha separadora; chips dentro da omnibox; criar vira icon no header com o mesmo modal; cards edge-to-edge com linha; desktop inalterado; empty state coerente)
- [ ] Invariantes AGENTS/engineering-standards (nenhuma escrita/access/PII; copy pt-BR; identificadores em inglês)
- [ ] Testes: e2e mobile + desktop da superfície; gates completos (`pnpm gate:fast`)
Self-score decision-quality: 4,5/5 (decisões caras com rejeitadas ✓ · cabe no appetite ✓ · rabbit holes nomeados ✓ · depth check: reusa C101/slot C94-C95/chassis B127 ✓ · intenção preservada ✓)

View file

@ -15,7 +15,7 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
})
const CampaignUpdatesFeedItem = ({ card }: { card: CampaignUpdatesFeedCard }) => (
<li className="flex gap-3 rounded-xl border p-4">
<li className="flex gap-3 rounded-none border-b border-border p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b">
<CampaignUserAvatar
name={card.author.name}
avatarUrl={card.author.avatarUrl}
@ -48,13 +48,13 @@ export const CampaignUpdatesFeed = ({ cards }: { cards: CampaignUpdatesFeedCard[
<CampaignListEmptyState
icon={Inbox}
title="Nenhuma atualização encontrada"
description="Ajuste o filtro ou registre um novo fato com o botão “+ Nova atualização”."
description="Ajuste o filtro ou registre um novo fato de campo."
/>
)
}
return (
<ul className="flex flex-col gap-4">
<ul className="-mx-4 my-0 flex list-none flex-col [&>li]:mt-0 md:mx-0 md:gap-4">
{cards.map((card) => (
<CampaignUpdatesFeedItem key={card.id} card={card} />
))}

View file

@ -0,0 +1,155 @@
import type { Page } from '@playwright/test'
import type { CampaignUser } from '../../src/payload-types.js'
import { expect, test, type CampaignE2EFixture } from './fixtures/campaignE2EFixtures.js'
const MOBILE_VIEWPORT = { width: 390, height: 844 }
const DESKTOP_VIEWPORT = { width: 1280, height: 900 }
/**
* Dynamic campaign pages stream their content (awaited `searchParams`), so
* right after `page.goto` the DOM can hold a transient hidden `#S:*` copy of
* the page shell while the stream settles. All class-based locators below
* filter `visible: true` so assertions never match that transient duplicate
* (role-based locators already exclude it).
*/
const settleStream = (page: Page) =>
page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0, undefined, {
timeout: 15_000,
})
const seedUpdates = async (
campaign: CampaignE2EFixture,
): Promise<CampaignUser & { password: string }> => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
for (let i = 0; i < 8; i += 1) {
await fixtures.payload.create({
collection: 'municipalityUpdate',
data: {
municipality: municipality.id,
author: coordinator.id,
polarity: i % 2 === 0 ? 'boa' : 'ruim',
body: `Fato de campo C106 #${i + 1} em ${municipality.name}`,
},
depth: 0,
})
}
return coordinator
}
const scrollFeedToBottom = (page: Page) =>
page.evaluate(() => {
document.querySelector('[data-slot="campaign-content-scroll"]')?.scrollTo(0, 1e5)
})
test.describe('C106 — atualizações mobile sem moldura', () => {
test.setTimeout(90_000)
test('mobile: strip sem label/moldura e sticky, criar no header, cards edge-to-edge', async ({
campaign,
page,
}) => {
const coordinator = await seedUpdates(campaign)
await page.setViewportSize(MOBILE_VIEWPORT)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/atualizacoes`)
await settleStream(page)
const strip = page.getByRole('search')
const feedList = page.locator('ul').filter({ hasText: 'Fato de campo C106 #8' }).first()
// Label visualmente oculta no mobile (o input carrega o accessible name).
await expect(strip.locator('label')).toBeHidden()
// Filtro sem moldura e sticky sob a top bar, separado por uma linha
// (campaignListOmniboxFormClassName — padrão B184 compartilhado).
await expect(strip).toHaveCSS('position', 'sticky')
await expect(strip).toHaveCSS('top', '0px')
await expect(strip).toHaveCSS('border-bottom-width', '1px')
await expect(page.getByRole('combobox').locator('xpath=..')).toHaveCSS(
'border-top-width',
'0px',
)
// "Nova atualização" com texto some; o icon entra no header mobile.
await expect(strip.getByRole('button', { name: 'Nova atualização' })).toBeHidden()
const headerCreate = page.locator(
'[data-slot="campaign-mobile-top-bar"] button[aria-label="Nova atualização"]',
)
await expect(headerCreate).toBeVisible()
// Cards edge-to-edge, sem moldura, com a linha entre eles.
await expect(feedList).toHaveCSS('margin-left', '-16px')
const firstCard = feedList.locator('li').first()
await expect(firstCard).toHaveCSS('border-top-width', '0px')
await expect(firstCard).toHaveCSS('border-radius', '0px')
await expect(firstCard).toHaveCSS('border-bottom-width', '1px')
await expect(firstCard).toContainText('Fato de campo C106 #8')
// O filtro permanece visível o tempo todo: preso no topo durante a rolagem
// (o padrão B184 do form pina na borda do scroll container — a posição
// exata é a natural, o que importa é não rolar junto com os cards).
const pinnedY = (await strip.boundingBox())?.y
await scrollFeedToBottom(page)
await expect.poll(async () => (await strip.boundingBox())?.y).toBe(pinnedY)
const scrolled = await page.evaluate(
() => document.querySelector('[data-slot="campaign-content-scroll"]')?.scrollTop ?? 0,
)
expect(scrolled).toBeGreaterThan(100)
// O icon no header abre o mesmo bottom sheet de criação.
await headerCreate.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Nova atualização', { exact: true })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Registrar atualização' })).toBeVisible()
})
test('mobile: empty state aponta para a ação de criar sem citar o botão', async ({
campaign,
page,
}) => {
const coordinator = await seedUpdates(campaign)
await page.setViewportSize(MOBILE_VIEWPORT)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/atualizacoes`)
await settleStream(page)
await page.getByRole('combobox', { name: 'Filtrar atualizações' }).fill('zzz')
await page.getByRole('combobox', { name: 'Filtrar atualizações' }).press('Enter')
await expect(page.getByText('Nenhuma atualização encontrada')).toBeVisible()
await expect(page.getByText('Ajuste o filtro ou registre um novo fato de campo.')).toBeVisible()
})
test('desktop: label, botão com texto e cards com moldura ficam como estão', async ({
campaign,
page,
}) => {
const coordinator = await seedUpdates(campaign)
await page.setViewportSize(DESKTOP_VIEWPORT)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/atualizacoes`)
await settleStream(page)
const strip = page.getByRole('search')
// Sem sticky e sem linha separadora no desktop.
await expect(strip).toHaveCSS('position', 'static')
await expect(strip).toHaveCSS('border-bottom-width', '0px')
// Label e botão com texto seguem visíveis.
await expect(strip.locator('label')).toBeVisible()
await expect(strip.getByRole('button', { name: 'Nova atualização' })).toBeVisible()
// Nenhum icon de criar nos headers.
await expect(page.locator('header button[aria-label="Nova atualização"]').first()).toBeHidden()
// Cards mantêm a moldura arredondada e o gap.
const firstCard = page.locator('ul li').filter({ hasText: 'Fato de campo C106 #8' }).first()
await expect(firstCard).toHaveCSS('border-top-width', '1px')
await expect(firstCard).toHaveCSS('border-radius', '12px')
})
})