diff --git a/docs/changelog/2026-09-20-s22.md b/docs/changelog/2026-09-20-s22.md
new file mode 100644
index 00000000..688f0a1d
--- /dev/null
+++ b/docs/changelog/2026-09-20-s22.md
@@ -0,0 +1 @@
+**S22 — Jingles e rádio na home: a seção de som (2026-09-20):** o som do mandato chega à vitrine — a home pública ganha, entre "Nossa história" e "Faça parte", a seção "A trilha da nossa caminhada" (portada classe-a-classe do artefato aprovado `docs/plans/jingles-radio-homepage-ui-design.html`, Cenas 01–07) com a **Rádio Jorge Solla 1313** e até **3 jingles publicados** do S21, sem rota nova e sem tocar `/jingles` nem o rodapé. A rádio é um `RadioFacade` click-to-load: no estado `facade` não existe iframe, script ou request de terceiro — só o botão "Ouvir a rádio" e o link "Abrir no Zeno" —; o clique monta o widget oficial do zeno.fm e o card passa por `loading` (`role="status"` + `aria-busy` + spinner, com o foco do teclado movido para a região) até `loaded` no `onLoad`, com o **mesmo footprint reservado nos três estados** (330px mobile / 312px desktop, medido 522=522 e 314=314 no app real) — zero CLS, e o iframe nunca remonta (sem refetch). Os jingles reusam o core do S21, **extraído behavior-preserving** de `JinglePlayer` para `JingleCards` (exclusividade, `preload="none"`, download pelo slug e contrato `article[data-jingle]`/`data-state` intactos, com `headingLevel` para o outline da home e o anel de foco único em `focusRing.ts`): grade `md:grid-cols-3`, até 3 exibidos e "Ver todos os jingles" → `/jingles` **somente** quando há mais de 3; com zero publicados a grade e o link somem e a rádio permanece (fail-closed, mesmo kill switch do S21) na variante compacta da Cena 07 (`max-w-[880px]`, coluna de 240px, copy própria). A home segue estática: uma leitura `getPublishedJingleItems()` (cache tag `jingles`) alimenta a seção e o flag do rodapé (`slice(0,3)` em memória; nenhum `payload.find` novo). e2e: `frontendJingles` ganhou a seção da home (facade inerte com zero request `zeno.fm` **e** zero request de MP3 antes do gesto, widget de teste via `page.route`, `data-radio-state` até `loaded`, 4 publicados → 3 cards + handoff, kill switch → só rádio) e o `frontend` pinou a ordem (`sound = story+1`, `cards = sound+1`) e o overflow 390; unit: `radioFacade` (facade sem iframe → clique monta → `onLoad` não remonta) e `jingleHomeSection` (0/1/3/>3, "Ver todos", contrato da grade). Sem migration (nenhum schema; leitura pública sem PII/Consent). Crítica final do `designer` **CERTIFICADO** (tier `openai/gpt-5.6-sol`) contra o app renderizado em 390/1280 nos estados facade, carregando, carregado e zero — após uma primeira passada com 6 ajustes (escala do título, facade mobile da Cena 02, arte/wave, CLS, variante zero, header mobile), todos aplicados. `pnpm gate:fast` verde (4224 unit), e2e local selecionado verde (`frontendJingles` 4/4, `frontend` 2/2), simplify com 2 revisores (11 achados: 9 resolvidos no lugar, 2 descartados com respaldo; nenhum débito novo).
diff --git a/docs/plans/jingles-radio-homepage-impl.md b/docs/plans/jingles-radio-homepage-impl.md
new file mode 100644
index 00000000..23a4f585
--- /dev/null
+++ b/docs/plans/jingles-radio-homepage-impl.md
@@ -0,0 +1,140 @@
+# Impl: Jingles e rádio na home — a seção de som (S22)
+
+Status: aprovado — modo --auto
+Atualizado em: 2026-09-20
+Issue: #1228
+Intenção: docs/plans/jingles-radio-homepage.md
+Appetite restante: herdado (~1 dia eng; uma seção nova na home, sem rota nova) — sem ajuste de escopo; cortes em Rabbit holes.
+
+## Leitura da intenção
+
+- **Outcome:** a home passa a ter uma seção de som curta com a Rádio Jorge Solla 1313 (player do zeno só após o clique) e até 3 jingles publicados (capa, play um por vez, download MP3), linkando `/jingles` quando houver mais; zero jingles publicados deixa a rádio e esconde a grade (fail-closed); nada de terceiro, áudio ou PII antes do gesto do visitante.
+- **O que NÃO negociar:** `/jingles` continua canônica, indexável e no rodapé (não mudar player nem página do S21); facade click-to-load da rádio (zero request de terceiro antes do clique; link externo como alternativa); até 3 jingles + "Ver todos" só quando houver mais; posição entre `CampaignStorySection` e `CampaignCardsSection`; fonte única é a collection `jingle` via `getPublishedJingleItems()` (sem collection/mídia nova); home estática (nada de `payload.find` cru); kill switch do S21 preservado; sem formulário/consent/PII.
+- **O que reavaliar (hipóteses do plano de intenção):** (a) o `JinglePlayer` do S21 é reusável como está? — não: ele hardcoda o shell de página inteira (`JinglePlayer.tsx:134-135,261`) e a grade; a hipótese correta é extrair o core; (b) o fixture `e2eTest.ts` "aborta requests externos"? — não em geral: ele só roteia `youtube-nocookie` (`:108`) e `facebook.net` (`:114`); o iframe do zeno exige `page.route` próprio no spec, senão vira rede real/erro de console no guard; (c) o e2e da home mede overflow? — sim (`frontend.e2e.spec.ts:553-557`), mas não da seção nova: ela ganha asserção própria.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ RSC["(home)/page.tsx (RSC) getPublishedJingleItems() · cache tag jingles"] --> SEC["JingleHomeSection (server) data-home-section='sound'"]
+ RSC -- "slice(0,3)" --> GRID["JingleCards (client, extraído) article[data-jingle] · data-state · preload=none"]
+ RSC -- ">>> 3" --> ALL["Link 'Ver todos os jingles' → /jingles"]
+ SEC --> RADIO["RadioFacade (client) data-radio-state: facade → loading → loaded"]
+ RADIO -- "clique" --> IFRAME["iframe zeno.fm/player/jorge-solla-1313/"]
+ S21["JinglePlayer (shell S21) · /jingles"] --> GRID
+```
+
+**Opções consideradas:** A | B | C | D (por decisão, abaixo)
+**Recomendação:** extrair o core do player (`JingleCards`) e montar a seção da home com ele + uma facade de rádio sob `src/components/jingles/`; leitura única do listing cacheado na page.
+**Rejeitadas:** props de shell no `JinglePlayer` (arrisca `/jingles` sem ganho), grade reimplementada na home (duplica a máquina de estado), iframe direto da rádio (viola o aceite), spec e2e novo (segundo dono das linhas de jingle + entrada de manifest).
+
+### Decisões de engenharia
+
+**D1 — Reuso do player (caro: fronteira de módulo)**
+Opções: A) props de shell/compacto no `JinglePlayer`; B) extrair `JingleCards` (core client) e deixar `JinglePlayer` como shell do S21; C) montar a grade na home sem reusar.
+Recomendação: B — uma única dona da exclusividade (`activeId`/`playback`/refs em `JinglePlayer.tsx:83-131`), `/jingles` visual e contratualmente intacta (seletores pinados `article[data-jingle]`, `data-state`, `button[data-play]`, `audio[preload="none"]`, `download={downloadFilename}`) e nenhum branching novo no componente que o e2e `frontendJingles` pina.
+Alternativas rejeitadas: A porque move o branching para o shell pinado e arrisca regressão visual em `/jingles`; C porque duplica a exclusividade, o catch do `play()` abortado e o progresso — drift garantido contra `jinglePlayer.unit.spec.tsx`.
+
+**D2 — Facade da rádio (caro: componente client + contrato de terceiro)**
+Opções: A) `RadioFacade` client em `src/components/jingles/`, sem sandbox, estados `facade/loading/loaded`, iframe oficial só no clique; B) iframe direto (como o YouTube da story); C) só link externo; D) facade com `sandbox` restritivo.
+Recomendação: A — entrega o "embed tocável" pedido sem expor GA (`G-2T527NZWVM`) e redes de anúncio da zeno em todo pageview; fica sob o prefixo `src/components/jingles` (manifest `frontendJingles`, sem entrada nova); iframe com `src` do widget, `title` em pt-BR, `allow="autoplay"`, altura reservada; clique monta, `onLoad` promove `loading → loaded`; `aria-busy` no botão de carga; link "Abrir no Zeno" `target="_blank" rel="noopener noreferrer"` sempre visível na facade.
+Alternativas rejeitadas: B porque carrega third-party/ads em toda abertura da home e viola o aceite; C porque não entrega o player pedido; D porque sandbox sem contrato do widget pode quebrar o player sem ganho de isolamento (o vendor já vê o que veria) — reavaliar apenas se a zeno documentar requisitos.
+Lacuna de design: o artefato só tem `facade`/`loaded` (a cena 03 é "loaded", sem pending). Fase 0 estende o artefato com a cena de loading antes de qualquer markup.
+
+**D3 — Leitura na home e derivação do rodapé (caro: cache/estático)**
+Opções: A) uma chamada `getPublishedJingleItems()` na page e derivar `showJingles` e a seleção; B) manter `hasPublishedJingles()` + chamada extra ao listing; C) mover o slice para dentro de `JingleHomeSection`.
+Recomendação: A — o listing cacheado (`unstable_cache` tag `jingles`, `jingleReads.ts:32-35`) já é a fonte do rodapé e o padrão existente é o dono da lista derivar o flag (`/jingles/page.tsx:84-100`, comentário em `CampaignFooter.tsx:21`); uma leitura só evita divergir "tem jingles" de "tem grade"; `slice(0,3)` em memória (limit 0 devolve todos; volume trivial).
+Alternativas rejeitadas: B porque `hasPublishedJingles` vira pass-through de `length > 0` quando a lista já está em mãos (depth check); C porque esconde a regra de produto no componente e tira do teste da page a seleção.
+`hasPublishedJingles` continua usado por `(home)/cards/page.tsx:27` — não fica órfão (knip ok). O mock de `tests/unit/campaignHome.unit.spec.tsx:41-43` passa a exportar `getPublishedJingleItems`.
+
+**D4 — Posição e pino de ordem (caro: pin de e2e)**
+Opções: A) `data-home-section="sound"` entre `story` e `cards` + atualizar o pino; B) renderizar sem `data-home-section` para não tocar o teste.
+Recomendação: A — o pino (`frontend.e2e.spec.ts:1700-1706`) existe para proteger a ordem do bloco de conversão; a seção nova é parte da home e deve aparecer no contrato. Atualizar para `sound === story + 1` e `cards === sound + 1` (e manter `cards === newsletter - 1`).
+Alternativa rejeitada: B porque deixa a seção invisível ao teste de ordem e qualquer reordenação futura passa silenciosa.
+
+**D5 — Estratégia de testes (caro: superfície de spec/manifest)**
+Opções: A) estender `frontendJingles.e2e.spec.ts` (serial, dono das linhas de jingle) + update do pino/overflow em `frontend.e2e.spec.ts`; B) criar `frontendJinglesHome.e2e.spec.ts`; C) só unit.
+Recomendação: A — o spec serial controla publicação/kill switch e já visita a home (`frontendJingles.e2e.spec.ts:200-201,231-232`), então grade/facade/zero/overflow-com-conteúdo cabem nele; o dono da home mantém ordem e overflow independente-de-conteúdo. Os prefixos atuais do manifest (`src/components/jingles` → `frontendJingles`, `:99-108`; `src/app/(frontend)` → `frontend`, `:77`) já cobrem todos os arquivos tocados — sem mudança no manifest.
+Alternativas rejeitadas: B porque exige entrada nova no manifest e cria segundo dono das linhas de jingle (serialização/limpeza duplicadas) sem cobertura extra; C porque não prova o facade sem request de terceiro nem o estado real da home.
+
+### Componentes / mudanças
+
+- **`src/app/(frontend)/(home)/page.tsx`** — troca o import de `hasPublishedJingles` (`:9`) por `getPublishedJingleItems`; `const jingles = await getPublishedJingleItems()`; `const homeJingles = jingles.slice(0, 3)`; `showJingles={jingles.length > 0}`; render ` homeJingles.length} />` entre `` (`:241`) e `` (`:242`).
+- **`src/components/jingles/JingleCards.tsx`** (novo, `'use client'`) — core extraído de `JinglePlayer.tsx:83-259`: estado de exclusividade, progresso e o grid (`max-w-6xl`, `gap-5 sm:gap-6 md:grid-cols-3`), com todo o markup/contrato dos cards (capa `next/image fill` sizes 33vw, título, `data-play`, `data-state`, progressbar, download com `downloadFilename` e `
- Facade da rádio + três jingles · no ritmo real da home
+ Facade da rádio + três de um acervo maior · no ritmo real da home
@@ -481,7 +534,7 @@
>
-
+
@@ -533,9 +586,40 @@
>O player do zeno.fm só é carregado depois do seu clique.
Facade não contém iframe: zeno.fm só entra após “Ouvir a rádio”.
+
+ Pós-clique: loading mantém logo, título e “Abrir no Zeno”; região e botão
+ usam aria-busy="true" até o onLoad.
+
+
+ .radio-state-panel reserva 260px no mobile e 300px no desktop; slot do
+ widget, 150px / 168px — sem salto entre facade, loading e loaded.
+
“Rádio online” identifica o canal sem afirmar status “ao vivo” não verificado.
Cards, play de 56px, download de 44px, foco e progresso preservam o S21.
@@ -1458,6 +2211,23 @@
Com zero publicados, nenhum card ou link de jingles é renderizado; a rádio permanece.
+
+ Com 1–2 publicados, a grade continua md:grid-cols-3, ocupa só as primeiras
+ colunas e não renderiza “Ver todos”; com 3 também não renderiza — o link exige >3.
+
+
+ Capas portam para next/image fill no wrapper
+ relative aspect-square; mobile 390 nunca cria largura mínima nem overflow
+ horizontal.
+
+
+ Reusar tokens [data-theme='campaign-site'], shells
+ campaign-section-eyebrow/title/copy e o card do S21 classe a classe.
+
+
+ A wave é aria-hidden; prefers-reduced-motion remove sua
+ animação e a do spinner sem remover o affordance visual.
+
Sem autoplay, captação de dados ou request de terceiro antes da ação explícita.
@@ -1468,7 +2238,10 @@
document.querySelectorAll('[data-load-radio]').forEach((button) => {
button.addEventListener('click', () => {
const card = button.closest('[data-radio]')
- card.dataset.radioState = 'loaded'
+ // O artefato para em loading para deixá-lo inspecionável; a Cena 03 mostra o onLoad.
+ // Nenhum iframe/src é criado aqui: é somente apresentação, sem request de terceiro.
+ button.setAttribute('aria-busy', 'true')
+ card.dataset.radioState = 'loading'
})
})
diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx
index 17f8dddf..57c8997f 100644
--- a/src/app/(frontend)/(home)/page.tsx
+++ b/src/app/(frontend)/(home)/page.tsx
@@ -5,8 +5,9 @@ import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
+import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
-import { hasPublishedJingles } from '@/utilities/jingleReads'
+import { getPublishedJingleItems } from '@/utilities/jingleReads'
import type { ReactNode } from 'react'
import { CampaignCardsSection } from './CampaignCardsSection'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
@@ -115,8 +116,10 @@ const proofItems = [
export default async function HomePage() {
// S10 — site-level Meta pixel for the campaign home (cached read, fail-closed).
const pixelId = await getCampaignHomeMetaPixelId()
- // S21 — the footer's "Jingles" discovery link (cached, keeps the home static).
- const showJingles = await hasPublishedJingles()
+ // S21/S22 — one cached listing (`unstable_cache` tag `jingles`) serves the
+ // home sound section and the footer's discovery flag, keeping the home static.
+ const jingles = await getPublishedJingleItems()
+ const homeJingles = jingles.slice(0, 3)
return (
<>
@@ -239,11 +242,12 @@ export default async function HomePage() {
+ homeJingles.length} />
-
+ 0} />
>
)
}
diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css
index 878749e5..194972e9 100644
--- a/src/app/(frontend)/styles.css
+++ b/src/app/(frontend)/styles.css
@@ -1154,3 +1154,92 @@ body,
border: 1px solid rgb(0 0 0 / 20%);
}
}
+
+/* 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. */
+.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/JingleCards.tsx b/src/components/jingles/JingleCards.tsx
new file mode 100644
index 00000000..8ae487a5
--- /dev/null
+++ b/src/components/jingles/JingleCards.tsx
@@ -0,0 +1,267 @@
+'use client'
+
+import Image from 'next/image'
+import { useRef, useState } from 'react'
+
+import type { JingleViewModel } from '@/lib/jingle'
+import { formatSpeechClock } from '@/lib/speechClock'
+import { cn } from '@/lib/utils'
+
+import { JINGLE_FOCUS_RING } from './focusRing'
+
+/**
+ * S21 — the jingles card grid (artefato: cenas 01/02). One client component
+ * owns the exclusivity state: every card renders its own `` (no request before the play, per the gate note) and starting
+ * one pauses the others. The play control reverts on a failed `play()` so
+ * `aria-pressed` never gets stuck; progress/time come from the media events.
+ * S22 reuses this core in the home sound section, so the
+ * `article[data-jingle]`/`data-state`/download contract stays single-owned and
+ * the heading level follows the host page outline.
+ */
+
+const PLAY_CONTROL = cn(
+ 'inline-flex h-14 w-14 flex-none items-center justify-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink)',
+ 'shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow,filter] duration-150 ease-out',
+ 'hover:-translate-y-0.5 hover:brightness-[0.98] hover:shadow-[0_8px_0_#cfb900,0_12px_24px_rgb(0_0_0/18%)]',
+ 'active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900]',
+ 'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
+ JINGLE_FOCUS_RING,
+)
+
+const DOWNLOAD_CONTROL = cn(
+ 'mt-5 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/32%)] bg-transparent',
+ 'font-[family-name:var(--font-exo2)] font-extrabold text-(--pt-red) no-underline',
+ 'transition-[background-color,border-color,transform] duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] active:scale-[0.99]',
+ 'motion-reduce:transition-none',
+ JINGLE_FOCUS_RING,
+)
+
+const PlayIcon = () => (
+
+
+
+)
+
+const PauseIcon = () => (
+
+
+
+)
+
+const DownloadIcon = () => (
+
+
+
+
+
+)
+
+type Playback = {
+ currentSeconds: number
+ durationSeconds: number | null
+}
+
+const EMPTY_PLAYBACK: Playback = { currentSeconds: 0, durationSeconds: null }
+
+const EMPTY_TIME = '—:—'
+
+export const JingleCards = ({
+ jingles,
+ headingLevel = 'h2',
+}: {
+ jingles: readonly JingleViewModel[]
+ headingLevel?: 'h2' | 'h3' | 'h4'
+}) => {
+ const Heading = headingLevel
+ const [activeId, setActiveId] = useState(null)
+ const [playback, setPlayback] = useState>({})
+ const audioRefs = useRef(new Map())
+ // One stable ref callback per id: an inline arrow would be detached and
+ // reattached on every render (each `timeupdate` re-renders the list).
+ const audioRefCallbacks = useRef(new Map void>())
+
+ const audioRef = (id: number) => {
+ const cached = audioRefCallbacks.current.get(id)
+ if (cached) return cached
+
+ const callback = (node: HTMLAudioElement | null) => {
+ if (node) {
+ audioRefs.current.set(id, node)
+ return
+ }
+
+ audioRefs.current.delete(id)
+ }
+ audioRefCallbacks.current.set(id, callback)
+ return callback
+ }
+
+ const updatePlayback = (id: number, patch: Partial) => {
+ setPlayback((current) => ({
+ ...current,
+ [id]: { ...(current[id] ?? EMPTY_PLAYBACK), ...patch },
+ }))
+ }
+
+ const toggle = (id: number) => {
+ const audio = audioRefs.current.get(id)
+ if (!audio) return
+
+ if (activeId === id) {
+ audio.pause()
+ setActiveId(null)
+ return
+ }
+
+ for (const [otherId, other] of audioRefs.current) {
+ if (otherId !== id) other.pause()
+ }
+
+ setActiveId(id)
+ // A pending play() of another card rejects when we pause it; the catch
+ // must only clear the state of THIS card, never the one now playing.
+ void audio.play().catch(() => setActiveId((current) => (current === id ? null : current)))
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/jingles/JingleHomeSection.tsx b/src/components/jingles/JingleHomeSection.tsx
new file mode 100644
index 00000000..0687db18
--- /dev/null
+++ b/src/components/jingles/JingleHomeSection.tsx
@@ -0,0 +1,105 @@
+import Link from 'next/link'
+
+import type { JingleViewModel } from '@/lib/jingle'
+
+import { JINGLE_FOCUS_RING } from './focusRing'
+import { JingleCards } from './JingleCards'
+import { RadioFacade } from './RadioFacade'
+
+type JingleHomeSectionProps = {
+ jingles: readonly JingleViewModel[]
+ showAll: boolean
+}
+
+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
+ * `getPublishedJingleItems()` listing in, so the section never re-reads.
+ */
+export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps) => {
+ const hasJingles = jingles.length > 0
+
+ return (
+
+
+
+
+
+ A trilha da nossa caminhada
+
+
+ {hasJingles ? (
+ <>
+ Cante, baixe
+ e espalhe
+ >
+ ) : (
+ 'Sintonize com a Rádio 1313'
+ )}
+
+
+ {hasJingles
+ ? 'Ouça a Rádio Jorge Solla 1313 e os jingles oficiais da campanha. Dê o play e baixe o seu preferido para levar com você.'
+ : 'Ainda não há jingles publicados por aqui. Enquanto isso, ouça a Rádio Jorge Solla 1313.'}
+
+
+
+
+
+ {hasJingles ? (
+ <>
+
+
+
+ Jingles oficiais
+
+
+ Dê o play
+ Dê o play. O próximo ritmo é seu.
+
+
+ Um jingle toca por vez.
+
+
+
+
+
+
+ {showAll ? (
+
+
+ Ver todos os jingles
+
+
+
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/jingles/JinglePlayer.tsx b/src/components/jingles/JinglePlayer.tsx
index c9cd49cc..df2a83e4 100644
--- a/src/components/jingles/JinglePlayer.tsx
+++ b/src/components/jingles/JinglePlayer.tsx
@@ -1,266 +1,19 @@
-'use client'
-
-import Image from 'next/image'
-import { useRef, useState } from 'react'
-
import type { JingleViewModel } from '@/lib/jingle'
-import { formatSpeechClock } from '@/lib/speechClock'
-import { cn } from '@/lib/utils'
+
+import { JingleCards } from './JingleCards'
/**
- * S21 — the jingles list (artefato: cenas 01/02). One client component owns the
- * exclusivity state: every card renders its own `` (no
- * request before the play, per the gate note) and starting one pauses the
- * others. The play control reverts on a failed `play()` so `aria-pressed` never
- * gets stuck; progress/time come from the media events.
+ * S21 — the jingles page shell (artefato: cenas 01/02): the player core
+ * (`JingleCards`) owns the exclusivity state, the lazy audio and the download
+ * name; this shell keeps the published band and the lazy-audio note. S22
+ * reuses the same core in the home sound section.
*/
+export const JinglePlayer = ({ jingles }: { jingles: readonly JingleViewModel[] }) => (
+
+
-const 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)'
-
-const PLAY_CONTROL = cn(
- 'inline-flex h-14 w-14 flex-none items-center justify-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink)',
- 'shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow,filter] duration-150 ease-out',
- 'hover:-translate-y-0.5 hover:brightness-[0.98] hover:shadow-[0_8px_0_#cfb900,0_12px_24px_rgb(0_0_0/18%)]',
- 'active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900]',
- 'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
- FOCUS_RING,
+
+ Sem autoplay. O áudio só é carregado depois do play.
+
- Sem autoplay. O áudio só é carregado depois do play.
-
-
- )
-}
diff --git a/src/components/jingles/RadioFacade.tsx b/src/components/jingles/RadioFacade.tsx
new file mode 100644
index 00000000..16ad877e
--- /dev/null
+++ b/src/components/jingles/RadioFacade.tsx
@@ -0,0 +1,285 @@
+'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
new file mode 100644
index 00000000..e2fa0bcb
--- /dev/null
+++ b/src/components/jingles/focusRing.ts
@@ -0,0 +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.
+ */
+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/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts
index 7ba9b5c6..8495e65e 100644
--- a/tests/e2e/frontend.e2e.spec.ts
+++ b/tests/e2e/frontend.e2e.spec.ts
@@ -557,6 +557,52 @@ test.describe('Frontend', () => {
expect(overflow).toBeLessThanOrEqual(1)
})
+ test('shows the sound section between the story and the cards with the inert radio facade', async ({
+ page,
+ }) => {
+ await page.goto('/')
+
+ const sound = page.locator('[data-home-section="sound"]')
+ await expect(sound).toBeVisible()
+ await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible()
+
+ // Position: after the story, before the cards (the conversion block stays
+ // last). The jingle grid is content-dependent, so this spec never counts
+ // cards — counts belong to the serial frontendJingles spec.
+ const tops = await page.evaluate(() => {
+ const top = (selector: string) => {
+ const element = document.querySelector(selector)
+ if (!element) throw new Error(`Seção ausente: ${selector}`)
+ return element.getBoundingClientRect().top
+ }
+ return {
+ story: top('[data-home-section="story"]'),
+ sound: top('[data-home-section="sound"]'),
+ cards: top('[data-home-section="cards"]'),
+ }
+ })
+ 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/',
+ )
+
+ // Mobile 390: the section never overflows horizontally.
+ await page.setViewportSize({ width: 390, height: 844 })
+ const overflow = await page.evaluate(() => {
+ const scrollContainer = document.querySelector('[data-theme="campaign-site"]')
+ return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0
+ })
+ expect(overflow).toBeLessThanOrEqual(1)
+ })
+
test('keeps carousel auto-advance and chips on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
@@ -1702,7 +1748,10 @@ test.describe('Cards personalizados (S14)', () => {
.evaluateAll((sections) =>
sections.map((section) => section.getAttribute('data-home-section')),
)
- expect(order.indexOf('cards')).toBe(order.indexOf('story') + 1)
+ // S22 — the sound section sits between the story and the cards; the
+ // conversion block (cards + newsletter) stays last.
+ expect(order.indexOf('sound')).toBe(order.indexOf('story') + 1)
+ expect(order.indexOf('cards')).toBe(order.indexOf('sound') + 1)
expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') - 1)
const section = page.locator('section#cards')
diff --git a/tests/e2e/frontendJingles.e2e.spec.ts b/tests/e2e/frontendJingles.e2e.spec.ts
index de3d22ee..6b418e7b 100644
--- a/tests/e2e/frontendJingles.e2e.spec.ts
+++ b/tests/e2e/frontendJingles.e2e.spec.ts
@@ -199,6 +199,12 @@ 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.
+ 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('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)
await expect
.poll(async () =>
@@ -237,4 +243,81 @@ test.describe('Frontend jingles (S21)', () => {
await page.goto('/')
await expect(page.getByRole('link', { name: 'Jingles' })).toBeVisible()
})
+
+ test('shows the sound section on the home and loads the radio only on click', async ({
+ page,
+ request,
+ }) => {
+ const headers = await adminHeaders(request, BASE_URL)
+ await unpublishEveryJingle(request, headers)
+ for (const title of ['Axé na home', 'Forró na home', 'Pagodão na home']) {
+ await createJingle(request, headers, { title, slug: uniqueSlug() })
+ }
+
+ const zenoRequests: string[] = []
+ const audioRequests: string[] = []
+ page.on('request', (req) => {
+ 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"]')
+ await expect(sound).toBeVisible()
+ await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible()
+ await expect(sound.locator('article[data-jingle]')).toHaveCount(3)
+ // 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')
+ await expect(frame).toHaveCount(1)
+ await expect(frame).toHaveAttribute('src', 'https://zeno.fm/player/jorge-solla-1313/')
+ expect(zenoRequests.length).toBeGreaterThan(0)
+
+ // Mobile 390: the section never overflows horizontally.
+ await page.setViewportSize({ width: 390, height: 844 })
+ const overflow = await page.evaluate(() => {
+ const scrollContainer = document.querySelector('[data-theme="campaign-site"]')
+ return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0
+ })
+ expect(overflow).toBeLessThanOrEqual(1)
+ })
+
+ test('shows three of a larger collection and hands off to /jingles', async ({
+ page,
+ request,
+ }) => {
+ const headers = await adminHeaders(request, BASE_URL)
+ await unpublishEveryJingle(request, headers)
+ for (let index = 0; index < 4; index += 1) {
+ await createJingle(request, headers, { title: `Jingle extra ${index}`, slug: uniqueSlug() })
+ }
+
+ await page.goto(`/?e2e=${Date.now()}`)
+ const sound = page.locator('[data-home-section="sound"]')
+ await expect(sound.locator('article[data-jingle]')).toHaveCount(3)
+
+ const seeAll = sound.getByRole('link', { name: 'Ver todos os jingles' })
+ await expect(seeAll).toBeVisible()
+ await expect(seeAll).toHaveAttribute('href', '/jingles')
+ })
})
diff --git a/tests/unit/campaignHome.unit.spec.tsx b/tests/unit/campaignHome.unit.spec.tsx
index 4537451c..4cf18746 100644
--- a/tests/unit/campaignHome.unit.spec.tsx
+++ b/tests/unit/campaignHome.unit.spec.tsx
@@ -35,11 +35,12 @@ vi.mock('@/utilities/campaignHomeTracking', () => ({
getCampaignHomeMetaPixelId: async () => null,
}))
-// S21 — the footer discovery flag reads the jingle listing through the Payload
-// DB + unstable_cache; the unit env has neither, and the discovery/kill-switch
-// behavior is e2e-covered (frontendJingles.e2e.spec.ts).
+// S21/S22 — the home reads the jingle listing through the Payload DB +
+// unstable_cache for the sound section and the footer discovery flag; the unit
+// env has neither, and the real listing/kill-switch behavior is e2e-covered
+// (frontendJingles.e2e.spec.ts).
vi.mock('@/utilities/jingleReads', () => ({
- hasPublishedJingles: async () => false,
+ getPublishedJingleItems: async () => [],
}))
// S14 — the card section renders the client studio island (next/font local
@@ -56,7 +57,7 @@ vi.mock('@/components/cards/CardsStudio', () => ({
afterEach(cleanup)
describe('Campaign home', () => {
- it('monta as cinco seções previstas e o rodapé eleitoral', async () => {
+ it('monta as seis seções previstas e o rodapé eleitoral', async () => {
render(await HomePage())
expect(screen.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO' })).toBeTruthy()
@@ -69,6 +70,8 @@ describe('Campaign home', () => {
name: /Junto com o trabalhador e do lado de quem mais precisa, sempre/i,
}),
).toBeTruthy()
+ // S22 — with zero published jingles the sound section shows only the radio.
+ expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeTruthy()
expect(screen.getByRole('heading', { name: /Mostre que você está com Solla/i })).toBeTruthy()
expect(screen.getByRole('heading', { name: /Receba as novidades da campanha/i })).toBeTruthy()
expect(screen.getByText(/CNPJ: 68\.430\.467\/0001-05/)).toBeTruthy()
diff --git a/tests/unit/jingleHomeSection.unit.spec.tsx b/tests/unit/jingleHomeSection.unit.spec.tsx
new file mode 100644
index 00000000..fbfa8032
--- /dev/null
+++ b/tests/unit/jingleHomeSection.unit.spec.tsx
@@ -0,0 +1,75 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
+import type { JingleViewModel } from '@/lib/jingle'
+
+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
+
+ ),
+}))
+
+vi.mock('next/link', () => ({
+ default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
+
+ {children}
+
+ ),
+}))
+
+const jingle = (id: number, title: string): JingleViewModel => ({
+ id,
+ title,
+ coverUrl: `/api/media/file/capa-${id}.jpg`,
+ coverAlt: `Capa do jingle ${title} de Jorge Solla 1313`,
+ audioUrl: `/api/media/file/${id}.mp3`,
+ downloadFilename: `jorge-solla-1313-${id}.mp3`,
+})
+
+const THREE_JINGLES = [jingle(1, 'Axé'), jingle(2, 'Forró'), jingle(3, 'Pagodão')] as const
+const cards = () => document.querySelectorAll('article[data-jingle]')
+
+afterEach(cleanup)
+
+describe('JingleHomeSection', () => {
+ it('renders the radio alone with zero published jingles (fail-closed)', () => {
+ render()
+
+ expect(document.querySelector('[data-home-section="sound"]')).not.toBeNull()
+ expect(document.querySelector('[data-radio][data-radio-state="facade"]')).not.toBeNull()
+ expect(cards()).toHaveLength(0)
+ expect(screen.queryByText('Jingles oficiais')).toBeNull()
+ expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
+ expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeDefined()
+ })
+
+ it('renders one or two jingles without inventing content or "Ver todos"', () => {
+ render()
+
+ expect(cards()).toHaveLength(1)
+ expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
+ expect(screen.getByText('Jingles oficiais')).toBeDefined()
+ })
+
+ it('renders the three shown jingles without "Ver todos" when the listing fits', () => {
+ render()
+
+ expect(cards()).toHaveLength(3)
+ expect(screen.getByRole('heading', { name: 'Cante, baixe e espalhe' })).toBeDefined()
+ expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
+ })
+
+ it('hands off to /jingles only when more than three are published', () => {
+ render()
+
+ const link = screen.getByRole('link', { name: /Ver todos os jingles/ })
+ expect(link.getAttribute('href')).toBe('/jingles')
+ })
+})
diff --git a/tests/unit/radioFacade.unit.spec.tsx b/tests/unit/radioFacade.unit.spec.tsx
new file mode 100644
index 00000000..4cbdd5c7
--- /dev/null
+++ b/tests/unit/radioFacade.unit.spec.tsx
@@ -0,0 +1,66 @@
+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()
+ })
+})