feat(S6): home de campanha sem carrossel no desktop — grade estática em ≥1024px, mobile preservado
This commit is contained in:
parent
50749270d6
commit
36a447e344
11 changed files with 454 additions and 53 deletions
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-18-s6.md
Normal file
1
docs/changelog/2026-08-18-s6.md
Normal 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`.
|
||||
162
docs/plans/home-campanha-sem-carrossel-desktop-impl.md
Normal file
162
docs/plans/home-campanha-sem-carrossel-desktop-impl.md
Normal 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.
|
||||
37
docs/plans/s6-followup-playwright-filtros-cli-ignorados.md
Normal file
37
docs/plans/s6-followup-playwright-filtros-cli-ignorados.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
24
src/components/CampaignFlagCard.tsx
Normal file
24
src/components/CampaignFlagCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/CampaignProblemCard.tsx
Normal file
44
src/components/CampaignProblemCard.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue