diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index 7fb3128b..29283b15 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
+**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
+
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
diff --git a/docs/changelog/2026-08-18-s6.md b/docs/changelog/2026-08-18-s6.md
new file mode 100644
index 00000000..9356bd68
--- /dev/null
+++ b/docs/changelog/2026-08-18-s6.md
@@ -0,0 +1 @@
+**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
diff --git a/docs/plans/home-campanha-sem-carrossel-desktop-impl.md b/docs/plans/home-campanha-sem-carrossel-desktop-impl.md
new file mode 100644
index 00000000..3ceba19c
--- /dev/null
+++ b/docs/plans/home-campanha-sem-carrossel-desktop-impl.md
@@ -0,0 +1,162 @@
+# Impl: S6 — Home de campanha sem carrossel no desktop
+
+Status: aprovado
+Atualizado em: 2026-08-18
+Issue: #34
+Intenção: docs/plans/home-campanha-sem-carrossel-desktop.md
+Appetite restante: herdado (~0,5–1 dia eng; só layout, sem dados novos)
+
+## Leitura da intenção
+
+- **Outcome:** no desktop (≥1024px) as seções "Por que essa eleição importa" (3 cards) e
+ "Nossa caminhada" (6 cards) mostram todo o conteúdo de uma vez — grade estática, sem
+ auto-avanço, sem rolagem oculta, sem indicadores/chips; mobile (<1024px) 100% preservado.
+- **O que NÃO negociar:** mobile intocado (comportamento e geometria); mesma copy/imagens/ordem;
+ sem redesenho de cards; seção "Acompanhe de perto" (S1) fora do escopo; zero schema/migration/
+ Consent/dados.
+- **Questões em aberto da intenção (assumidas, validar no gate):** chips somem no desktop (A);
+ cards de bandeira com altura por conteúdo e respiro (A); breakpoint `lg` = 1024px (A); a seção
+ dos 6 cards é "Nossa caminhada" (confirmado — eyebrow atual no site).
+- **O que reavaliar:** a hipótese "variante estática em `lg` dentro do `CampaignCarousel`" —
+ **rejeitada**: o `CampaignCarousel` é o módulo pinado à geometria Penpot de auto-avanço
+ (precedente S1: "not a variant of `CampaignCarousel`"); o padrão consagrado no repo para
+ "bento/grid no desktop + carrossel no mobile" é renderização-irmã com toggle CSS (`hidden md:grid`
+ - `md:hidden` em `CampaignContentSection`). Ver Decisão 1.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ subgraph page[page.tsx — server]
+ A[problemItems/flagItems] --> B[Grade estática desktop
hidden lg:grid]
+ A --> C[Carrossel mobile existente
lg:hidden]
+ end
+ B --> D[CampaignProblemCard / CampaignFlagCard
components presentacionais compartilhados]
+ C --> D
+ E[styles.css
@media min-width 64rem
.campaign-flags height recalibrada] --> B
+```
+
+**Opções consideradas:** A | B | C | D
+**Recomendação:** **A — renderizações-irmãs no `page.tsx`** (grade estática server-rendered
+`hidden lg:grid` + carrossel atual embrulhado em `lg:hidden`), com os dois cards extraídos para
+components presentacionais compartilhados — exatamente o padrão S1 (`CampaignContentSection` +
+`CampaignArticleCard`).
+**Rejeitadas:**
+
+- **B) track único vira grade via CSS dentro do `CampaignCarousel`** (`lg:grid lg:overflow-visible`
+ - matchMedia para suprimir JS/a11y no desktop). O S1 já rejeitou estender esse módulo para um
+ layout diferente ("pinned to the Penpot auto-advancing geometry"); a troca de semântica a11y
+ (`aria-roledescription="carrossel"`, "N de M") não é expressável em CSS — exigiria estado de
+ matchMedia com flip pós-hidratação — e o componente é pinado por unit + e2e de swipe.
+- **C) desmontar o carrossel no desktop** (wrapper client com matchMedia). Custo: componente
+ wrapper novo + dupla renderização pós-hidratação para matar um timer invisível de ~1 re-render/
+ 4s numa subárvore `display:none` — o mesmo custo que o S1 já aceita (carrossel montado oculto
+ no desktop, sem timer por não ter auto-avanço). Não vale o movimento.
+- **D) duplicar o JSX dos cards inline no `page.tsx`** (grade copiada, carrossel intocado).
+ Cria duas fontes de verdade do markup do card (~50 linhas) com risco de drift em qualquer
+ mudança visual futura. O S1 extraiu o card (`CampaignArticleCard`) — a extração é o dono do
+ concern.
+
+### Componentes / mudanças
+
+- **`CampaignProblemCard`** (`src/components/CampaignProblemCard.tsx`, server/presentacional):
+ o artigo do card `problem` (imagem `fill`/`imageFrame`, gradiente, título+corpo overlay) —
+ extraído verbatim do `li` do carrossel; recebe `item` (`CampaignCarouselItem`).
+- **`CampaignFlagCard`** (`src/components/CampaignFlagCard.tsx`, server/presentacional): o artigo
+ do card `flags` (título+corpo) — extraído verbatim; recebe `item` + prop `size: 'compact' |
+'spacious'` (padding `px-3.5 py-2.5` no carrossel — mobile intocado — e `p-4` na grade,
+ conforme o rascunho UI aprovado).
+- **`CampaignCarousel`** (`src/components/CampaignCarousel.tsx`): SÓ re-roteia o markup dos cards
+ para os dois components extraídos; classes, estado, timers e a11y byte-idênticos. Unit tests
+ existentes permanecem sem mudança.
+- **`page.tsx`** (`src/app/(frontend)/(home)/page.tsx`): dentro de cada wrapper absoluto existente
+ (`.campaign-problem-carousel` / `.campaign-flags-carousel`), renderiza:
+ - grade desktop: `
` →
+ `
` com os
+ components de card (li `h-[437px]` no problem; altura por conteúdo no flags);
+ - carrossel: wrapper `` → `CampaignCarousel` inalterado.
+- **`styles.css`** (`src/app/(frontend)/styles.css`): sob `@media (min-width: 64rem)` recalibra
+ **apenas** `.campaign-flags { height: clamp(…) }` — a grade problem mantém a geometria atual
+ (top 191 + 437 + 31px de respiro já existentes). A altura do flags vira clamp fluido entre
+ âncoras medidas no browser (pior caso ~1024px: corpos de 3 linhas; caso largo: 2 linhas);
+ o mobile (<64rem) fica intacto.
+- **Migration:** sem migration. **Access/Consent:** n/a. **UI:** Impeccable B — shape → craft
+ (browser, viewports 1024/1280/1440/1920) → critique (agente design-vision sobre screenshots) →
+ polish.
+
+### Dados → forma
+
+- Sem dados novos — itens já existem (`problemItems`/`flagItems`). Forma: grade estática com os
+ mesmos cards; sem contadores, sem elementos novos (restrição da intenção).
+
+## Fases verificáveis
+
+1. **Extração dos cards (tracer/refactor neutro)** — criar `CampaignProblemCard`/
+ `CampaignFlagCard`, re-rotear o `CampaignCarousel` para usá-los; `pnpm test` (unit
+ `campaignCarousel` verde, sem diff de comportamento) prova a neutralidade.
+2. **Grades no `page.tsx` + toggle CSS** — wrappers `hidden lg:block`/`lg:hidden`, `data-grid`,
+ grids `grid-cols-3`; verificação visual no browser (dev server da worktree).
+3. **Altura do flags em desktop** — medir a grade (2 linhas) em 1024/1280/1440/1920 no browser,
+ fixar o clamp fluido em `styles.css`; conferir respiro ≥ 24px em todas as larguras.
+4. **e2e** — reescrever `auto-advances and keeps carousel controls synchronized`
+ (tests/e2e/frontend.e2e.spec.ts): no viewport desktop → grade estática (3 cards numa linha,
+ 6 cards 3×2), sem `aria-current`, chips/carrossel ocultos; resize mobile → auto-avanço e
+ chips sincronizados continuam. Teste novo de geometria desktop (respiro bottom ≥ 24, sem
+ overflow horizontal em 1024/1280/1920). Swipe mobile e demais testes intocados.
+5. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` + changelog OPS44 na entrega.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Não redesenhar o card problem no desktop (draft mostra card redesenhado com imagem 280px +
+ texto abaixo — é esboço; a intenção corta "redesenho de cards"; mantém o overlay atual).
+- Não mexer em hero/prova social/S1; não tocar na geometria mobile das seções.
+- Não desmontar o carrossel no desktop via wrapper client (Decisão 1-C rejeitada) — o guard de
+ `display:none` no auto-avanço já elimina o churn do timer oculto sem mover o componente.
+
+## Riscos e mitigação
+
+- **Regressão mobile:** componente e CSS mobile intocados + pinos existentes (mobileSpacing @430,
+ swipe @430, spacing tests) verificam.
+- **Altura do flags errada no desktop:** âncoras medidas no browser (não chutadas) + e2e de
+ respiro asserta o invariante (bottom ≥ 24) em vez de pixels fixos; clamp com folga.
+- **Cards problem estreitos em 1024 (≈207px de coluna):** tradeoff assumido da intenção
+ ("cards preenchendo a coluna"); texto é overlay absoluto no bottom do card fixo 437px — sem
+ clip; verificado visualmente na craft.
+- **Duplicação de DOM (grade + carrossel oculto):** mesma que o S1 já aceita; o carrossel oculto
+ fica fora da árvore a11y (`display:none`), sem foco, sem movimento visível. O timer de
+ auto-avanço do carrossel oculto foi morto por guard de `display:none` no effect (pós-simplify).
+
+## Pós-simplify (3 reviewers) — aplicado
+
+- `lg:` órfãos removidos do `CampaignCarousel` (o carrossel nunca renderiza ≥1024px agora).
+- Grade duplicada extraída em `CampaignCardGrid` local no `page.tsx` (fonte única do contrato).
+- `overflow: hidden` na seção flags ≥64rem — zoom de texto ≥125% clipa na banda em vez de pintar
+ sobre o footer.
+- Guard de `display:none` no auto-avanço do carrossel (jsdom não computa `display:none` — unit
+ tests preservados).
+- e2e: `document.fonts.ready` antes das medições; eixo x no grid problem; pino
+ `scrollHeight ≤ clientHeight` (sem rolagem oculta); comentário do clamp com âncoras.
+
+## Débitos (triage autônoma)
+
+- **Registrado:** Issue #58 (S6-FOLLOWUP, kind defect, `depends: [S6]`) — a CLI do Playwright
+ 1.58.2 ignora `--grep`/posicionais e `pnpm test:e2e:affected` roda a suíte completa
+ silenciosamente (plano: `docs/plans/s6-followup-playwright-filtros-cli-ignorados.md`).
+- **Defer (gatilho):** literais de gap da grade (`gap-[21px]`/`gap-[14px]`) não referenciam a
+ geometria do carrossel — gatilho: recalibração Penpot da geometria do carrossel.
+- **Descartado:** contratos de tipo dos cards (armadilha knip; structural typing ok), aria-labels
+ duplicados (copy pinada no e2e), DOM duplicado sem CSS (tradeoff S1 consagrado), tablet
+ 768–1023 sem pino e2e (S6 não tocou <1024).
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto (grade desktop, mobile 100% preservado)
+- [x] Invariantes AGENTS/engineering-standards (sem migration/Consent; identificadores em inglês;
+ copy pt-BR intocada)
+- [x] Unit `campaignCarousel` verde sem edição; e2e frontend reescrito cobre grade desktop +
+ carrossel mobile; `pnpm test`, `pnpm test:e2e`, lint/format/tsc/knip/cycles verdes
+- [x] Changelog OPS44 (`docs/changelog/2026-08-18-s6.md` + `pnpm changelog:build`)
+
+Self-score decision-quality: **4/5** — opções explícitas com razões + precedente do repo;
+rejeitadas documentadas; fases verificáveis; único salto medido (altura do flags) fica
+pinned por e2e de invariante em vez de pixel mágico.
diff --git a/docs/plans/s6-followup-playwright-filtros-cli-ignorados.md b/docs/plans/s6-followup-playwright-filtros-cli-ignorados.md
new file mode 100644
index 00000000..ecdb71bb
--- /dev/null
+++ b/docs/plans/s6-followup-playwright-filtros-cli-ignorados.md
@@ -0,0 +1,37 @@
+# Impl: S6-FOLLOWUP — CLI Playwright ignora `--grep` e filtros posicionais; `test:e2e:affected` roda a suíte completa
+
+Status: registrado
+Atualizado em: 2026-08-18
+Issue: #(a criar — S6-FOLLOWUP, depends S6)
+Priority: P2
+Appetite: ~0,5 dia
+
+## Problema
+
+O binário `playwright` 1.58.2 instalado no repo ignora **`--grep`/`-g` e filtros posicionais de arquivo**:
+
+- `pnpm test:e2e frontend -g "static grids on desktop"` → roda 164 testes (a suíte toda), não 2.
+- Reproduzido com config mínima no mesmo binário (`--grep alpha` roda o teste `beta`; posicional `a.spec.ts` idem).
+- `--list frontend` lista todos os projetos; `--project=frontend --no-deps` **funciona** (único filtro que respeita).
+
+## Consequência
+
+`pnpm test:e2e:affected` (`scripts/run-e2e-affected.mjs`) passa paths completos (`tests/e2e/
.e2e.spec.ts`) como posicionais → hoje roda a **suíte completa silenciosamente** em vez dos specs afetados. CI não é afetado (usa `--shard`, caminho diferente). Iteração local depende de `--project= --no-deps`.
+
+## Correção proposta (a validar no gate)
+
+1. **Diagnóstico da causa**: inspecionar por que `cliGrep`/`cliArgs` não chegam ao runner (o código em `lib/program.js`/`loadUtils.js` está correto — hipótese: parsing do commander consumindo os args, ou bug do build 1.58.2). Verificar se há fix upstream (changelog/issue do Playwright) e se um pin/bump resolve.
+2. **Fixo mínimo no repo** (independente do diagnóstico):
+ - `run-e2e-affected.mjs` deixa de depender de posicional: família → `--project=` (mapa spec→projeto), `--no-deps` quando o gate não precisa da cadeia; ou gera o comando por arquivo se um mecanismo alternativo funcionar (`--test-list` investigado: também não filtrou no repro — descartado sem fix do binário).
+ - Documentar no AGENTS.md/playwright.config.ts o contorno `--project=X --no-deps` para iteração local.
+3. **Teste**: unit do `run-e2e-affected` (invocation args) + smoke manual do comando gerado (contagem de testes).
+
+## Não escopo
+
+- Não mudar os specs nem o CI (`--shard` funciona).
+- Não trocar o runner (Playwright é o framework do repo).
+
+## Aceite
+
+- `pnpm test:e2e:affected` roda só os specs afetados (contagem verificável).
+- Comando de iteração local documentado.
diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx
index a76693ac..278d728c 100644
--- a/src/app/(frontend)/(home)/page.tsx
+++ b/src/app/(frontend)/(home)/page.tsx
@@ -1,7 +1,37 @@
import { CampaignCarousel, type CampaignCarouselItem } from '@/components/CampaignCarousel'
import { CampaignContentSection } from '@/components/CampaignContentSection'
+import { CampaignFlagCard } from '@/components/CampaignFlagCard'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
+import { CampaignProblemCard } from '@/components/CampaignProblemCard'
+import type { ReactNode } from 'react'
+
+/**
+ * S6 — desktop sibling of the mobile carousel: the same cards as a static
+ * grid (3 columns), visible only from the `lg` breakpoint the carousel
+ * geometry used to interpolate at. Kept here with the sections it serves.
+ */
+const CampaignCardGrid = ({
+ gridKey,
+ ariaLabel,
+ gapClassName,
+ children,
+}: {
+ gridKey: 'problem' | 'flags'
+ ariaLabel: string
+ gapClassName: string
+ children: ReactNode
+}) => (
+
+)
const problemItems: CampaignCarouselItem[] = [
{
@@ -131,11 +161,24 @@ export default async function HomePage() {
-
+ gapClassName="gap-[21px]"
+ >
+ {problemItems.map((item) => (
+
-
+
+
+ ))}
+
+
+
+
@@ -163,11 +206,24 @@ export default async function HomePage() {
Ministério da Saúde e da saúde da Bahia.
-
+ gapClassName="gap-[14px]"
+ >
+ {flagItems.map((item) => (
+
-
+
+
+ ))}
+
+
+
+
diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css
index ec6f6f6b..1400734d 100644
--- a/src/app/(frontend)/styles.css
+++ b/src/app/(frontend)/styles.css
@@ -460,7 +460,18 @@ body,
}
}
+/* Desktop grid recalibration (S6): the flags section grows so both grid rows
+ of content-height cards fit with breathing room. Fluid clamp between the
+ measured anchors: ~520px at 1024px (narrow columns, 3-line card bodies) →
+ ~418px from ~1385px up (columns saturate at the content max width).
+ `overflow: hidden` keeps text-zoom growth (≥125%) inside the band instead
+ of painting the cards over the footer. */
@media (min-width: 64rem) {
+ .campaign-flags {
+ height: clamp(418px, calc(807.7px - 28.13vw), 520px);
+ overflow: hidden;
+ }
+
[data-theme='campaign-site'] {
--campaign-desktop-portrait-width: clamp(465px, calc(26.45vw + 194.15px), 702px);
--campaign-desktop-portrait-height: clamp(517px, calc(29.41vw + 215.84px), 781px);
diff --git a/src/components/CampaignCarousel.tsx b/src/components/CampaignCarousel.tsx
index 8c17af1c..25ae74a4 100644
--- a/src/components/CampaignCarousel.tsx
+++ b/src/components/CampaignCarousel.tsx
@@ -1,6 +1,7 @@
'use client'
-import Image from 'next/image'
+import { CampaignFlagCard } from '@/components/CampaignFlagCard'
+import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { useCallback, useEffect, useRef, useState } from 'react'
const AUTO_ADVANCE_MS = 4_000
@@ -83,6 +84,9 @@ export const CampaignCarousel = ({ ariaLabel, items, variant }: CampaignCarousel
useEffect(() => {
if (prefersReducedMotion || temporarilyPaused || items.length < 2) return
+ // S6: on desktop the carousel lives inside an `lg:hidden` wrapper — its
+ // auto-advance chain must not keep re-rendering a display:none subtree.
+ if (trackRef.current && window.getComputedStyle(trackRef.current).display === 'none') return
const timer = window.setTimeout(() => {
scrollToItem((activeIndex + 1) % items.length)
@@ -142,7 +146,7 @@ export const CampaignCarousel = ({ ariaLabel, items, variant }: CampaignCarousel
{items.map((item, index) => {
const isActive = index === activeIndex
@@ -176,7 +180,7 @@ export const CampaignCarousel = ({ ariaLabel, items, variant }: CampaignCarousel
onScroll={syncActiveItem}
data-carousel-track
className={`scrollbar-hide m-0 flex list-none snap-x snap-mandatory overflow-x-auto scroll-smooth p-0 ${
- variant === 'problem' ? 'gap-2.5 lg:gap-[21px]' : 'mt-5 gap-2.5 lg:gap-[14px]'
+ variant === 'problem' ? 'gap-2.5' : 'mt-5 gap-2.5'
}`}
>
{items.map((item, index) => (
@@ -187,45 +191,14 @@ export const CampaignCarousel = ({ ariaLabel, items, variant }: CampaignCarousel
aria-current={index === activeIndex ? 'true' : undefined}
className={`m-0 shrink-0 snap-start ${
variant === 'problem'
- ? 'h-[437px] w-[calc(100vw_-_38px)] max-w-[355px] lg:w-[355px]'
- : 'h-[100px] w-[calc(100vw_-_66px)] max-w-[327px] lg:w-[327px]'
+ ? 'h-[437px] w-[calc(100vw_-_38px)] max-w-[355px]'
+ : 'h-[100px] w-[calc(100vw_-_66px)] max-w-[327px]'
}`}
>
{variant === 'problem' ? (
-
- {item.image && item.imageFrame ? (
-
- ) : item.image ? (
-
- ) : null}
-
-
-
- {item.title}
-
-
{item.body}
-
-
+
) : (
-
-
- {item.title}
-
- {item.body}
-
+
)}
))}
diff --git a/src/components/CampaignFlagCard.tsx b/src/components/CampaignFlagCard.tsx
new file mode 100644
index 00000000..1e7ad306
--- /dev/null
+++ b/src/components/CampaignFlagCard.tsx
@@ -0,0 +1,24 @@
+type CampaignFlagCardItem = {
+ id: string
+ title: string
+ body: string
+}
+
+type CampaignFlagCardProps = {
+ item: CampaignFlagCardItem
+ size?: 'compact' | 'spacious'
+}
+
+export const CampaignFlagCard = ({ item, size = 'compact' }: CampaignFlagCardProps) => {
+ const padding = size === 'spacious' ? 'p-4' : 'px-3.5 py-2.5'
+ return (
+
+
+ {item.title}
+
+ {item.body}
+
+ )
+}
diff --git a/src/components/CampaignProblemCard.tsx b/src/components/CampaignProblemCard.tsx
new file mode 100644
index 00000000..3ff602f5
--- /dev/null
+++ b/src/components/CampaignProblemCard.tsx
@@ -0,0 +1,44 @@
+import Image from 'next/image'
+
+export type CampaignProblemCardItem = {
+ id: string
+ title: string
+ body: string
+ image?: string
+ imageAlt?: string
+ imageFrame?: {
+ width: number
+ height: number
+ className: string
+ }
+}
+
+export const CampaignProblemCard = ({ item }: { item: CampaignProblemCardItem }) => (
+
+ {item.image && item.imageFrame ? (
+
+ ) : item.image ? (
+
+ ) : null}
+
+
+
+ {item.title}
+
+
{item.body}
+
+
+)
diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts
index d220af3c..a9a1ca53 100644
--- a/tests/e2e/frontend.e2e.spec.ts
+++ b/tests/e2e/frontend.e2e.spec.ts
@@ -350,9 +350,93 @@ test.describe('Frontend', () => {
}
})
- test('auto-advances and keeps carousel controls synchronized', async ({ page }) => {
+ test('shows the campaign message sections as static grids on desktop', async ({ page }) => {
await page.goto('/')
+ const problemGrid = page.locator('[data-home-section="problem"] [data-grid="problem"]')
+ await expect(problemGrid).toBeVisible()
+ await expect(problemGrid.getByRole('listitem')).toHaveCount(3)
+ const problemBoxes = await problemGrid
+ .getByRole('listitem')
+ .evaluateAll((items) => items.map((item) => item.getBoundingClientRect()))
+ expect(new Set(problemBoxes.map((box) => Math.round(box.y))).size).toBe(1)
+ expect(new Set(problemBoxes.map((box) => Math.round(box.x))).size).toBe(3)
+ expect(problemBoxes.every((box) => box.width > 0 && box.height > 0)).toBe(true)
+
+ const flagsGrid = page.locator('[data-home-section="flags"] [data-grid="flags"]')
+ await expect(flagsGrid).toBeVisible()
+ await expect(flagsGrid.getByRole('listitem')).toHaveCount(6)
+ const flagsBoxes = await flagsGrid
+ .getByRole('listitem')
+ .evaluateAll((items) => items.map((item) => item.getBoundingClientRect()))
+ expect(new Set(flagsBoxes.map((box) => Math.round(box.x))).size).toBe(3)
+ expect(new Set(flagsBoxes.map((box) => Math.round(box.y))).size).toBe(2)
+
+ // No carousel chrome on desktop: regions and chips are display:none (out
+ // of the a11y tree) and the hidden carousel never starts its auto-advance
+ // chain, so no aria-current state exists anywhere in the grids.
+ await expect(
+ page.getByRole('region', { name: 'Bandeiras que tornam esta eleição decisiva' }),
+ ).toHaveCount(0)
+ await expect(page.getByRole('region', { name: 'Bandeiras da campanha' })).toHaveCount(0)
+ await expect(page.locator('[data-carousel-chips]')).toBeHidden()
+ await expect(problemGrid.locator('[aria-current]')).toHaveCount(0)
+ await expect(flagsGrid.locator('[aria-current]')).toHaveCount(0)
+
+ // The grids stay inside their sections with breathing room at every
+ // desktop width, no card is clipped (no hidden scroll) and nothing
+ // overflows the page horizontally. Fonts settle first: the flags cards
+ // are content-height, so a fallback-font measurement could differ from
+ // the real layout.
+ await page.evaluate(() => document.fonts.ready)
+ for (const width of [1024, 1280, 1440, 1920]) {
+ await page.setViewportSize({ width, height: 900 })
+ const spacing = await page.evaluate(() => {
+ const section = (selector: string) => {
+ const element = document.querySelector
(selector)
+ if (!element) throw new Error(`Seção ausente: ${selector}`)
+ return element.getBoundingClientRect()
+ }
+ const gridBottom = (selector: string) => {
+ const grid = document.querySelector(selector)
+ if (!grid) throw new Error(`Grade ausente: ${selector}`)
+ const cards = Array.from(grid.querySelectorAll('li'))
+ return Math.max(...cards.map((card) => card.getBoundingClientRect().bottom))
+ }
+ const gridClipped = (selector: string) => {
+ const grid = document.querySelector(selector)
+ if (!grid) throw new Error(`Grade ausente: ${selector}`)
+ return grid.scrollHeight - grid.clientHeight
+ }
+ const problem = section('[data-home-section="problem"]')
+ const flags = section('[data-home-section="flags"]')
+ const scrollContainer = document.querySelector('[data-theme="campaign-site"]')
+ return {
+ problemBottomGap:
+ problem.bottom - gridBottom('[data-home-section="problem"] [data-grid]'),
+ flagsBottomGap: flags.bottom - gridBottom('[data-home-section="flags"] [data-grid]'),
+ problemClipped: gridClipped('[data-home-section="problem"] [data-grid]'),
+ flagsClipped: gridClipped('[data-home-section="flags"] [data-grid]'),
+ horizontalOverflow: scrollContainer
+ ? scrollContainer.scrollWidth - scrollContainer.clientWidth
+ : 0,
+ }
+ })
+ expect(spacing.problemBottomGap).toBeGreaterThanOrEqual(24)
+ expect(spacing.flagsBottomGap).toBeGreaterThanOrEqual(24)
+ expect(spacing.problemClipped).toBeLessThanOrEqual(1)
+ expect(spacing.flagsClipped).toBeLessThanOrEqual(1)
+ expect(spacing.horizontalOverflow).toBeLessThanOrEqual(1)
+ }
+ })
+
+ test('keeps carousel auto-advance and chips on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('/')
+
+ await expect(page.locator('[data-home-section="problem"] [data-grid]')).toBeHidden()
+ await expect(page.locator('[data-home-section="flags"] [data-grid]')).toBeHidden()
+
const problemCarousel = page.getByRole('region', {
name: 'Bandeiras que tornam esta eleição decisiva',
})
diff --git a/tests/unit/campaignHome.unit.spec.tsx b/tests/unit/campaignHome.unit.spec.tsx
index 1b6036c5..e2952dcf 100644
--- a/tests/unit/campaignHome.unit.spec.tsx
+++ b/tests/unit/campaignHome.unit.spec.tsx
@@ -45,11 +45,18 @@ describe('Campaign home', () => {
it('usa exatamente as seis bandeiras aprovadas', async () => {
render(await HomePage())
- expect(screen.getByText('Defender o SUS e valorizar quem cuida')).toBeTruthy()
- expect(screen.getByText('Fim da escala 6×1 e jornada de 40h')).toBeTruthy()
- expect(screen.getByText('Educação em tempo integral e federais no interior')).toBeTruthy()
- expect(screen.getByText('Recomprar a Refinaria de Mataripe')).toBeTruthy()
- expect(screen.getByText('Salário mínimo forte, emprego e moradia')).toBeTruthy()
- expect(screen.getByText('Defesa intransigente da democracia')).toBeTruthy()
+ // S6: cada bandeira aparece em duas renderizações irmãs (grade desktop e
+ // carrossel mobile); o teste garante que as duas seguem a mesma copy.
+ const expectedTitles = [
+ 'Defender o SUS e valorizar quem cuida',
+ 'Fim da escala 6×1 e jornada de 40h',
+ 'Educação em tempo integral e federais no interior',
+ 'Recomprar a Refinaria de Mataripe',
+ 'Salário mínimo forte, emprego e moradia',
+ 'Defesa intransigente da democracia',
+ ]
+ for (const title of expectedTitles) {
+ expect(screen.getAllByText(title)).toHaveLength(2)
+ }
})
})