feat(S6): home de campanha sem carrossel no desktop — grade estática em ≥1024px, mobile preservado

This commit is contained in:
Francisco Solla 2026-08-18 14:04:15 -03:00
parent 50749270d6
commit 36a447e344
11 changed files with 454 additions and 53 deletions

View file

@ -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`.
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções de estilo computado no e2e full-state da seção (fundo ≠ branco ≠ prova, hairline top presente, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.

View file

@ -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`.

View file

@ -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<br/>hidden lg:grid]
A --> C[Carrossel mobile existente<br/>lg:hidden]
end
B --> D[CampaignProblemCard / CampaignFlagCard<br/>components presentacionais compartilhados]
C --> D
E[styles.css<br/>@media min-width 64rem<br/>.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: `<div class="hidden lg:block px-(--campaign-carousel-inset)">` →
`<ol data-grid="problem|flags" class="grid grid-cols-3 gap-[21px]|gap-[14px]">` com os
components de card (li `h-[437px]` no problem; altura por conteúdo no flags);
- carrossel: wrapper `<div class="lg:hidden">` → `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.

View file

@ -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/<name>.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=<família> --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=<família>` (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.

View file

@ -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
}) => (
<div className="hidden px-(--campaign-content-inset) lg:block">
<ol
data-grid={gridKey}
aria-label={ariaLabel}
className={`m-0 grid list-none grid-cols-3 p-0 ${gapClassName}`}
>
{children}
</ol>
</div>
)
const problemItems: CampaignCarouselItem[] = [
{
@ -131,11 +161,24 @@ export default async function HomePage() {
</strong>
</p>
<div className="campaign-problem-carousel absolute right-0 left-0">
<CampaignCarousel
<CampaignCardGrid
gridKey="problem"
ariaLabel="Bandeiras que tornam esta eleição decisiva"
items={problemItems}
variant="problem"
/>
gapClassName="gap-[21px]"
>
{problemItems.map((item) => (
<li key={item.id} className="m-0 h-[437px]">
<CampaignProblemCard item={item} />
</li>
))}
</CampaignCardGrid>
<div className="lg:hidden">
<CampaignCarousel
ariaLabel="Bandeiras que tornam esta eleição decisiva"
items={problemItems}
variant="problem"
/>
</div>
</div>
</div>
</section>
@ -163,11 +206,24 @@ export default async function HomePage() {
Ministério da Saúde e da saúde da Bahia.
</p>
<div className="campaign-flags-carousel absolute right-0 left-0">
<CampaignCarousel
<CampaignCardGrid
gridKey="flags"
ariaLabel="Bandeiras da campanha"
items={flagItems}
variant="flags"
/>
gapClassName="gap-[14px]"
>
{flagItems.map((item) => (
<li key={item.id} className="m-0">
<CampaignFlagCard item={item} size="spacious" />
</li>
))}
</CampaignCardGrid>
<div className="lg:hidden">
<CampaignCarousel
ariaLabel="Bandeiras da campanha"
items={flagItems}
variant="flags"
/>
</div>
</div>
</div>
</section>

View file

@ -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);

View file

@ -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
<div
aria-label="Escolher bandeira"
data-carousel-chips
className="scrollbar-hide absolute -top-2.5 right-0 left-0 flex h-11 items-center gap-1.5 overflow-x-auto pr-2 lg:gap-[7px]"
className="scrollbar-hide absolute -top-2.5 right-0 left-0 flex h-11 items-center gap-1.5 overflow-x-auto pr-2"
>
{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' ? (
<article className="relative h-full overflow-hidden rounded-[10px] bg-[#2a0b08] font-[family-name:var(--font-arimo)] text-white">
{item.image && item.imageFrame ? (
<Image
src={item.image}
alt={item.imageAlt ?? ''}
width={item.imageFrame.width}
height={item.imageFrame.height}
sizes="(max-width: 393px) 137vw, 485px"
className={item.imageFrame.className}
/>
) : item.image ? (
<Image
src={item.image}
alt={item.imageAlt ?? ''}
fill
sizes="355px"
className="object-cover object-center"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(to_top,#2a0b08_0%,rgba(42,11,8,0.96)_23%,rgba(42,11,8,0.68)_34%,transparent_62%)]" />
<div className="absolute right-3.5 bottom-3.5 left-3.5">
<h3 className="m-0 border-0 p-0 text-[15px] leading-tight font-bold tracking-normal">
{item.title}
</h3>
<p className="m-0 mt-1 text-[12px] leading-[1.16] text-white/90">{item.body}</p>
</div>
</article>
<CampaignProblemCard item={item} />
) : (
<article className="h-full rounded-[10px] bg-(--campaign-surface) px-3.5 py-2.5 font-[family-name:var(--font-arimo)] text-black">
<h3 className="m-0 border-0 p-0 text-[14px] leading-[1.12] font-bold tracking-normal">
{item.title}
</h3>
<p className="m-0 mt-1 text-[12px] leading-[1.18] text-[#6f6f73]">{item.body}</p>
</article>
<CampaignFlagCard item={item} />
)}
</li>
))}

View file

@ -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 (
<article
className={`h-full rounded-[10px] bg-(--campaign-surface) ${padding} font-[family-name:var(--font-arimo)] text-black`}
>
<h3 className="m-0 border-0 p-0 text-[14px] leading-[1.12] font-bold tracking-normal">
{item.title}
</h3>
<p className="m-0 mt-1 text-[12px] leading-[1.18] text-[#6f6f73]">{item.body}</p>
</article>
)
}

View file

@ -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 }) => (
<article className="relative h-full overflow-hidden rounded-[10px] bg-[#2a0b08] font-[family-name:var(--font-arimo)] text-white">
{item.image && item.imageFrame ? (
<Image
src={item.image}
alt={item.imageAlt ?? ''}
width={item.imageFrame.width}
height={item.imageFrame.height}
sizes="(max-width: 393px) 137vw, 485px"
className={item.imageFrame.className}
/>
) : item.image ? (
<Image
src={item.image}
alt={item.imageAlt ?? ''}
fill
sizes="355px"
className="object-cover object-center"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(to_top,#2a0b08_0%,rgba(42,11,8,0.96)_23%,rgba(42,11,8,0.68)_34%,transparent_62%)]" />
<div className="absolute right-3.5 bottom-3.5 left-3.5">
<h3 className="m-0 border-0 p-0 text-[15px] leading-tight font-bold tracking-normal">
{item.title}
</h3>
<p className="m-0 mt-1 text-[12px] leading-[1.16] text-white/90">{item.body}</p>
</div>
</article>
)

View file

@ -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<HTMLElement>(selector)
if (!element) throw new Error(`Seção ausente: ${selector}`)
return element.getBoundingClientRect()
}
const gridBottom = (selector: string) => {
const grid = document.querySelector<HTMLElement>(selector)
if (!grid) throw new Error(`Grade ausente: ${selector}`)
const cards = Array.from(grid.querySelectorAll<HTMLElement>('li'))
return Math.max(...cards.map((card) => card.getBoundingClientRect().bottom))
}
const gridClipped = (selector: string) => {
const grid = document.querySelector<HTMLElement>(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<HTMLElement>('[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',
})

View file

@ -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)
}
})
})