feat(S24): rádio na home — embed direto do zeno.fm, sem facade (Closes #1235)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

This commit is contained in:
Francisco Solla 2026-09-20 21:42:11 -03:00
parent 908a31eea4
commit 5aefb2f3ba
13 changed files with 280 additions and 482 deletions

View file

@ -0,0 +1 @@
**S24 — Rádio na home: embed direto do zeno.fm, sem facade (2026-09-20):** a Rádio Jorge Solla 1313 toca no primeiro play, sem passo intermediário — a seção de som da home troca a facade click-to-load do S22 pelo **embed oficial do zeno.fm montado já no carregamento da página** (decisão explícita do humano; reverte de propósito a razão da facade, com o trade-off assumido e registrado: o widget traz GA `G-2T527NZWVM` + redes de anúncio em todo pageview — sem PII nossa, sem linha de transparência, o player fica nu). `RadioFacade` vira `RadioEmbed` (rename via `git mv`; edita o dono, sem twin): **server component**, sem `'use client'`/estado/`onLoad` — a facade inteira sai (arte "Rádio online", ondas, eyebrow "Sintonize com a gente", textos, escudo, botão "Ouvir a rádio", "Abrir no Zeno", estados `loading/loaded` e o aviso de terceiro). A moldura mínima é portada classe-a-classe do artefato aprovado `docs/plans/radio-embed-direto-ui-design.html` (Cenas 01/02/03): `rounded-[14px]`, borda `--campaign-line`, sombra baixa, `p-3 md:p-4`; o iframe monta eager no HTML inicial (`allow="autoplay"` sem autoplay real, `title` acessível, `article aria-label`), com **150px mobile / 168px desktop reservados desde o primeiro paint** (zero CLS; sem `loading="lazy"`, que contrariaria o aceite). A regra da seção fica intacta: header/copy variando com 0/N jingles, grade dos 3 jingles, "Ver todos" → `/jingles`, kill switch do S21 fail-closed (0 publicados = só rádio, agora na variante compacta `mx-auto max-w-[880px]`); `/jingles` e `JingleCards` intocados; home segue estática (leitura única `getPublishedJingleItems()`, cache tag `jingles`). CSS órfão da facade removido (`campaign-radio-art/wave/spinner` + keyframes e o bloco reduced-motion; `.campaign-sound-title` fica). e2e hermético: stub do zeno sobe para o fixture (`context.route` `https://zeno.fm/player/**` com `fulfill`, nunca abort — o guard de console externo continua valendo) e cobre todo spec que abre a home; `frontend` pina o embed montado sem gesto (iframe 1, `src`/`title`, request zeno > 0, `168px`/`150px`, overflow 390) e `frontendJingles` prova o mount direto e mantém o kill switch; unit `radioEmbed` (mount no primeiro render, sem `loading`, sem botão/link/estado) e `jingleHomeSection` (0/N + variante compacta). Sem migration (nenhum schema; leitura pública sem PII/Consent). Crítica final do `designer` **CERTIFICADO** (tier primário) contra o app renderizado em 390/1280 nas variantes compacta (0 jingles, estado de produção) e cheia (3 jingles): aprovado sem ajustes. `pnpm gate:fast` verde (4211 unit), e2e local verde (`frontend` + `frontendJingles`, 36 passed), simplify com 2 revisores (15 achados: 9 resolvidos no lugar, 5 descartados/já resolvidos, 1 débito deferido com gatilho no `*-impl.md`), 0 Issues novas.

View file

@ -0,0 +1,131 @@
# Impl: Rádio 1313 na home — embed direto (S24)
Status: aprovado (modo `--auto`)
Atualizado em: 2026-09-20
Issue: #1235
Intenção: docs/plans/radio-embed-direto.md
Appetite restante: herdado (~0,5–1 dia eng) — sem corte adicional; a entrega é remoção + porte da moldura, cabe numa sessão.
## Leitura da intenção
- **Outcome:** a seção de som da home passa a mostrar o embed oficial do zeno.fm (`https://zeno.fm/player/jorge-solla-1313/`) já montado e tocável desde o carregamento da página, sem botão nem clique prévio; todo o wrapper/facade do S22 some (arte "Rádio online", eyebrow "Sintonize com a gente", textos, escudo, "Ouvir a rádio", "Abrir no Zeno", estados `loading/loaded` e aviso de terceiro); o header/copy da seção, a grade de jingles, o "Ver todos" e `/jingles` seguem exatamente como estão; kill switch e fail-closed valendo; sem salto vertical perceptível, sem overflow a 390px; o embed nunca toca sozinho com som.
- **O que NÃO negociar:** (1) embed direto carregado no pageview — trade-off aceito e registrado (o widget zeno traz GA `G-2T527NZWVM` + ads em toda home; sem PII nossa); (2) remoção integral da facade, sem linha/aviso/badge de transparência (decisão do gate); (3) variante compact preservada para 0 jingles (comportamento, não o card morto); (4) regra da seção: rádio sempre visível, grade/handoff fail-closed por publicação (kill switch do S21); (5) home estática: única leitura `getPublishedJingleItems()` (cache tag `jingles`), nenhum `payload.find` novo; (6) `/jingles`, `JingleCards`, `JinglePlayer` e `focusRing` intocados; (7) sem Consent/CSP/analytics/player próprio; identifiers em inglês, copy pt-BR inalterada.
- **O que reavaliar (hipóteses do explorador):** (a) o embed direto **não precisa de client component** — não há clique, estado nem `onLoad`; o iframe é server-renderável e monta no HTML inicial, então `'use client'`, `useState`, `useRef`, `useEffect` e o `onLoad` morrem com a facade; (b) o nome `RadioFacade` passa a descrever o que saiu — renomear é barato (D1); (c) `data-radio-state` fica sem máquina de estados — vira contrato morto se mantido (D5); (d) o `page.route` local do `frontendJingles` não basta: com o embed eager, **todo** spec que abre a home dispara zeno (10 specs hoje), então o stub é do fixture (D4); (e) `allow="autoplay"` continua o mínimo correto (playback só após gesto dentro do widget), sem a lista completa do YouTube e sem `autoplay=1`.
## Abordagem recomendada
```mermaid
flowchart LR
HOME["(home)/page.tsx (RSC, intocado)<br/>getPublishedJingleItems() · cache tag jingles"] --> SEC["JingleHomeSection (server)<br/>data-home-section='sound' · header/copy intactos"]
SEC --> EMB["RadioEmbed (server · novo, ex-RadioFacade)<br/>article[data-radio][aria-label] · moldura mínima"]
EMB --> IFRAME["iframe zeno player<br/>src oficial · title · allow=autoplay<br/>h 150/168 · eager no HTML inicial"]
SEC --> GRID["JingleCards (intocado)<br/>só se > 0 publicados"]
SEC --> ALL["'Ver todos os jingles' → /jingles (intocado)"]
FIX["fixtures/e2eTest.ts<br/>context.route zeno.fm → stub local (fulfill)"] -.-> IFRAME
```
**Opções consideradas:** A) manter `RadioFacade.tsx` e editar por dentro; B) `git mv` para `RadioEmbed.tsx` reescrito como server component e deletar a facade; C) inline no `JingleHomeSection`.
**Recomendação:** B — a superfície de rádio tem um dono só (o arquivo), o nome deixa de mentir ("facade" sai), o embed ganha um unit test isolado e a seção não incha; como não há interação, o componente é **server** (zero JS de cliente para o embed).
**Rejeitadas:** A porque perpetua um nome que descreve comportamento removido e polui greps futuros; C porque esconde o contrato do iframe (`src`/`title`/altura/eager) dentro da seção e acopla o teste unitário do embed à lógica de jingles.
### Decisões de engenharia
**D1 — Componente do embed (caro: fronteira de módulo + rename)**
Opções: A) editar `RadioFacade.tsx` mantendo o nome; B) `git mv src/components/jingles/RadioFacade.tsx src/components/jingles/RadioEmbed.tsx` + reescrever como server component (moldura + iframe), atualizando o import em `JingleHomeSection.tsx:7,62`; C) inline no `JingleHomeSection`.
Recomendação: B — edita o dono (sem twin), nome fiel, teste isolado próprio, e é o mais barato de reverter: o revert do PR devolve o arquivo antigo (git detecta o rename; nenhum caminho paralelo fica no repo). A facade morre inteira: `RadioArt`, `ExternalLink`, `ShieldIcon`, `FacadePlayButton`, `FacadePanel`, `LoadingPanel`, `LoadedPanel`, `RadioPanel`, `RADIO_PAGE_URL`, `RadioState` e `JINGLE_FOCUS_RING` (o anel continua vivo para `JingleCards`/handoff).
Alternativas rejeitadas: A porque `RadioFacade` sem facade é dívida de nomenclatura imediata; C porque centraliza no arquivo que já tem a regra de jingles e tira do unit o contrato do iframe.
Props mantidas: `{ compact?: boolean }` — a seção continua passando `compact={!hasJingles}` (`JingleHomeSection.tsx:62`); o prop volta a significar só a largura/margem da moldura, sem copy/arte/botões.
**D2 — Altura/CLS (caro: aceite de layout)**
Opções: A) reservar o footprint com o próprio iframe, server-rendered, fixo `h-[150px] md:h-[168px]` sobre `bg-(--campaign-band)`; B) deixar o iframe ditar a altura; C) placeholder separado trocado por client `onLoad`.
Recomendação: A — o iframe existe desde o primeiro paint, então a caixa 150/168 já faz parte do layout inicial; o `bg-(--campaign-band)` mantém a reserva visível enquanto o widget pinta; nenhum placeholder de troca (o `widget-placeholder` do artefato é NEEDS ASSET do protótipo, UI que não embarca).
Alternativas rejeitadas: B porque depende da altura intrínseca do vendor e não pina a reserva (mudança futura da zeno move a página); C porque reintroduz exatamente o estado/loading que a intenção remove — a troca é a origem do salto.
**D3 — `loading` do iframe (caro: aceite explícito do humano)**
Opções: A) eager — sem atributo `loading`, server-rendered no HTML inicial; B) `loading="lazy"`; C) `loading="eager"` explícito.
Recomendação: A — o aceite pede o embed "carregado automaticamente desde o início do carregamento da página"; um iframe server-rendered sem `loading` começa a buscar durante o parse inicial, que é a decisão literal do humano, e a altura reservada garante CLS zero. `allow="autoplay"` permanece (playback após gesto dentro do widget) e **não** se acrescenta `autoplay=1` nem parâmetro algum ao `src` — o embed nunca toca sozinho com som. `title="Player da Rádio Jorge Solla 1313 no zeno.fm"` permanece como rótulo acessível do frame, e o `article` ganha `aria-label="Player da Rádio Jorge Solla 1313"` (contrato do design aprovado).
Alternativas rejeitadas: B porque adiar para perto do viewport contradiz a decisão registrada e atrasa o player justo quando o visitante chega na seção; C porque é o default do browser com ruído.
**D4 — Hermetismo e2e (caro: guard de console + paralelismo)**
Opções: A) stub zeno no fixture `tests/e2e/fixtures/e2eTest.ts` via `context.route('https://zeno.fm/**', fulfill)` junto de `youtube-nocookie`/`facebook.net` (`:105-116`), removendo o `page.route` local de `frontendJingles.e2e.spec.ts:265-267`; B) manter `page.route` por spec e adicionar um em cada spec que abre a home; C) `route.abort()`.
Recomendação: A — com o embed eager, **todo** spec que renderiza a home pública dispara request zeno (10 specs, vários em paralelo, inclusive `campaignHomePixel`/`frontendShareLink`); o fixture é o dono único que torna a suíte hermética e preserva o guard de erro externo com sentido. `fulfill` (nunca `abort`): um frame abortado loga "Failed to load resource" e o guard rejeita console de origem externa (`e2eTest.ts:48-66` — a origem externa nunca é permitida); fulfill do stub `< !doctype html><title>zeno stub</title>` já é o padrão do S22. Com `route.fulfill`, o evento `request` continua disparando, então o spec consegue provar o load eager (requests > 0 **sem clique**). Remover o `page.route` local elimina dois donos do mesmo stub (page route tem precedência sobre context route — duplicar convida drift).
Alternativas rejeitadas: B porque replica o stub em N specs e um esquecimento = GA/ads reais no CI + flake do guard; C porque quebra o guard (achado 10a do explorador).
**D5 — Kill switch / varredura de testes (caro: contrato pinado em 4 arquivos)**
Opções: A) manter `[data-radio]` como hook estável e **remover** `data-radio-state` (não há mais máquina de estados; o e2e pina `[data-radio]` + `iframe` com `src`/`title`); B) manter `data-radio-state="embed"` estático; C) remover os dois data-attrs e localizar por `aria-label`.
Recomendação: A — um atributo de estado estático seria uma mentira semântica que convida drift; `[data-radio]` continua como hook de teste (mesmo papel de `data-home-section`/`data-jingle`) e é ele que sustenta a asserção de kill switch ("com 0 publicados a rádio permanece"). A a11y fica com `aria-label` no `article` + `title` no iframe.
Alternativas rejeitadas: B porque codifica um estado que não existe; C porque não ganha nada sobre o hook e perde o seletor barato de escopo da seção.
Varredura decidida:
- `tests/unit/radioFacade.unit.spec.tsx` → `git mv` para `tests/unit/radioEmbed.unit.spec.tsx` e reescrever: no primeiro render existe 1 iframe com `src` official, `title`, `allow="autoplay"`, **sem** `loading`; não existem "Ouvir a rádio", "Abrir no Zeno" nem as copies da facade; o `article` tem `data-radio` e `aria-label`.
- `tests/unit/jingleHomeSection.unit.spec.tsx:46` — trocar `[data-radio][data-radio-state="facade"]` por `[data-radio]` + `iframe` presente no caso 0 jingles; manter as asserções fail-closed (sem cards, sem "Jingles oficiais", sem "Ver todos").
- `tests/unit/campaignHome.unit.spec.tsx:74` — intocado (só heading; o iframe em jsdom não faz rede).
- `tests/e2e/frontend.e2e.spec.ts:560-604` — reescrever o teste: remover as asserções de facade (estado, botão, link, iframe count 0) e pinar o embed (1 iframe visível, `src`, `title`); manter ordem (`sound` entre `story` e `cards`) e overflow 390.
- `tests/e2e/frontendJingles.e2e.spec.ts:202-207` — kill switch: `[data-radio]` + `iframe` visíveis com 0 publicados; demais contagens seguem.
- `tests/e2e/frontendJingles.e2e.spec.ts:247-303` — reescrever: após `goto('/?e2e=<ts>')`, 1 iframe com `src`, `zenoRequests.length > 0` sem nenhum clique (prova do eager), `audioRequests` segue 0 (`preload="none"` dos cards), 3 cards, sem "Ver todos", overflow 390; remover o fluxo de clique e o `page.route` local.
- Pós-edição: grep de `data-radio-state`, `RadioFacade` e das copies da facade — "Ouvir a rádio", "Abrir no Zeno", "O player do zeno.fm" — deve voltar vazio em `src/`; em `tests/` sobra só `tests/unit/radioEmbed.unit.spec.tsx` (asserções negativas intencionais, o contrato da remoção). Planos históricos não contam.
**D6 — CSS órfão (caro: superfície morta)**
Opções: A) remover só os blocos comprovadamente órfãos e manter `.campaign-sound-title`; B) remover todos os `campaign-radio-*`; C) deixar como está.
Recomendação: A — saem `.campaign-radio-art` (`styles.css:1168-1199`), `.campaign-radio-wave` (`:1201-1216`), `@keyframes campaign-radio-wave-pulse` (`:1218-1223`), `.campaign-radio-spinner` (`:1225-1233`), `@keyframes campaign-radio-spin` (`:1234-1238`) e o bloco `prefers-reduced-motion` (`:1240-1244`, cujos dois seletores são os removidos); o comentário S22 (`:1158-1161`) é reescrito para descrever só o título. `.campaign-sound-title` (`:1162-1166`) **fica** — é usado por `JingleHomeSection.tsx:44` (40/34px).
Alternativas rejeitadas: B porque derruba a escala do título da seção; C porque é CSS de uma superfície removida (knip não vê CSS).
**D7 — Decisões de produto (fechamento das questões em aberto)**
Adotadas as recomendações da intenção, sem reabrir: **compact mantido** (A) — com 0 publicados o embed fica centralizado em `max-w-[880px]`, sem copy/arte/botões internos; **"Abrir no Zeno" some** (A) — o embed tem os próprios links; **sem linha de transparência** (decisão do gate) — widget nu.
`numero-negativo.png`: **fica no `public/campaign-kit/`** — é asset do kit compartilhado de marca (como `jorge-solla-negativo.png` etc.), não é bundle e a limpeza de binários do kit é fora de escopo; fica registrado que o S24 removeu o único consumidor em código.
### Componentes / mudanças
- **`RadioEmbed`** (`src/components/jingles/RadioEmbed.tsx`, renomeado de `RadioFacade.tsx`; server component, sem `'use client'`): `article` com `data-radio` e `aria-label="Player da Rádio Jorge Solla 1313"`; moldura portada classe-a-classe do design aprovado (Cenas 01/02/03) traduzida para os utilitários/tokens do app — `overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_10px_28px_rgb(71_19_14/7%)] p-3 md:p-4` + `compact ? 'mx-auto mt-8 max-w-[880px]' : 'mt-9'`; dentro, só o iframe `src="https://zeno.fm/player/jorge-solla-1313/"`, `title="Player da Rádio Jorge Solla 1313 no zeno.fm"`, `allow="autoplay"`, sem `loading`, `className="h-[150px] w-full rounded-xl border-0 bg-(--campaign-band) md:h-[168px]"`. Sem placeholder, sem aviso, sem link, sem botão. As classes do protótipo (`player-frame`, `widget-placeholder`) não existem em `styles.css` — não importar; a tradução acima é o port.
- **`JingleHomeSection`** (`src/components/jingles/JingleHomeSection.tsx`): troca o import (`RadioFacade` → `RadioEmbed`) e atualiza o comentário S22→S24 (a seção passa a montar o embed direto; grid/handoff inalterados). Nada mais muda: header/copy condicional por `hasJingles`, `data-home-section="sound"`, `aria-labelledby="sound-title"`, `JingleCards`, "Ver todos" e o `compact={!hasJingles}` continuam idênticos.
- **`src/app/(frontend)/styles.css`**: remoções do D6 + comentário reescrito.
- **`src/app/(frontend)/(home)/page.tsx`**: **intocado** — a leitura cacheada, o `slice(0,3)` e o rodapé não mudam.
- **`tests/e2e/fixtures/e2eTest.ts`**: novo `context.route` do zeno (D4) junto dos stubs existentes, com comentário S24 explicando que o embed é eager e o fulfill evita o guard de console externo.
- **Migration:** nenhuma (item puramente frontend; sem schema, sem `generate:types`).
- **Access / Consent:** N/A — leitura pública, sem formulário, sem PII, sem chave de `Consent`; nenhum write path.
- **UI:** Impeccable B — a estrutura visual é a do design aprovado (`docs/plans/radio-embed-direto-ui-design.html`), portada sem improviso (Cenas 01 compacta desktop 1280, 02 compacta mobile 390, 03 cheia com jingles); classes do protótipo não existem no app e são traduzidas para `border-(--campaign-line)` (12%), `rounded-[14px]`, `shadow-[0_10px_28px_rgb(71_19_14/7%)]`, `p-3 md:p-4`, `h-[150px] md:h-[168px]`; footprint reservado e widget nu.
### Dados → forma (se aplicável)
- **N/A** — a intenção declara "Vou apresentar dados? Não": sem agregação, sem PII, sem pergunta de data-presentation; a superfície é um player de terceiro.
## Fases verificáveis
1. **Fase 1 — RadioEmbed + seção (~1,5 h):** `git mv RadioFacade.tsx RadioEmbed.tsx`; reescrever como server component (D1–D3); atualizar `JingleHomeSection` (import + comentário); `git mv tests/unit/radioFacade.unit.spec.tsx tests/unit/radioEmbed.unit.spec.tsx` e reescrever; ajustar `jingleHomeSection.unit.spec.tsx:46`. Checkpoint: `pnpm test:unit -- radioEmbed jingleHomeSection campaignHome` verde.
2. **Fase 2 — CSS órfão (~0,5 h):** remoções do D6; reescrever o comentário; grep de confirmação (`campaign-radio-art|wave|spinner` = 0 em `src/`). Checkpoint: `pnpm lint` e build local da home com a seção renderizando o iframe.
3. **Fase 3 — e2e hermetismo + reescritas (~1,5 h):** stub zeno no fixture (remover o local do `frontendJingles`); reescrever o teste da seção no `frontend` e os dois testes do `frontendJingles` (D5); varredura greps do D5. Checkpoint: `pnpm test:e2e -- frontend frontendJingles` verde (home com embed, sem clique, kill switch e overflow 390).
4. **Gates (~0,5 h):** `pnpm gate:fast`; registro em `docs/changelog/2026-09-20-s24.md` (uma entrada, incluindo o trade-off GA/ads assumido); push via `pnpm push` (a CI seleciona `frontend` por `src/app/(frontend)` — o CSS — e `frontendJingles` por `src/components/jingles`, ambos já mapeados, sem mudança no manifest; o restante roda no gate:ci).
## Rabbit holes / Não escopo (engenharia)
- Consent/CSP/banner de terceiro, sandbox do iframe, `referrerPolicy` ou lista de `allow` do YouTube: embed oficial nu, decidido no gate.
- Analytics de reprodução, contador de ouvintes, "no ar", player custom/proxy/stream próprio.
- Tocar `JingleCards`/`JinglePlayer`/`JinglePageHeader`/`focusRing.ts`/`/jingles` ou a grade da home: intocados; nenhum rename ali.
- Mexer em `(home)/page.tsx`, criar leitura nova, ou duplicar tag/cache: a seção continua recebendo props.
- Reintroduzir estado/`'use client'` para "melhorar" o embed (fade-in, skeleton, onLoad): a ausência de estado é o ponto.
- Toggle/A-B facade↔embed, feature flag, parâmetro de URL: produto decidiu; não fica caminho latente.
- Aspect-ratio, largura fixa ou `loading="lazy"` para "economizar": contraria o aceite.
- Limpar `numero-negativo.png` do kit, ou redesenhar header/copy da seção.
- Novo spec e2e ou entrada nova no manifest (prefixos atuais cobrem); unit/int novos além dos reescritos.
## Riscos e mitigação
- **Terceiro eager em todo pageview (GA + ads):** risco assumido por decisão do humano; mitigação é não coletar PII (nada muda) e registrar no changelog; não há gate de consent no repo (fora de escopo por decisão).
- **Rede real/guard de console em ~10 specs que abrem a home:** stub zeno no fixture com `fulfill` (D4); um dono só, removendo o `page.route` local; o guard continua pegando erro de console de origem externa em qualquer request não roteado.
- **CLS/"salto":** iframe server-rendered com 150/168 fixos + `bg-(--campaign-band)`; unit pina a ausência de `loading`; e2e mede a altura do box a 390 (150) e desktop (168) — desvio acende o alarme.
- **Overflow horizontal a 390:** iframe `w-full` dentro de moldura `p-3` no container `px-5`; o e2e mantém a asserção de `scrollWidth - clientWidth ≤ 1` nos dois specs.
- **Kill switch perdido na varredura:** `frontendJingles:202-207` reescrito (não removido) segue afirmando rádio visível + 0 cards + 0 "Ver todos"; grep de `data-radio-state` em `src/` = 0 (em `tests/`, só as asserções negativas do `radioEmbed`).
- **Contrato de teste obsoleto esquecido:** a varredura do D5 lista os 6 pontos exatos (3 unit, 3 e2e) + greps; qualquer sobra falha no `gate:fast`/`gate:ci`.
- **Rename com atrito:** manifest é por prefixo (`src/components/jingles` → `frontendJingles`, `:99-108`) e não referencia nome de arquivo; sem entrada nova; knip/ciclos/import map cobertos pelo gate.
- **ISR/cache heurístico:** inalterado; specs navegam `/?e2e=<ts>` como hoje; nenhuma mudança de revalidação.
- **Vendor mudar a altura do widget:** a reserva 150/168 é pinada pelo e2e; se a zeno mudar, o ajuste é consciente (não silencioso). _Débito deferido (triagem S24): o layout-shift real do widget não é medido — gatilho: a zeno mudar a altura do widget ou relato real de salto → medir com `PerformanceObserver` no e2e._
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto — embed montado no load sem clique (e2e `frontend`/`frontendJingles`: iframe visível + requests zeno > 0 sem gesto); facade removida (greps + unit `radioEmbed`); regra da seção preservada (unit `jingleHomeSection` 0 jingles; e2e kill switch); sem salto (altura fixa + pin do box); 390 sem overflow; `/jingles` intocado (nenhum arquivo seu tocado).
- [ ] Invariantes AGENTS/engineering-standards — home estática via `getPublishedJingleItems()` (tag `jingles`), sem `payload.find` novo; sem PII/Consent; sem schema/migration (`push: false` intocado); sem mudança de URL pública; dono editado (rename), sem twin; identificadores em inglês, copy pt-BR preservada.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum access/write path; a mudança é de apresentação): cobertura unit (`radioEmbed`, `jingleHomeSection`) e e2e (`frontend`, `frontendJingles`) listada no D5; `campaignHome.unit.spec.tsx` segue como está.
## Self-score — decision-quality: 4/5
Todas as seis decisões forçadas têm opções/recomendação/rejeitadas com critério verificável (dono do arquivo, aceite literal do eager, guard de console, semântica dos data-attrs, orfandade comprovada por grep), e o plano fecha as questões de produto herdadas sem reabri-las. Perde 1 ponto por duas incertezas fora do nosso controle: a altura do widget zeno pode variar no futuro (mitigada por pin de e2e, mas não eliminada) e a medição de "sem salto" é estrutural (altura reservada desde o primeiro paint) mais pin de box, não uma medição de CLS real no browser; se o gate humano quiser, um `PerformanceObserver` de layout-shift no e2e cobre, mas isso adiciona superfície que a entrega não pede.

View file

@ -1155,91 +1155,10 @@ body,
}
}
/* S22 — home "A trilha da nossa caminhada": the Rádio 1313 card art (port
classe-a-classe de docs/plans/jingles-radio-homepage-ui-design.html). The
frame, panels and states live as Tailwind utilities in RadioFacade; only the
painted art, the decorative wave and the loading spinner are CSS here. */
/* S22 — home "A trilha da nossa caminhada": the section title scales above the
neighbours' (Cenas 01/02: 40px desktop, 34px mobile) without touching the
shared token. The radio frame and the direct zeno.fm embed (S24) live as
Tailwind utilities in RadioEmbed — no painted art, wave or spinner here. */
.campaign-sound-title {
/* The artefact scales this section's title above the neighbours' (Cenas
01/02: 40px desktop, 34px mobile) without touching the shared token. */
font-size: clamp(34px, calc(1.7vw + 18px), 40px);
}
.campaign-radio-art {
position: relative;
isolation: isolate;
overflow: hidden;
background:
radial-gradient(circle at 18% 20%, rgb(255 230 7 / 100%) 0 8%, transparent 8.4%),
radial-gradient(circle at 85% 75%, rgb(24 78 146 / 90%) 0 14%, transparent 14.5%),
linear-gradient(140deg, #c52626 0%, #8d1414 58%, #47130e 100%);
}
.campaign-radio-art::before,
.campaign-radio-art::after {
content: '';
position: absolute;
z-index: -1;
border: 1px solid rgb(255 255 255 / 28%);
border-radius: 999px;
}
.campaign-radio-art::before {
width: 190px;
height: 190px;
right: -56px;
top: -74px;
}
.campaign-radio-art::after {
width: 250px;
height: 250px;
right: -86px;
top: -104px;
}
.campaign-radio-wave span {
display: block;
width: 4px;
border-radius: 999px;
background: var(--pt-yellow);
animation: campaign-radio-wave-pulse 1.15s ease-in-out infinite alternate;
transform-origin: center;
}
.campaign-radio-wave span:nth-child(2n) {
animation-delay: -420ms;
}
.campaign-radio-wave span:nth-child(3n) {
animation-delay: -760ms;
}
@keyframes campaign-radio-wave-pulse {
to {
transform: scaleY(0.55);
opacity: 0.72;
}
}
.campaign-radio-spinner {
width: 34px;
height: 34px;
border: 3px solid rgb(162 28 28 / 18%);
border-top-color: var(--pt-red);
border-radius: 999px;
animation: campaign-radio-spin 850ms linear infinite;
}
@keyframes campaign-radio-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.campaign-radio-wave span,
.campaign-radio-spinner {
animation: none;
}
}

View file

@ -4,7 +4,7 @@ import type { JingleViewModel } from '@/lib/jingle'
import { JINGLE_FOCUS_RING } from './focusRing'
import { JingleCards } from './JingleCards'
import { RadioFacade } from './RadioFacade'
import { RadioEmbed } from './RadioEmbed'
type JingleHomeSectionProps = {
jingles: readonly JingleViewModel[]
@ -14,11 +14,12 @@ type JingleHomeSectionProps = {
const SEE_ALL_CONTROL = `inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 font-bold text-(--pt-red) underline-offset-4 hover:underline ${JINGLE_FOCUS_RING}`
/**
* S22 — the home sound section (artefato: cenas 01/02/06/07): the Rádio 1313
* facade (click-to-load) followed by up to three published jingles and the
* "Ver todos" handoff to `/jingles` when more exist. With zero published
* jingles the grid and the handoff are gone and only the radio stays
* (fail-closed, same kill switch as S21); the page passes the already-cached
* S22 — the home sound section (artefato: cenas 01/02/06/07 do S22; a moldura
* da rádio agora é a do S24, cenas 01/02/03): the Rádio 1313 embed (direto
* desde o S24) followed by up to three published jingles and the "Ver todos"
* handoff to `/jingles` when more exist. With zero published jingles the grid
* and the handoff are gone and only the radio stays (fail-closed, same kill
* switch as S21); the page passes the already-cached
* `getPublishedJingleItems()` listing in, so the section never re-reads.
*/
export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps) => {
@ -59,7 +60,7 @@ export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps)
</p>
</div>
<RadioFacade compact={!hasJingles} />
<RadioEmbed compact={!hasJingles} />
{hasJingles ? (
<>

View file

@ -0,0 +1,31 @@
import { cn } from '@/lib/utils'
const RADIO_PLAYER_URL = 'https://zeno.fm/player/jorge-solla-1313/'
/**
* S24 — the Rádio Jorge Solla 1313 embed (artefato: cenas 01/02/03), replacing
* the S22 click-to-load facade: the official zeno.fm player is server-rendered
* and starts loading with the page — no button, state machine or request gated
* behind a click. The frame keeps the reserved 150/168px footprint from the
* first paint (no jump) and stays a bare third-party widget (no transparency
* line, chip or shield). `compact` is the zero-jingles variant: the frame
* narrows and centers; the section header/copy and the jingles grid are owned
* by `JingleHomeSection` and unchanged.
*/
export const RadioEmbed = ({ compact = false }: { compact?: boolean }) => (
<article
data-radio
aria-label="Player da Rádio Jorge Solla 1313"
className={cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white p-3 shadow-[0_10px_28px_rgb(71_19_14/7%)] md:p-4',
compact ? 'mx-auto mt-8 max-w-[880px]' : 'mt-9',
)}
>
<iframe
src={RADIO_PLAYER_URL}
title="Player da Rádio Jorge Solla 1313 no zeno.fm"
allow="autoplay"
className="h-[150px] w-full rounded-xl border-0 bg-(--campaign-band) md:h-[168px]"
/>
</article>
)

View file

@ -1,285 +0,0 @@
'use client'
import Image from 'next/image'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { JINGLE_FOCUS_RING } from './focusRing'
/**
* S22 — the Rádio Jorge Solla 1313 card with a click-to-load facade
* (artefato: cenas 01/02/03/04/07): the official zeno.fm widget is only mounted
* after "Ouvir a rádio", so no third-party request, script or ad loads on the
* home pageview. While the iframe answers, the reaction is `loading`
* (`role="status"` + `aria-busy`), promoting to `loaded` on `onLoad`; the three
* states share one reserved footprint, so nothing jumps (CLS). The external
* link stays available in every state. `compact` is the Cena 07 variant: the
* zero-jingles home narrows the card and drops the facade's intro and note.
*/
const RADIO_PAGE_URL = 'https://zeno.fm/radio/jorge-solla-1313/'
const RADIO_PLAYER_URL = 'https://zeno.fm/player/jorge-solla-1313/'
type RadioState = 'facade' | 'loading' | 'loaded'
const ExternalLink = ({ className }: { className?: string }) => (
<a
href={RADIO_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-md font-bold text-(--pt-red) underline-offset-4 hover:underline',
JINGLE_FOCUS_RING,
className,
)}
>
Abrir no Zeno
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</a>
)
const ShieldIcon = ({ className }: { className?: string }) => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={cn('h-4 w-4 flex-none', className)}
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
</svg>
)
const RadioArt = ({ compact }: { compact?: boolean }) => (
<div
className={cn(
'campaign-radio-art flex h-[190px] flex-col justify-between p-5 text-white md:h-auto md:p-6',
compact ? 'sm:min-h-[220px]' : 'md:min-h-[270px] lg:p-7',
)}
>
<div className="flex items-center justify-between">
<span className="rounded-full border border-white/30 bg-white/10 px-3 py-1 font-[family-name:var(--font-exo2)] text-[10px] font-black tracking-[0.1em] uppercase md:text-[11px]">
Rádio online
</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-6 w-6 text-(--pt-yellow) md:h-7 md:w-7"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9M8.5 15.5a5 5 0 0 1 0-7M19.1 4.9c3.9 3.9 3.9 10.3 0 14.2M15.5 8.5a5 5 0 0 1 0 7" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</svg>
</div>
<Image
src="/campaign-kit/numero-negativo.png"
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
width={1037}
height={595}
className={cn(
'h-auto self-center object-contain',
compact ? 'w-[155px] sm:w-[165px]' : 'w-[155px] md:w-[190px]',
)}
/>
<div
aria-hidden="true"
className="campaign-radio-wave flex h-6 items-center justify-center gap-1.5 md:h-8"
>
<span className="h-2 md:h-3" />
<span className="h-5 md:h-6" />
<span className="h-3 md:h-4" />
<span className="h-6 md:h-8" />
<span className="h-4 md:h-5" />
<span className="hidden h-7 md:block" />
<span className="hidden h-3 md:block" />
</div>
</div>
)
const RadioPanel = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
className={cn(
// The loaded state's real footprint (header + 150/168px widget + note)
// is the tallest of the three, so it is the reserved height for facade,
// loading and loaded alike — no vertical jump (Cena 04's contract).
'flex min-h-[330px] flex-col justify-center p-5 md:min-h-[312px] md:p-8',
className,
)}
{...props}
/>
)
const FacadePlayButton = ({ onRequestPlayer }: { onRequestPlayer: () => void }) => (
<button
type="button"
onClick={onRequestPlayer}
className={cn(
'inline-flex min-h-12 w-full items-center justify-center gap-2.5 rounded-[10px] bg-(--pt-red) px-5 font-[family-name:var(--font-exo2)] font-black text-white md:w-auto',
'shadow-[0_5px_0_#751111,0_10px_22px_rgb(71_19_14/18%)] transition-[transform,box-shadow,filter] duration-150 ease-out',
'hover:-translate-y-0.5 hover:brightness-105 hover:shadow-[0_7px_0_#751111,0_13px_25px_rgb(71_19_14/22%)]',
'active:translate-y-[3px] active:shadow-[0_2px_0_#751111]',
'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
JINGLE_FOCUS_RING,
)}
>
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-current">
<path d="m8 5 11 7-11 7z" />
</svg>
Ouvir a rádio
</button>
)
const FacadePanel = ({
compact,
onRequestPlayer,
}: {
compact?: boolean
onRequestPlayer: () => void
}) => (
<RadioPanel>
{compact ? (
<>
<h3 className="m-0 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em]">
Rádio Jorge Solla 1313
</h3>
<p className="mt-2 max-w-xl text-sm leading-6 text-(--campaign-muted)">
O player oficial só é carregado quando você pedir.
</p>
<div className="mt-5 flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center md:gap-x-5">
<FacadePlayButton onRequestPlayer={onRequestPlayer} />
<ExternalLink className="justify-center md:justify-start" />
</div>
</>
) : (
<>
<p className="m-0 hidden font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.1em] text-(--pt-red) uppercase md:block">
Sintonize com a gente
</p>
<h3 className="m-0 mt-2 font-[family-name:var(--font-exo2)] text-2xl leading-tight font-black tracking-[-0.025em] md:text-[31px]">
Rádio Jorge Solla 1313
</h3>
<p className="mt-3 max-w-xl text-[15px] leading-6 text-(--campaign-muted)">
<span className="md:hidden">Carregue o player oficial só quando quiser ouvir.</span>
<span className="hidden md:inline">
Clique para carregar o player oficial e ouvir a programação da rádio.
</span>
</p>
<div className="mt-5 flex flex-col gap-2 md:mt-6 md:flex-row md:flex-wrap md:items-center md:gap-x-6">
<FacadePlayButton onRequestPlayer={onRequestPlayer} />
<ExternalLink className="justify-center md:justify-start" />
</div>
<p className="mt-3 flex flex-col items-center gap-1 text-center text-xs leading-5 text-black/55 md:flex-row md:items-start md:gap-2 md:text-left">
<ShieldIcon className="hidden md:block" />
<span className="md:hidden">O player do zeno.fm só carrega depois do clique.</span>
<span className="hidden md:inline">
O player do zeno.fm só é carregado depois do seu clique.
</span>
</p>
</>
)}
</RadioPanel>
)
const LoadingPanel = ({ panelRef }: { panelRef?: React.Ref<HTMLDivElement> }) => (
<RadioPanel ref={panelRef} tabIndex={-1} role="status" aria-live="polite" aria-busy="true">
<div className="flex items-start justify-between gap-5">
<div>
<p className="m-0 font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.1em] text-(--pt-red) uppercase">
Sintonize com a gente
</p>
<h3 className="m-0 mt-1 font-[family-name:var(--font-exo2)] text-2xl leading-tight font-black tracking-[-0.02em] md:text-[28px]">
Rádio Jorge Solla 1313
</h3>
<p className="mt-3 text-sm font-bold">Carregando o player da rádio…</p>
<p className="mt-1 text-xs text-(--campaign-muted)">Isso pode levar alguns segundos.</p>
</div>
<div className="campaign-radio-spinner mt-2 flex-none" aria-hidden="true" />
</div>
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-3">
<button
type="button"
disabled
aria-busy="true"
className="min-h-11 cursor-default rounded-[10px] bg-[rgb(162_28_28/10%)] px-5 text-sm font-bold text-[#7d4848]"
>
Carregando…
</button>
<ExternalLink className="text-sm" />
</div>
</RadioPanel>
)
/**
* Mounted at the click (with the iframe) but kept `hidden` until `onLoad`:
* `display:none` does not stop the frame from loading, so the same element
* survives the `loading → loaded` promotion and the widget never refetches.
*/
const LoadedPanel = ({ hidden, onLoaded }: { hidden: boolean; onLoaded: () => void }) => (
<div className={cn(hidden && 'hidden')}>
<RadioPanel>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="m-0 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em]">
Rádio Jorge Solla 1313
</h3>
<ExternalLink className="text-sm" />
</div>
<iframe
src={RADIO_PLAYER_URL}
title="Player da Rádio Jorge Solla 1313 no zeno.fm"
allow="autoplay"
onLoad={onLoaded}
className="h-[150px] w-full rounded-xl border-0 bg-(--campaign-band) md:h-[168px]"
/>
<p className="mt-2 mb-0 text-xs leading-5 text-(--campaign-muted)">
Player fornecido por zeno.fm. Ao usar, você acessa um serviço externo.
</p>
</RadioPanel>
</div>
)
export const RadioFacade = ({ compact = false }: { compact?: boolean }) => {
const [state, setState] = useState<RadioState>('facade')
const statusRef = useRef<HTMLDivElement>(null)
// The facade's button unmounts at the click; move the keyboard focus to the
// status region so the journey does not fall back to the document body.
useEffect(() => {
if (state === 'loading') statusRef.current?.focus()
}, [state])
return (
<article
data-radio
data-radio-state={state}
className={cn(
'grid overflow-hidden rounded-[18px] border border-[rgb(71_19_14/18%)] bg-white shadow-[0_16px_44px_rgb(71_19_14/10%)]',
compact
? 'mx-auto mt-8 max-w-[880px] sm:grid-cols-[240px_1fr]'
: 'mt-9 md:grid-cols-[270px_1fr]',
)}
>
<RadioArt compact={compact} />
{state === 'facade' ? (
<FacadePanel compact={compact} onRequestPlayer={() => setState('loading')} />
) : (
<>
{state === 'loading' ? <LoadingPanel panelRef={statusRef} /> : null}
<LoadedPanel hidden={state === 'loading'} onLoaded={() => setState('loaded')} />
</>
)}
</article>
)
}

View file

@ -1,8 +1,8 @@
/**
* S21/S22 — the shared focus-visible ring of the jingles surfaces (play,
* download, radio CTA and the "Ver todos" handoff): yellow outline over the
* dark red shadow, the same policy the campaign sections apply via
* `safe-focus` in the design artefact.
* download and the "Ver todos" handoff): yellow outline over the dark red
* shadow, the same policy the campaign sections apply via `safe-focus` in the
* design artefact.
*/
export const JINGLE_FOCUS_RING =
'focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark)] focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'

View file

@ -115,6 +115,19 @@ export const test = base.extend<E2EFailureGuardFixtures>({
route.fulfill({ body: '', contentType: 'application/javascript' }),
)
// S24 — the home radio is a direct zeno.fm iframe that loads eagerly with
// the pageview, so every spec rendering the home requests it. Fulfill the
// player locally (an aborted frame logs an external-origin console error
// and the guard above would fail); the request event still fires, which is
// how the sound-section specs prove the eager mount. The glob is scoped to
// the player path: any other zeno.fm request stays a real failure.
await context.route('https://zeno.fm/player/**', (route) =>
route.fulfill({
body: '<!doctype html><title>zeno stub</title>',
contentType: 'text/html',
}),
)
await use()
page.off('console', onConsole)

View file

@ -557,9 +557,14 @@ test.describe('Frontend', () => {
expect(overflow).toBeLessThanOrEqual(1)
})
test('shows the sound section between the story and the cards with the inert radio facade', async ({
test('shows the sound section between the story and the cards with the direct radio embed', async ({
page,
}) => {
const zenoRequests: string[] = []
page.on('request', (req) => {
if (req.url().includes('zeno.fm')) zenoRequests.push(req.url())
})
await page.goto('/')
const sound = page.locator('[data-home-section="sound"]')
@ -584,18 +589,25 @@ test.describe('Frontend', () => {
expect(tops.sound).toBeGreaterThan(tops.story)
expect(tops.cards).toBeGreaterThan(tops.sound)
// S22 — the official player only mounts after the gesture; until then the
// facade holds no iframe and the external link is the alternative.
await expect(sound.locator('[data-radio][data-radio-state="facade"]')).toBeVisible()
await expect(sound.locator('iframe')).toHaveCount(0)
await expect(sound.getByRole('button', { name: 'Ouvir a rádio' })).toBeVisible()
await expect(sound.getByRole('link', { name: 'Abrir no Zeno' })).toHaveAttribute(
'href',
'https://zeno.fm/radio/jorge-solla-1313/',
)
// S24 — the official player is mounted from the first paint (the fixture
// stubs the zeno request): no facade, no button, no click gate, and the
// frame was actually requested before any gesture.
await expect(sound.locator('[data-radio]')).toBeVisible()
const radioFrame = sound.locator('[data-radio] iframe')
await expect(radioFrame).toHaveCount(1)
await expect(radioFrame).toHaveAttribute('src', 'https://zeno.fm/player/jorge-solla-1313/')
await expect(radioFrame).toHaveAttribute('title', 'Player da Rádio Jorge Solla 1313 no zeno.fm')
await expect(sound.locator('[data-radio] button')).toHaveCount(0)
await expect(sound.locator('[data-radio] a')).toHaveCount(0)
await expect.poll(() => zenoRequests.length).toBeGreaterThan(0)
// Mobile 390: the section never overflows horizontally.
// The reserved footprint comes from the frame itself: 168px desktop.
await expect(radioFrame).toHaveCSS('height', '168px')
// Mobile 390: the footprint narrows to 150px and the section never
// overflows horizontally.
await page.setViewportSize({ width: 390, height: 844 })
await expect(radioFrame).toHaveCSS('height', '150px')
const overflow = await page.evaluate(() => {
const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0

View file

@ -199,10 +199,11 @@ test.describe('Frontend jingles (S21)', () => {
await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0)
await page.goto('/')
await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0)
// S22 — the home sound section keeps the radio and drops the empty grid.
// S24 — the home sound section keeps the radio (direct embed) and drops the empty grid.
const sound = page.locator('[data-home-section="sound"]')
await expect(sound.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeVisible()
await expect(sound.locator('[data-radio][data-radio-state="facade"]')).toBeVisible()
await expect(sound.locator('[data-radio]')).toBeVisible()
await expect(sound.locator('[data-radio] iframe')).toHaveCount(1)
await expect(sound.locator('article[data-jingle]')).toHaveCount(0)
await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0)
expect((await request.get(`${BASE_URL}/jingles`)).status()).toBe(200)
@ -244,7 +245,7 @@ test.describe('Frontend jingles (S21)', () => {
await expect(page.getByRole('link', { name: 'Jingles' })).toBeVisible()
})
test('shows the sound section on the home and loads the radio only on click', async ({
test('shows the sound section on the home and mounts the radio directly', async ({
page,
request,
}) => {
@ -260,11 +261,6 @@ test.describe('Frontend jingles (S21)', () => {
if (req.url().includes('zeno.fm')) zenoRequests.push(req.url())
if (/\/api\/media\/file\/.*\.mp3/.test(req.url())) audioRequests.push(req.url())
})
// Hermetic e2e: the official widget comes from the local double, never the
// real network (the fixture guard fails on external console errors).
await page.route('https://zeno.fm/**', (route) =>
route.fulfill({ body: '<!doctype html><title>zeno stub</title>', contentType: 'text/html' }),
)
await page.goto(`/?e2e=${Date.now()}`)
const sound = page.locator('[data-home-section="sound"]')
@ -274,24 +270,16 @@ test.describe('Frontend jingles (S21)', () => {
// Three of three: the handoff only exists when more are published.
await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0)
// The facade is inert: no iframe, no zeno request and no audio fetch
// before the gesture (`preload="none"` on every card).
await expect(sound.locator('[data-radio][data-radio-state="facade"]')).toBeVisible()
await expect(sound.locator('iframe')).toHaveCount(0)
expect(zenoRequests).toHaveLength(0)
expect(audioRequests).toHaveLength(0)
await expect(sound.getByRole('link', { name: 'Abrir no Zeno' })).toHaveAttribute(
'href',
'https://zeno.fm/radio/jorge-solla-1313/',
)
// The click mounts the official player (loading → loaded on onLoad).
await sound.getByRole('button', { name: 'Ouvir a rádio' }).click()
await expect(sound.locator('[data-radio]')).toHaveAttribute('data-radio-state', 'loaded')
const frame = sound.locator('iframe')
// S24 — the direct embed mounts with the pageview, no gesture (the fixture
// stubs the zeno request): the frame is there and the stub was requested
// before any click.
const frame = sound.locator('[data-radio] iframe')
await expect(frame).toHaveCount(1)
await expect(frame).toHaveAttribute('src', 'https://zeno.fm/player/jorge-solla-1313/')
expect(zenoRequests.length).toBeGreaterThan(0)
await expect(frame).toHaveAttribute('title', 'Player da Rádio Jorge Solla 1313 no zeno.fm')
await expect.poll(() => zenoRequests.length).toBeGreaterThan(0)
// The cards still fetch nothing until a play (`preload="none"` on every card).
expect(audioRequests).toHaveLength(0)
// Mobile 390: the section never overflows horizontally.
await page.setViewportSize({ width: 390, height: 844 })

View file

@ -43,7 +43,9 @@ describe('JingleHomeSection', () => {
render(<JingleHomeSection jingles={[]} showAll={false} />)
expect(document.querySelector('[data-home-section="sound"]')).not.toBeNull()
expect(document.querySelector('[data-radio][data-radio-state="facade"]')).not.toBeNull()
expect(document.querySelector('[data-radio] iframe')).not.toBeNull()
// Cena 01/02 (S24): zero jingles keeps the compact, centered frame.
expect(document.querySelector('[data-radio]')?.className).toContain('max-w-[880px]')
expect(cards()).toHaveLength(0)
expect(screen.queryByText('Jingles oficiais')).toBeNull()
expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
@ -54,6 +56,8 @@ describe('JingleHomeSection', () => {
render(<JingleHomeSection jingles={[jingle(1, 'Axé')]} showAll={false} />)
expect(cards()).toHaveLength(1)
// Cena 03: with jingles the frame takes the full column width.
expect(document.querySelector('[data-radio]')?.className).not.toContain('max-w-[880px]')
expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
expect(screen.getByText('Jingles oficiais')).toBeDefined()
})

View file

@ -0,0 +1,49 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { RadioEmbed } from '@/components/jingles/RadioEmbed'
const frame = () => document.querySelector<HTMLIFrameElement>('iframe')
afterEach(cleanup)
describe('RadioEmbed', () => {
it('mounts the official player on the first render, without a click', () => {
render(<RadioEmbed />)
const mountedFrame = frame()
expect(mountedFrame).not.toBeNull()
expect(mountedFrame?.getAttribute('src')).toBe('https://zeno.fm/player/jorge-solla-1313/')
expect(mountedFrame?.getAttribute('title')).toContain('Rádio Jorge Solla 1313')
expect(mountedFrame?.getAttribute('allow')).toBe('autoplay')
})
it('does not defer the frame to the viewport', () => {
render(<RadioEmbed />)
expect(frame()?.hasAttribute('loading')).toBe(false)
})
it('labels the frame region and drops every facade affordance', () => {
render(<RadioEmbed />)
expect(screen.getByRole('article', { name: 'Player da Rádio Jorge Solla 1313' })).toBeDefined()
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('link')).toBeNull()
expect(screen.queryByText(/Ouvir a rádio/)).toBeNull()
expect(screen.queryByText(/Abrir no Zeno/)).toBeNull()
expect(screen.queryByText(/só é carregado depois do seu clique/)).toBeNull()
})
it('narrows and centers the frame only in the compact variant', () => {
const { container, unmount } = render(<RadioEmbed compact />)
expect(container.querySelector('[data-radio]')?.className).toContain('mx-auto')
expect(container.querySelector('[data-radio]')?.className).toContain('max-w-[880px]')
unmount()
const full = render(<RadioEmbed />)
expect(full.container.querySelector('[data-radio]')?.className).not.toContain('max-w-[880px]')
})
})

View file

@ -1,66 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RadioFacade } from '@/components/jingles/RadioFacade'
type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
fill?: boolean
priority?: boolean
}
vi.mock('next/image', () => ({
default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => (
// eslint-disable-next-line @next/next/no-img-element
<img alt={alt} {...props} />
),
}))
const card = () => document.querySelector<HTMLElement>('[data-radio]')
const radioState = () => card()?.getAttribute('data-radio-state')
const frame = () => document.querySelector<HTMLIFrameElement>('iframe')
afterEach(cleanup)
describe('RadioFacade', () => {
it('starts at the facade with no iframe, script or third-party request', () => {
render(<RadioFacade />)
expect(radioState()).toBe('facade')
expect(frame()).toBeNull()
expect(screen.getByRole('button', { name: 'Ouvir a rádio' })).toBeDefined()
expect(
screen.getByText(/O player do zeno\.fm só é carregado depois do seu clique/),
).toBeDefined()
})
it('keeps the external link as the always-available alternative', () => {
render(<RadioFacade />)
const link = screen.getByRole('link', { name: /Abrir no Zeno/ })
expect(link.getAttribute('href')).toBe('https://zeno.fm/radio/jorge-solla-1313/')
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
})
it('mounts the official player only on click and promotes to loaded on onLoad', () => {
render(<RadioFacade />)
fireEvent.click(screen.getByRole('button', { name: 'Ouvir a rádio' }))
expect(radioState()).toBe('loading')
const mountedFrame = frame()
expect(mountedFrame).not.toBeNull()
expect(mountedFrame?.getAttribute('src')).toBe('https://zeno.fm/player/jorge-solla-1313/')
expect(mountedFrame?.getAttribute('title')).toContain('Rádio Jorge Solla 1313')
expect(screen.getByRole('status').getAttribute('aria-busy')).toBe('true')
expect(screen.getByRole('button', { name: 'Carregando…' }).hasAttribute('disabled')).toBe(true)
fireEvent.load(mountedFrame as HTMLIFrameElement)
expect(radioState()).toBe('loaded')
expect(screen.queryByRole('status')).toBeNull()
// The very same frame survives loading → loaded: the widget never refetches.
expect(frame()).toBe(mountedFrame)
expect(screen.getByText(/Player fornecido por zeno\.fm/)).toBeDefined()
})
})