S8 — Seção de vídeo da história na home de campanha #103

Merged
fsolla merged 2 commits from S8-s8-secao-de-video-da-historia-na-home-de-campanha into main 2026-08-19 22:23:07 +00:00
8 changed files with 330 additions and 0 deletions

3
.gitignore vendored
View file

@ -90,3 +90,6 @@ next-env.d.ts
# Proprietary design font files kept only in the local workspace
/public/arial-mt/
/data/encode/
/data/poster-candidates/

View file

@ -8,6 +8,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.
**S8** — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). **Divergência aprovada em execução pelo humano:** o vídeo é embutido do canal do deputado no YouTube (Short `i_fbclWWC5o`) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; `youtube-nocookie` (LGPD), `loading="lazy"` nativo (zero bytes até o frame se aproximar do viewport), `playsinline`, `allowfullscreen`, `title` a11y, `allow` com `compute-pressure` (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em `data/encode/` — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do `youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com `CampaignContentSection` quando S5 #33 terminar).
**OPS69** — o admin do Payload abria **em branco em produção** (P0, 2026-08-19): o `importMap` commitado (`src/app/(payload)/admin/importMap.js`) ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — o commit OPS52 (`dabc00f4`) removeu o handler do Vercel Blob e **não** adicionou o do S3, porque o `pnpm generate:importmap` rodou **sem** as envs `S3_*`: com o plugin `s3Storage` condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → `getFromImportMap: PayloadComponent not found in importMap` → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 `S3_*` dummy inline (a mecânica do CLI: `iterateConfig` sem DB; `loadEnvConfig` do `@next/env` não sobrescreve envs já no processo) → diff de **exatamente +2 linhas** (import + entrada do `S3ClientUploadHandler`), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — `/admin/login` renderiza (6 inputs, formulário visível, **zero erros de console**) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: `generate:importmap` precisa das `S3_*` setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — `depends` #77). Verificação pós-deploy: `/admin` abre o login e o log do container não re-emite o `getFromImportMap` para o handler (runbook `docs/ops/teqo-1313-deploy.md`).
**OPS62-FOLLOWUP** — flake do `municipalitiesWithoutUpdateTool.unit.spec.ts` (2 ocorrências 2026-08-18: gate local 19:22 e CI run 779 23:53) eliminado pela raiz, com a hipótese original da Issue refutada por reprodução. Causa raiz: o fixture do teste (não o ambiente nem locale/ICU): `daysAgo()` chamava `Date.now()` **por chamada**; as duas construções `daysAgo(40)` de `Beta`/`Alpha` podiam cair em milissegundos diferentes sob carga (GC/preempção do fork do vitest na máquina compartilhada), deixando o timestamp de `Alpha` estritamente mais novo — em `sortStale` (`getMunicipalitiesWithoutUpdate.ts`) o sort por timestamp vencia o tie-break por nome e o teste recebia `[Beta, Alpha]` no lugar de `[Alpha, Beta]` (só o par trocado, resto correto — "sort não atuou" estava errado; o sort atuou sobre timestamps acidentalmente desiguais). Repro mecânica (sort de produção verbatim + fixture): fixture atual 0.08% de falhas em 200k runs sem pausa e **100%** com pausa de 1 ms injetada; fixture ancorado 0 em 2000 runs. Fix (test-only, nada em produção — o sort real é determinístico sobre timestamps fixos do DB): âncora `fixtureNowMs = Date.now()` no load do módulo (padrão do `prioritiesTool.unit.spec.ts`) + hoist do timestamp empatado (`const stale40 = daysAgo(40)` compartilhado por `Beta`/`Alpha` — igualdade estrutural, imune até a regressão da âncora) + comentário explicando a classe de flake. Scan da mesma classe: `prioritiesTool` (âncora no load) e `suggestionCatalog` (now fixo) já seguros; nenhum outro spec combina `Date.now()` por chamada com assunção de igualdade. Verificação: 30/30 runs do spec, unit full (249 arquivos/2405 testes), int (89/791), tsc, lint, format, check:cycles verdes (knip só com o P3 ledgered de sempre). /simplify: 3 reviewers aprovaram; débito absorvido no próprio teste (comentário do porquê). Impl plan: `docs/plans/ops62-followup-ci-flake-fixture-timestamps-impl.md`.

View file

@ -0,0 +1 @@
**S8** — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). **Divergência aprovada em execução pelo humano:** o vídeo é embutido do canal do deputado no YouTube (Short `i_fbclWWC5o`) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; `youtube-nocookie` (LGPD), `loading="lazy"` nativo (zero bytes até o frame se aproximar do viewport), `playsinline`, `allowfullscreen`, `title` a11y, `allow` com `compute-pressure` (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em `data/encode/` — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do `youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com `CampaignContentSection` quando S5 #33 terminar).

View file

@ -0,0 +1,172 @@
# Impl: Seção de vídeo da história na home de campanha (S8)
Status: em execução
Atualizado em: 2026-08-19
Issue: #91
Intenção: docs/plans/secao-video-historia-home.md
Appetite restante: herdado (~1 dia eng)
## Divergência de produto aprovada pelo humano (2026-08-19)
**Embed do YouTube no lugar de self-hosted.** O plano de intenção previa asset próprio
(WebM/AV1 + MP4/H.264 em `public/videos/`, lazy total, poster extraído/fornecido). Durante a
execução o humano constatou que o vídeo já está publicado no canal do deputado
(https://www.youtube.com/shorts/i_fbclWWC5o — "18 de agosto de 2026", confirmado pelo dono do
produto como o vídeo da história; a assessoria usa títulos datados) e **decidiu embutir o
YouTube**, divergindo do aceite "sem dependência de terceiros". Motivação do produto: cada play
embutido conta como view no canal (alimenta o algoritmo; o canal já é vitrine na seção
"Acompanhe de perto"). Aceite atualizado:
- Seção com o vídeo da história via **iframe `youtube-nocookie`** (LGPD), `playsinline`,
`loading="lazy"` nativo (iframe só é buscado perto do viewport), frame 9:16, `allowFullScreen`,
título a11y.
- **Deixa de valer:** lazy-until-click total, poster próprio, chip de duração, orçamento de
~15 MB de assets no repo (nada é commitado; os encodes one-off ficam em `data/encode/`,
gitignored, como artefato local caso se volte a self-hosted).
- **Mantém-se:** posição após "Bandeiras", cenas desktop (2 colunas) e mobile (empilhado) do
rascunho, CTA "Assistir à história" (agora rola até o player), zero schema/Consent, seções
existentes intactas.
## Leitura da intenção
Outcome e aceite da intenção (docs/plans/secao-video-historia-home.md), com a divergência
aprovada abaixo: seção com o vídeo da história na home pública, posição após "Bandeiras",
cenas desktop/mobile do rascunho aprovado, zero schema/migration/Consent, seções existentes
intactas, original de 236 MB fora do repo. O que permanece NÃO negociável: copy/pt-BR e
identificadores em inglês; lazy (nada pesado baixa sem o usuário pedir — o `loading="lazy"` do
iframe cobre isso); sem autoplay forçado; seções existentes intocadas.
## Abordagem recomendada
```mermaid
flowchart LR
YT["YouTube Short i_fbclWWC5o (canal do Solla)"] --> EMB["iframe youtube-nocookie (9:16, lazy, playsinline)"]
EMB --> SEC["CampaignStorySection (client island)"]
SEC --> HOME["src/app/(frontend)/(home)/page.tsx — após seção bandeiras"]
```
**Opções consideradas:** A) iframe do YouTube (decisão do humano) | B) `<video>` nativo
self-hosted com WebM/AV1 + MP4/H.264 (plano original)
**Recomendação (humano):** A — o vídeo já vive no canal; embed soma views ao canal e evita
15 MB de asset no repo; troca de vídeo sem deploy.
**Rejeitadas (pelo humano, sobre o plano aprovado):** B — dependência própria do aceite; os
artefatos de encode já produzidos (14.9 MB total, validados por design-vision) ficam em
`data/encode/` como fallback local.
### Decisões de engenharia
- **Embed (decisão do humano, supersede o formato/encode do plano original).** iframe
`youtube-nocookie.com/embed/i_fbclWWC5o` (privacy LGPD) com `playsinline=1&rel=0`, 9:16,
`loading="lazy"` (busca só perto do viewport), `allow` (autoplay, fullscreen, gyroscope…),
`allowFullScreen`, `title` a11y. Rejeitadas: `youtube.com/embed` (versão com cookies), o
padrão de overlay próprio + click (não dá para disparar play cross-origin sem o IFrame API —
duplo clique no iOS, fragilidade sem ganho).
- **Formato/encode (histórico — não entra).** A conversão one-off foi executada e validada
(MP4 H.264 720×1280 CRF 27 + WebM AV1 810×1440 CRF 39, 14.9 MB total, poster AVIF da foto
JOA00327 escolhida pelo humano) e fica como artefato local em `data/encode/` (gitignored) —
fallback pronto se um dia se voltar a self-hosted. Nada disso é commitado.
- **Posição na home.** Opções: A) após conteúdos | B) após "Bandeiras" (fim da página, antes do
footer) | C) página própria. **Recomendação: B** (da intenção) — zona de conversão acima da
dobra intacta, fechamento emocional no fim, semente da seção 6 "Quem é Jorge Solla" do plano do
site. Rejeitadas: A porque compete com o bloco do problema no funil; C porque estoura o
appetite de "uma seção na home".
- **CTA "Assistir à história".** Botão que rola (smooth) até o player — play cross-origin não é
disparável de fora do iframe; o visitante chega no frame com o player do YouTube à mostra.
- **Estilos.** Seguir o padrão atual `CampaignContentSection` (container `max-w-[1160px]`,
`px-5 sm:px-8 lg:px-10 py-12 lg:py-16`, tokens `--campaign-band`/`--campaign-line`/
`--campaign-muted`/`--pt-red`) — seção em fluxo, não o padrão absoluto de `problem`/`flags`.
Fundo branco (contraste entre `--campaign-band` de bandeiras e o footer `#180a09`).
### Componentes / mudanças
- **`CampaignStorySection`** (`src/components/CampaignStorySection.tsx`): client island (`'use
client'`, precedente `CampaignCarousel`) — copy (eyebrow/título/copy do rascunho), frame 9:16
com iframe do YouTube (nocookie, lazy, playsinline); CTA rola até o player.
`data-home-section="story"`.
- **`page.tsx`** (`src/app/(frontend)/(home)/page.tsx`): inserir `<CampaignStorySection />` após
a seção `bandeiras`, antes do footer. Nenhuma seção existente é tocada.
- **Assets:** nenhum commitado (o vídeo vive no YouTube; encodes em `data/encode/` gitignored).
- **Migration:** sem migration (zero schema).
- **Access / Consent:** N/A — conteúdo estático público.
- **UI:** Impeccable B — seguir o rascunho aprovado (desktop 2 colunas / mobile empilhado);
shape → craft → critique → polish contra o rascunho; verificação mobile 320–430px (o frame
9:16 full-width fica alto — checar folga vertical e sem overflow horizontal).
### Dados → forma (se aplicável)
- N/A: sem métrica/KPI. Nenhum dado estático (o player do YouTube traz o próprio thumbnail e
duração).
## Fases verificáveis
1. ~~**Encode (tracer)**~~ — executado como fallback antes da divergência: encodes H.264 + AV1
(14.9 MB) + poster AVIF da foto JOA00327 (validados por design-vision), retidos em
`data/encode/` (gitignored), fora do commit.
2. **UI** — `CampaignStorySection` (embed) + wiring no page; shape/craft contra o rascunho;
crítica visual (design-vision) + polish; e2e novo em `frontend.e2e.spec.ts` (seção presente
após bandeiras, iframe com src nocookie + `loading="lazy"` + `allowfullscreen`, CTA rola até
o player, mobile sem overflow; requests do YouTube abortados no teste para o CI ficar
hermético).
3. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`,
`pnpm test`, `pnpm build`; e2e no CI; push via `pnpm push`.
## Conflitos de rebase resolvidos (2026-08-19)
- **S9 mergeou em main durante a execução** e inseriu `CampaignNewsletterSection` no mesmo ponto
(após bandeiras, antes do footer). Resolução: **história → newsletter → footer** — o funil
põe o "ask" (formulário de captura) depois do fechamento emocional; S9 declara "logo acima do
rodapé" e S8 "após bandeiras"; ambas as intenções ficam satisfeitas.
- **Changelog agregado**: conflito insert-only resolvido com `git checkout --theirs` +
`pnpm changelog:build` (S8 re-anexado sobre o agregado com S7/S9).
- Nota de ambiente: o e2e local (dev server) acusa warnings de React keys em
`CampaignColumnPicker`/omnibox de assessores (B137/B197, era C139) que o guard falha — são
**dev-only** (build de produção do CI não emite warnings de keys), pré-existentes em main e
fora do escopo S8.
## Débitos (capture-review-debts — triage autônoma)
- **Absorvidos nesta entrega:** JSDoc enxuto; e2e sem `toHaveURL` redundante; asserts de
desktop (2 colunas, centro vertical) + `allow`/`playsinline`; plano sem conteúdo morto da
era self-hosted; `compute-pressure` no `allow` do iframe (silencia o console.error do
player do YouTube — guard do e2e falha em console.error) + abort global do
`youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI, mesma filosofia dos stubs de
feed).
- **Diferidos (registrados aqui, não como Issue):** (1) rótulo do CTA "Assistir à história"
promete ação que só rola — copy aprovada no gate, alternativa mais honesta ("Ver a história
em vídeo") se a assessoria reabrir o copy; (2) classe do CTA duplica
`sectionHeaderLinkClassName` de `CampaignContentSection` — consolidar quando S5 (#33)
terminar a separação da seção de conteúdos (evitar conflito com trabalho in-progress).
- **Descartados:** Safari <16.4/Firefox <121 ignoram `loading="lazy"` em iframe (carrega
eager — degradação graciosa, sem ação); artefatos de encode self-hosted em `data/encode/`
(gitignored, fallback documentado).
## Rabbit holes / Não escopo (engenharia)
- Player JS / playlist / tracking de views próprias (o embed já conta views no YouTube) /
analytics de playback.
- Overlay próprio + click para "autoplay" do embed (cross-origin; IFrame API é fragilidade sem
ganho — o player do YouTube já tem o próprio play).
- Pipeline de transcodificação versionado no repo (conversão one-off local, não commitada).
- Legendas/transcrição (item futuro separado, fora do escopo da intenção).
- Trocar o vídeo por outro Short: mudança de ID no `src` do iframe (um lugar).
## Riscos e mitigação
- **Embed depender do YouTube** (risco aceito pelo humano): se o Short cair/for bloqueado, a
seção degrada para o quadro cinza com o player vazio. Mitigação: o fallback self-hosted
(artefatos em `data/encode/`) fica pronto para reverter em horas.
- **Short trocado/ID errado** → troca de ID no `src` do iframe (um lugar).
- **CI/e2e tocando o YouTube** → `page.route` aborta `youtube-nocookie.com` no teste; os
asserts são estruturais (src, lazy, allowfullscreen, posição), não de conteúdo do player.
- **Iframe pesado no mobile** → `loading="lazy"` nativo (busca só perto do viewport) + seção no
fim da página.
## Aceite de engenharia
- [x] Aceite de produto (atualizado pelo humano em 2026-08-19): seção com o vídeo da história
via embed do YouTube (nocookie, playsinline, lazy, 9:16), posição após Bandeiras, zero
schema, seções existentes intactas
- [x] Invariantes AGENTS/engineering-standards (sem schema/Consent/access; i18n pt-BR na UI,
identificadores em inglês; sem tocar seções existentes)
- [x] Testes previstos: e2e da seção (estrutura + scroll + mobile) — sem unit (JSX estático;
nenhuma lógica de domínio pura a testar)

View file

@ -4,6 +4,7 @@ import { CampaignFlagCard } from '@/components/CampaignFlagCard'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import type { ReactNode } from 'react'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
@ -229,6 +230,7 @@ export default async function HomePage() {
</div>
</section>
<CampaignStorySection />
<CampaignNewsletterSection />
</main>

View file

@ -0,0 +1,67 @@
'use client'
import { useRef } from 'react'
/**
* S8 — campaign home "Nossa história" section: the story video of Jorge
* Solla's trajectory embedded from the campaign's YouTube channel (the video
* lives there as a Short; every embed play counts as a channel view).
* `youtube-nocookie` keeps the embed LGPD-friendly; `loading="lazy"` defers
* the fetch until the frame nears the viewport.
*/
export const CampaignStorySection = () => {
const frameRef = useRef<HTMLDivElement>(null)
const handleCtaPlay = () => {
frameRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return (
<section
aria-labelledby="story-title"
data-home-section="story"
className="border-y border-(--campaign-line) bg-white"
>
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
<div className="flex flex-col items-start gap-8 lg:flex-row lg:items-center lg:justify-between lg:gap-12">
<div className="max-w-md">
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Nossa história
</p>
<h2
id="story-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Conheça a trajetória de Jorge Solla
</h2>
<p className="campaign-section-copy m-0 mt-2 text-(--campaign-muted)">
A história de quem já fez saúde pública na Bahia e no Brasil, em um minuto e meio: da
gestão no SUS ao mandato na Câmara.
</p>
<button
type="button"
onClick={handleCtaPlay}
className="mt-6 text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
Assistir à história
</button>
</div>
<div
ref={frameRef}
className="relative aspect-[9/16] w-full max-w-[420px] shrink-0 self-center overflow-hidden rounded-2xl bg-(--campaign-band) lg:max-w-[300px] lg:self-auto"
>
<iframe
src="https://www.youtube-nocookie.com/embed/i_fbclWWC5o?playsinline=1&rel=0"
title="Vídeo: Conheça a trajetória de Jorge Solla"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</div>
</div>
</section>
)
}

View file

@ -102,6 +102,11 @@ export const test = base.extend<E2EFailureGuardFixtures>({
page.on('response', onResponse)
context.on('weberror', onWebError)
// S8 — the home story-video embed is a YouTube iframe; e2e must stay
// hermetic (CI network must never gate a home-page test on the real
// player loading — same spirit as the S2/S3 feed stubs).
await context.route('https://www.youtube-nocookie.com/**', (route) => route.abort())
await use()
page.off('console', onConsole)

View file

@ -432,6 +432,84 @@ test.describe('Frontend', () => {
}
})
test('shows the story video section after the flags with the YouTube embed', async ({ page }) => {
await page.goto('/')
const story = page.locator('[data-home-section="story"]')
await expect(story).toBeVisible()
// Position: after the flags section, before the footer.
const order = await page.evaluate(() => {
const top = (selector: string) => {
const element = document.querySelector<HTMLElement>(selector)
if (!element) throw new Error(`Seção ausente: ${selector}`)
return element.getBoundingClientRect().top
}
return {
flagsTop: top('[data-home-section="flags"]'),
storyTop: top('[data-home-section="story"]'),
footerTop: top('footer'),
}
})
expect(order.storyTop).toBeGreaterThan(order.flagsTop)
expect(order.footerTop).toBeGreaterThan(order.storyTop)
// Draft copy (eyebrow + title).
await expect(story.getByText('Nossa história')).toBeVisible()
await expect(
story.getByRole('heading', { name: 'Conheça a trajetória de Jorge Solla' }),
).toBeVisible()
// The embed: youtube-nocookie (LGPD), native lazy, fullscreen, a11y title.
const embed = story.locator('iframe')
await expect(embed).toBeVisible()
await expect(embed).toHaveAttribute(
'src',
/youtube-nocookie\.com\/embed\/i_fbclWWC5o.*playsinline=1/,
)
await expect(embed).toHaveAttribute('loading', 'lazy')
await expect(embed).toHaveAttribute('allow', /autoplay/)
await expect(embed).toHaveAttribute('allowfullscreen', '')
await expect(embed).toHaveAttribute('title', /trajetória de Jorge Solla/)
// The header CTA scrolls to the player.
await story.getByRole('button', { name: 'Assistir à história' }).click()
await expect(embed).toBeInViewport()
// Desktop scene of the draft: copy column left, frame right, vertically
// centered.
await page.setViewportSize({ width: 1440, height: 900 })
const desktopLayout = await page.evaluate(() => {
const storySection = document.querySelector<HTMLElement>('[data-home-section="story"]')
if (!storySection) throw new Error('Seção ausente')
const copy = storySection.querySelector<HTMLElement>('div.max-w-md')
const frame = storySection.querySelector<HTMLElement>('iframe')?.parentElement
if (!copy || !frame) throw new Error('Colunas ausentes')
const copyBox = copy.getBoundingClientRect()
const frameBox = frame.getBoundingClientRect()
return {
copyRight: Math.round(copyBox.right),
frameLeft: Math.round(frameBox.left),
centerDelta: Math.round(
copyBox.top + copyBox.height / 2 - (frameBox.top + frameBox.height / 2),
),
}
})
expect(desktopLayout.copyRight).toBeLessThan(desktopLayout.frameLeft)
expect(Math.abs(desktopLayout.centerDelta)).toBeLessThanOrEqual(2)
// The frame keeps the 9:16 ratio on mobile without horizontal overflow.
await page.setViewportSize({ width: 390, height: 844 })
const frame = await embed.boundingBox()
expect(frame).not.toBeNull()
expect(frame!.height / frame!.width).toBeCloseTo(16 / 9, 1)
const overflow = await page.evaluate(() => {
const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0
})
expect(overflow).toBeLessThanOrEqual(1)
})
test('keeps carousel auto-advance and chips on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')