feat(S8): seção 'Nossa história' na home — vídeo da trajetória embutido do YouTube (Closes #91)
This commit is contained in:
parent
398a91e54a
commit
eebe81cd1e
8 changed files with 317 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -90,3 +90,6 @@ next-env.d.ts
|
||||||
|
|
||||||
# Proprietary design font files kept only in the local workspace
|
# Proprietary design font files kept only in the local workspace
|
||||||
/public/arial-mt/
|
/public/arial-mt/
|
||||||
|
|
||||||
|
/data/encode/
|
||||||
|
/data/poster-candidates/
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,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`.
|
**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`).
|
**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`.
|
**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`.
|
||||||
|
|
|
||||||
1
docs/changelog/2026-08-19-s8.md
Normal file
1
docs/changelog/2026-08-19-s8.md
Normal 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).
|
||||||
159
docs/plans/secao-video-historia-home-impl.md
Normal file
159
docs/plans/secao-video-historia-home-impl.md
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# 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`.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
@ -4,6 +4,7 @@ import { CampaignFlagCard } from '@/components/CampaignFlagCard'
|
||||||
import { CampaignFooter } from '@/components/CampaignFooter'
|
import { CampaignFooter } from '@/components/CampaignFooter'
|
||||||
import { CampaignHero } from '@/components/CampaignHero'
|
import { CampaignHero } from '@/components/CampaignHero'
|
||||||
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
||||||
|
import { CampaignStorySection } from '@/components/CampaignStorySection'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
|
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
|
||||||
|
|
||||||
|
|
@ -229,6 +230,7 @@ export default async function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<CampaignStorySection />
|
||||||
<CampaignNewsletterSection />
|
<CampaignNewsletterSection />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
67
src/components/CampaignStorySection.tsx
Normal file
67
src/components/CampaignStorySection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,11 @@ export const test = base.extend<E2EFailureGuardFixtures>({
|
||||||
page.on('response', onResponse)
|
page.on('response', onResponse)
|
||||||
context.on('weberror', onWebError)
|
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()
|
await use()
|
||||||
|
|
||||||
page.off('console', onConsole)
|
page.off('console', onConsole)
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
test('keeps carousel auto-advance and chips on mobile', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 })
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue