From f05ddc2a672cade9ab06a66dbfd475b940ee3663 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 19 Aug 2026 16:13:31 -0300 Subject: [PATCH] feat(S7): cards 'Acompanhe de perto' sem raio, borda, sombra ou chip; capa de borda a borda (Closes #90) --- ...cards-acompanhe-de-perto-polimento-impl.md | 68 +++++++++++++++++++ src/components/CampaignContentCard.tsx | 23 ++----- src/components/CampaignContentSection.tsx | 4 -- src/utilities/posts.ts | 8 --- tests/e2e/frontend.e2e.spec.ts | 19 ++++-- tests/unit/postsDateFormats.unit.spec.ts | 13 +--- 6 files changed, 90 insertions(+), 45 deletions(-) create mode 100644 docs/plans/cards-acompanhe-de-perto-polimento-impl.md diff --git a/docs/plans/cards-acompanhe-de-perto-polimento-impl.md b/docs/plans/cards-acompanhe-de-perto-polimento-impl.md new file mode 100644 index 00000000..30d15bef --- /dev/null +++ b/docs/plans/cards-acompanhe-de-perto-polimento-impl.md @@ -0,0 +1,68 @@ +# Impl: Cards da seção "Acompanhe de perto": sem cantos arredondados, imagem de borda a borda, chip removido + +Status: aprovado +Atualizado em: 2026-08-19 +Issue: #90 +Intenção: docs/plans/cards-acompanhe-de-perto-polimento.md +Appetite restante: herdado (~0,5–1 dia; na prática, horas) + +## Leitura da intenção + +- **Outcome:** os cards da seção "Acompanhe de perto" (bento 1+4 no desktop, carrossel no mobile) passam a ter cantos retos (card e capa), capa de borda a borda, **sem borda e sem sombra no card** (decisão do humano em 2026-08-19, após o gate — estende o rascunho, que mantinha `border`/`shadow`) e nenhum chip de badge — nem tipos de post do site, nem origens externas (decisão B do gate). O texto interno (título, subtítulo, meta) continua com o respiro atual (`p-3`) em relação às bordas. +- **O que NÃO negociar:** nada além da superfície muda — compartilhamento (S4) usável no canto, grade do bento, carrossel, links, bandas do S5 (borda/background da seção) e o fail-closed da seção (some inteira quando nada é visível). Nada de tokens de cor, capas reais, proporções ou outras seções. +- **O que reavaliar:** a hipótese da intenção ("mover o padding para o bloco de texto e remover o badge; dados de badge tendem a ficar órfãos — o executor decide o que descartar"). Confirmado no código: `badgeLabel` vive só no card + nas três funções de mapeamento da seção + `POST_TYPE_BADGE_LABELS` (com um unit test dedicado). Decisão de engenharia: remover os órfãos por completo (tipo do card, constante e teste unitário) — manter morto seria dead code flagrável pelo knip (CI-blocking). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[CampaignContentCard.tsx
superfície do card] --> B["card: -rounded-xl -p-3 -gap
capa: -rounded-lg
texto: +p-3
badge span: removido"] + A --> C["CampaignContentCardData
-badgeLabel"] + D[CampaignContentSection.tsx] --> E["-badgeLabel nas 3 funções
-import POST_TYPE_BADGE_LABELS"] + F[posts.ts] --> G["-POST_TYPE_BADGE_LABELS"] + H[postsDateFormats.unit.spec.ts] --> I["-describe POST_TYPE_BADGE_LABELS"] + J[frontend.e2e.spec.ts] --> K["badge loop: 812-814,
YouTube: 952,
Instagram: 1153
→ ausência + raio 0"] +``` + +**Opções consideradas:** A (superfície no card único + remoção dos órfãos) | B (variante/flag de card, ex. `variant="flat"`) | C (manter `POST_TYPE_BADGE_LABELS` e o badge no DOM com CSS oculto) +**Recomendação:** A — o card é o único componente da seção (bento e carrossel reusam `CampaignContentCard`); não há outro consumidor com cantos arredondados para preservar, então uma variante seria abstração sem dono. A remoção dos dados de badge é limpa: `badgeLabel` não é usado em mais nenhum lugar do repo. +**Rejeitadas:** B (over-engineering: nenhum outro card usa o componente; "edit the owner, don't twin"); C (dead code + dead data path; knip derruba o CI e o teste unitário continuaria pinando um contrato morto). + +### Componentes / mudanças + +- **`CampaignContentCard.tsx`** (`src/components/`): `cardClassName` vira constante sem `featured` (o único uso do param era o `gap-3`/`gap-2`; `featured` continua dirigindo `sizes` da imagem e o `h3`): `flex h-full flex-col bg-white transition-shadow ...` — sem `rounded-xl`, sem `p-3`, sem `gap`, **sem `border border-(--campaign-line)` e sem `shadow-sm`/`hover:shadow-md`/`transition-shadow`**. Capa: remove `rounded-lg` (mantém `overflow-hidden` — o zoom hover continua contido na própria capa). Bloco de texto: ganha `p-3` (`flex flex-1 flex-col gap-1 p-3`), igual ao draft. Badge `` removido; `badgeLabel` sai de `CampaignContentCardData`. O anel de foco (`focus-visible:ring-2 ring-(--pt-red)`) do link permanece — a11y. +- **`CampaignContentSection.tsx`** (`src/components/`): `badgeLabel` removido de `toArticleCardData`/`toVideoCardData`/`toInstagramCardData`; import `POST_TYPE_BADGE_LABELS` removido. Nada mais muda (limites, mesclagem, capas, metas). +- **`src/utilities/posts.ts`**: deleta `POST_TYPE_BADGE_LABELS` (único consumidor era a seção; `POST_TYPE_LABELS` plural fica — usado nas listagens). +- **`tests/unit/postsDateFormats.unit.spec.ts`**: import e describe de `POST_TYPE_BADGE_LABELS` removidos. +- **Migration:** sem migration (nenhuma mudança de schema/coleção). +- **Access / Consent:** nenhum. +- **UI:** Impeccable B — encaixe em um componente existente (shape→craft→critique→polish). O botão `ContentShareButton` (S4) segue `absolute top-2 right-2` sobre a capa, agora sem moldura; o draft mantém o botão no canto — sem mudança nele. + +### Dados → forma (se aplicável) + +- N/A — nenhum dado novo; apenas remoção de um campo de superfície (`badgeLabel`) do tipo de dados do card. + +## Fases verificáveis + +1. **Superfície + órfãos** — `CampaignContentCard.tsx`, `CampaignContentSection.tsx`, `posts.ts`, unit test do badge; verificação: `pnpm typecheck` + `pnpm test:unit` (o describe removido some, o resto passa). +2. **e2e** — `tests/e2e/frontend.e2e.spec.ts`: loop de badges (812–814) e as asserções `YouTube`/`Instagram` (952, 1153) → asserções de ausência (`toHaveCount(0)` para os quatro tipos + YouTube/Instagram nos testes de feed) e pin do novo visual no teste do bento (`border-radius: 0px` no card e na capa, `border-top-width: 0px` e `box-shadow: none` no card, no mesmo espírito dos pins CSS do S5). `toHaveCSS` com `first()` para não quebrar com o carrossel escondido no mobile. +3. **Gates** — `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int). E2e roda no CI (OPS59) — local opcional com `pnpm test:e2e:affected`. + +## Rabbit holes / Não escopo (engenharia) + +- Não tocar `ContentShareButton`, `CampaignContentCarousel`, banda/bordas da seção (S5), `POST_TYPE_LABELS` (plural), `getVisiblePosts`/fail-closed ou capas. O rascunho HTML do gate fica intocado como artefato — a remoção de borda/sombra é decisão do humano posterior ao gate e não altera o arquivo do rascunho. +- Não criar variante de card, não esconder badge via CSS, não renomear `h3`/metas. +- Cuidado com o `gap` removido: o `mt-auto` da meta continua pinando a data ao rodapé do bloco; o `p-3` do bloco de texto é quem dá o respiro (draft). + +## Riscos e mitigação + +- **e2e que assere badge que não mapeei** — verificado por grep: só 812–814 (loop), 952 (YouTube) e 1153 (Instagram) no `frontend.e2e.spec.ts`; `waitForHomeHTML` só usa títulos/links de cabeçalho. Grep final antes do push. +- **Raio 0 pinado pelo e2e pode flake por arredondamento** — `toHaveCSS('border-radius', '0px')` é determinístico (nenhum token de raio no card/capa). +- **Dead code residual** — `POST_TYPE_BADGE_LABELS` e `badgeLabel` removidos do mesmo PR; knip no gate confirma. +- **Capa sem cover (placeholder `--campaign-band`)** — continua com borda a borda e cantos retos, igual ao draft; nada quebra. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (raio 0 card+capa, capa borda a borda, texto `p-3`, nenhum chip em nenhum card, **sem borda e sem sombra no card**) +- [ ] Invariantes AGENTS/engineering-standards (card único como dono da superfície; S5/bandas intocadas; fail-closed da seção intacto) +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum write path muda); e2e atualizados para o novo estado visual diff --git a/src/components/CampaignContentCard.tsx b/src/components/CampaignContentCard.tsx index b51ce76b..8571b793 100644 --- a/src/components/CampaignContentCard.tsx +++ b/src/components/CampaignContentCard.tsx @@ -7,7 +7,6 @@ export type CampaignContentCardData = { id: string | number href: string title: string - badgeLabel: string meta: string /** Source of the content — drives the WhatsApp share message template. */ shareKind: ContentShareKind @@ -25,10 +24,8 @@ type CampaignContentCardProps = { featured?: boolean } -const cardClassName = (featured: boolean) => - `flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${ - featured ? 'gap-3' : 'gap-2' - }` +const cardClassName = + 'flex h-full flex-col bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red)' /** * Presentational content card for the campaign home content bento/carousel — @@ -43,7 +40,7 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC const inner = ( <>
@@ -56,11 +53,8 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC className="object-cover transition-transform duration-300 group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100" /> ) : null} - - {card.badgeLabel} -
-
+

{card.external ? ( - + {inner} ) : ( - + {inner} )} diff --git a/src/components/CampaignContentSection.tsx b/src/components/CampaignContentSection.tsx index f0c0e259..4fe90bf6 100644 --- a/src/components/CampaignContentSection.tsx +++ b/src/components/CampaignContentSection.tsx @@ -6,7 +6,6 @@ import { getCategoryName, getPostCanonicalPath, getVisiblePosts, - POST_TYPE_BADGE_LABELS, } from '@/utilities/posts' import { getInstagramFeed, type InstagramPost } from '@/utilities/socialFeed/instagramFeed' import { @@ -47,7 +46,6 @@ const toArticleCardData = (post: Post): DatedCard | null => { id: post.id, href, title: post.title, - badgeLabel: POST_TYPE_BADGE_LABELS[post.type], shareKind: 'article' as const, meta: [formatRelativePostDate(post.publishedDate), categoryName].filter(Boolean).join(' · '), ...(cover?.url ? { coverUrl: cover.url, coverAlt: cover.alt ?? undefined } : {}), @@ -62,7 +60,6 @@ const toVideoCardData = (video: YouTubeVideo): DatedCard => ({ id: `yt:${video.id}`, href: `https://www.youtube.com/watch?v=${video.id}`, title: video.title, - badgeLabel: 'YouTube', shareKind: 'video' as const, meta: [ formatRelativePostDate(video.publishedAt), @@ -81,7 +78,6 @@ const toInstagramCardData = (post: InstagramPost): DatedCard => ({ id: `ig:${post.id}`, href: post.permalink, title: post.caption ?? 'Publicação no Instagram', - badgeLabel: 'Instagram', shareKind: 'instagram' as const, meta: formatRelativePostDate(post.timestamp) ?? '', coverAspect: 'square', diff --git a/src/utilities/posts.ts b/src/utilities/posts.ts index 49307764..5a02869f 100644 --- a/src/utilities/posts.ts +++ b/src/utilities/posts.ts @@ -17,14 +17,6 @@ export const POST_TYPE_LABELS: Record = { evento: 'Eventos', } -/** Singular labels for per-card badges (e.g. the campaign home content bento). */ -export const POST_TYPE_BADGE_LABELS: Record = { - noticia: 'Notícia', - campanha: 'Campanha', - artigo: 'Artigo', - evento: 'Evento', -} - export const isPostType = (value: string): value is PostType => (POST_TYPES as readonly string[]).includes(value) diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 342441ef..b60608cb 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -809,8 +809,10 @@ test.describe('Campaign home content section', () => { 'a[href^="/artigo/"], a[href^="/noticia/"], a[href^="/campanha/"], a[href^="/evento/"]', ) await expect(bentoCards.filter({ visible: true })).toHaveCount(5) - for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento']) { - await expect(section.getByText(badge, { exact: true }).first()).toBeVisible() + // S7 — cards are badge-free: no type chip (site or external source) + // anywhere in the section. + for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento', 'YouTube', 'Instagram']) { + await expect(section.getByText(badge, { exact: true })).toHaveCount(0) } await expect(section.getByText('E2e Artigo em destaque').first()).toBeVisible() await expect(section.getByText('E2e Conteúdo oculto')).toHaveCount(0) @@ -835,6 +837,13 @@ test.describe('Campaign home content section', () => { await expect(section).toHaveCSS('border-top-color', 'rgba(0, 0, 0, 0.12)') await expect(firstCard).toHaveCSS('background-color', 'rgb(255, 255, 255)') + // S7 — flat editorial cards: no radius, border or shadow (the white card + // reads against the band on shape alone), cover edge to edge. + await expect(firstCard).toHaveCSS('border-radius', '0px') + await expect(firstCard).toHaveCSS('border-top-width', '0px') + await expect(firstCard).toHaveCSS('box-shadow', 'none') + await expect(firstCard.locator('.aspect-video')).toHaveCSS('border-radius', '0px') + await featured.click() await expect(page).toHaveURL(new RegExp(`${featuredHref}$`)) await page.goBack() @@ -949,7 +958,8 @@ test.describe('Campaign home content section', () => { await expect( section.getByText('há 30 minutos · 12,4 mil visualizações').first(), ).toBeVisible() - await expect(section.getByText('YouTube', { exact: true }).first()).toBeVisible() + // S7 — no external-source chip on YouTube cards anymore. + await expect(section.getByText('YouTube', { exact: true })).toHaveCount(0) await expect(section.getByText('E2e Vídeo excluído')).toHaveCount(0) await expect(section.getByRole('link', { name: /E2e Artigo no mix/ }).first()).toBeVisible() @@ -1150,7 +1160,8 @@ test.describe('Campaign home content section', () => { await expect(featured).toHaveAttribute('href', 'https://www.instagram.com/p/e2e-ig-muro-1/') await expect(featured).toHaveAttribute('target', '_blank') await expect(featured).toHaveAttribute('rel', 'noopener noreferrer') - await expect(section.getByText('Instagram', { exact: true }).first()).toBeVisible() + // S7 — no external-source chip on Instagram cards anymore. + await expect(section.getByText('Instagram', { exact: true })).toHaveCount(0) await expect(section.getByText('há 20 minutos').first()).toBeVisible() await expect(section.getByText('E2e Post de grade')).toHaveCount(0) diff --git a/tests/unit/postsDateFormats.unit.spec.ts b/tests/unit/postsDateFormats.unit.spec.ts index 6f763389..994a88c7 100644 --- a/tests/unit/postsDateFormats.unit.spec.ts +++ b/tests/unit/postsDateFormats.unit.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { formatRelativePostDate, POST_TYPE_BADGE_LABELS } from '@/utilities/posts' +import { formatRelativePostDate } from '@/utilities/posts' describe('formatRelativePostDate', () => { afterEach(() => { @@ -40,14 +40,3 @@ describe('formatRelativePostDate', () => { expect(formatRelativePostDate('2026-07-17T12:00:00Z')).toBe('17 de julho de 2026') }) }) - -describe('POST_TYPE_BADGE_LABELS', () => { - it('spells the four types in the singular for card badges', () => { - expect(POST_TYPE_BADGE_LABELS).toEqual({ - noticia: 'Notícia', - campanha: 'Campanha', - artigo: 'Artigo', - evento: 'Evento', - }) - }) -})