From 5aefb2f3baf7a27adebf1342a25d35b0326015cc Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Sun, 20 Sep 2026 21:42:11 -0300
Subject: [PATCH] =?UTF-8?q?feat(S24):=20r=C3=A1dio=20na=20home=20=E2=80=94?=
=?UTF-8?q?=20embed=20direto=20do=20zeno.fm,=20sem=20facade=20(Closes=20#1?=
=?UTF-8?q?235)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/changelog/2026-09-20-s24.md | 1 +
docs/plans/radio-embed-direto-impl.md | 131 +++++++++
src/app/(frontend)/styles.css | 89 +-----
src/components/jingles/JingleHomeSection.tsx | 15 +-
src/components/jingles/RadioEmbed.tsx | 31 ++
src/components/jingles/RadioFacade.tsx | 285 -------------------
src/components/jingles/focusRing.ts | 6 +-
tests/e2e/fixtures/e2eTest.ts | 13 +
tests/e2e/frontend.e2e.spec.ts | 34 ++-
tests/e2e/frontendJingles.e2e.spec.ts | 36 +--
tests/unit/jingleHomeSection.unit.spec.tsx | 6 +-
tests/unit/radioEmbed.unit.spec.tsx | 49 ++++
tests/unit/radioFacade.unit.spec.tsx | 66 -----
13 files changed, 280 insertions(+), 482 deletions(-)
create mode 100644 docs/changelog/2026-09-20-s24.md
create mode 100644 docs/plans/radio-embed-direto-impl.md
create mode 100644 src/components/jingles/RadioEmbed.tsx
delete mode 100644 src/components/jingles/RadioFacade.tsx
create mode 100644 tests/unit/radioEmbed.unit.spec.tsx
delete mode 100644 tests/unit/radioFacade.unit.spec.tsx
diff --git a/docs/changelog/2026-09-20-s24.md b/docs/changelog/2026-09-20-s24.md
new file mode 100644
index 00000000..751ef8d2
--- /dev/null
+++ b/docs/changelog/2026-09-20-s24.md
@@ -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.
diff --git a/docs/plans/radio-embed-direto-impl.md b/docs/plans/radio-embed-direto-impl.md
new file mode 100644
index 00000000..b5b94b50
--- /dev/null
+++ b/docs/plans/radio-embed-direto-impl.md
@@ -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) getPublishedJingleItems() · cache tag jingles"] --> SEC["JingleHomeSection (server) data-home-section='sound' · header/copy intactos"]
+ SEC --> EMB["RadioEmbed (server · novo, ex-RadioFacade) article[data-radio][aria-label] · moldura mínima"]
+ EMB --> IFRAME["iframe zeno player src oficial · title · allow=autoplay h 150/168 · eager no HTML inicial"]
+ SEC --> GRID["JingleCards (intocado) só se > 0 publicados"]
+ SEC --> ALL["'Ver todos os jingles' → /jingles (intocado)"]
+ FIX["fixtures/e2eTest.ts 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>zeno stub` 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=')`, 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=` 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.
diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css
index 194972e9..21e4880e 100644
--- a/src/app/(frontend)/styles.css
+++ b/src/app/(frontend)/styles.css
@@ -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;
- }
-}
diff --git a/src/components/jingles/JingleHomeSection.tsx b/src/components/jingles/JingleHomeSection.tsx
index 0687db18..a5c7b5fe 100644
--- a/src/components/jingles/JingleHomeSection.tsx
+++ b/src/components/jingles/JingleHomeSection.tsx
@@ -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)
-
+
{hasJingles ? (
<>
diff --git a/src/components/jingles/RadioEmbed.tsx b/src/components/jingles/RadioEmbed.tsx
new file mode 100644
index 00000000..5c6ccf41
--- /dev/null
+++ b/src/components/jingles/RadioEmbed.tsx
@@ -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 }) => (
+
+
+
+)
diff --git a/src/components/jingles/RadioFacade.tsx b/src/components/jingles/RadioFacade.tsx
deleted file mode 100644
index 16ad877e..00000000
--- a/src/components/jingles/RadioFacade.tsx
+++ /dev/null
@@ -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 }) => (
-
- Abrir no Zeno
-
-
-)
-
-const ShieldIcon = ({ className }: { className?: string }) => (
-
-)
-
-const RadioArt = ({ compact }: { compact?: boolean }) => (
-
-
-)
-
-/**
- * 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 }) => (
-
-
-
-
- Rádio Jorge Solla 1313
-
-
-
-
-
- Player fornecido por zeno.fm. Ao usar, você acessa um serviço externo.
-
-
-
-)
-
-export const RadioFacade = ({ compact = false }: { compact?: boolean }) => {
- const [state, setState] = useState('facade')
- const statusRef = useRef(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 (
-
-
- {state === 'facade' ? (
- setState('loading')} />
- ) : (
- <>
- {state === 'loading' ? : null}
- setState('loaded')} />
- >
- )}
-
- )
-}
diff --git a/src/components/jingles/focusRing.ts b/src/components/jingles/focusRing.ts
index e2fa0bcb..bfc630f3 100644
--- a/src/components/jingles/focusRing.ts
+++ b/src/components/jingles/focusRing.ts
@@ -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)'
diff --git a/tests/e2e/fixtures/e2eTest.ts b/tests/e2e/fixtures/e2eTest.ts
index a600f86c..4ca7f0d6 100644
--- a/tests/e2e/fixtures/e2eTest.ts
+++ b/tests/e2e/fixtures/e2eTest.ts
@@ -115,6 +115,19 @@ export const test = base.extend({
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: 'zeno stub',
+ contentType: 'text/html',
+ }),
+ )
+
await use()
page.off('console', onConsole)
diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts
index 79c85a29..50127742 100644
--- a/tests/e2e/frontend.e2e.spec.ts
+++ b/tests/e2e/frontend.e2e.spec.ts
@@ -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('[data-theme="campaign-site"]')
return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0
diff --git a/tests/e2e/frontendJingles.e2e.spec.ts b/tests/e2e/frontendJingles.e2e.spec.ts
index 6b418e7b..c48052b6 100644
--- a/tests/e2e/frontendJingles.e2e.spec.ts
+++ b/tests/e2e/frontendJingles.e2e.spec.ts
@@ -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: 'zeno stub', 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 })
diff --git a/tests/unit/jingleHomeSection.unit.spec.tsx b/tests/unit/jingleHomeSection.unit.spec.tsx
index fbfa8032..6508d54a 100644
--- a/tests/unit/jingleHomeSection.unit.spec.tsx
+++ b/tests/unit/jingleHomeSection.unit.spec.tsx
@@ -43,7 +43,9 @@ describe('JingleHomeSection', () => {
render()
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()
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()
})
diff --git a/tests/unit/radioEmbed.unit.spec.tsx b/tests/unit/radioEmbed.unit.spec.tsx
new file mode 100644
index 00000000..88e68752
--- /dev/null
+++ b/tests/unit/radioEmbed.unit.spec.tsx
@@ -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('iframe')
+
+afterEach(cleanup)
+
+describe('RadioEmbed', () => {
+ it('mounts the official player on the first render, without a click', () => {
+ render()
+
+ 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()
+
+ expect(frame()?.hasAttribute('loading')).toBe(false)
+ })
+
+ it('labels the frame region and drops every facade affordance', () => {
+ render()
+
+ 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()
+
+ expect(container.querySelector('[data-radio]')?.className).toContain('mx-auto')
+ expect(container.querySelector('[data-radio]')?.className).toContain('max-w-[880px]')
+
+ unmount()
+ const full = render()
+
+ expect(full.container.querySelector('[data-radio]')?.className).not.toContain('max-w-[880px]')
+ })
+})
diff --git a/tests/unit/radioFacade.unit.spec.tsx b/tests/unit/radioFacade.unit.spec.tsx
deleted file mode 100644
index 4cbdd5c7..00000000
--- a/tests/unit/radioFacade.unit.spec.tsx
+++ /dev/null
@@ -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 & {
- 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
-
- ),
-}))
-
-const card = () => document.querySelector('[data-radio]')
-const radioState = () => card()?.getAttribute('data-radio-state')
-const frame = () => document.querySelector('iframe')
-
-afterEach(cleanup)
-
-describe('RadioFacade', () => {
- it('starts at the facade with no iframe, script or third-party request', () => {
- render()
-
- 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()
-
- 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()
-
- 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()
- })
-})