33 KiB
Impl: Player próprio da Rádio 1313 na home
Status: aprovado (modo --auto)
Atualizado em: 2026-09-21
Issue: #1239
Intenção: docs/plans/radio-player-proprio.md
Appetite restante: herdado (~1 dia eng) — sem corte; a entrega é um componente novo + reescrita de contratos de teste, cabe em uma sessão.
Leitura da intenção
- Outcome: o iframe do zeno.fm sai da home e a seção de som passa a mostrar um player próprio
<audio preload="none">sobrehttps://stream.zeno.fm/hys86kx6k16tv, discreto, com a cara da campanha: título fixo "Rádio Jorge Solla 1313", indicador "Ao vivo", CTA de compartilhar (WhatsApp com a mensagem literal + copiar com feedback), estadosidle/connecting/playing/errorfail-soft (erro → "Tentar novamente" + "Ouvir no Zeno" + compartilhar; nunca spinner infinito) e exclusividade rádio↔jingle na seção.preload="none"garante zero request ao stream ou a terceiros antes do gesto de play; kill switch preservado (0 jingles → rádio sozinha compacta; com jingles → grade + "Ver todos" abaixo);/jinglese o player dos jingles comportamentalmente intocados além do listener/dispatch de exclusividade; home estática (cache tagjingles) intocada; sem migration/schema/Consent/PII; MetaPixel da home intocado. - O que NÃO negociar: (1) player próprio é a única superfície de rádio da seção — nenhum iframe, nenhuma marca zeno visível; (2)
preload="none"e play só por gesto (nada de autoplay com som); (3) um áudio por vez na seção (rádio pausa jingle e vice-versa); (4) mensagem de compartilhamento literalOuça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/; (5) fail-soft com timeout — spinner nunca infinito; (6) kill switch e variante compact preservados; (7)/jinglesfica intocado além da exclusividade; (8) sem Consent/CSP/analytics/migration; (9) port classe-a-classe do design gate (o designer é dono da estrutura visual), com a estrela oficial do kit como arte assumida (NEEDS ASSET). - O que reavaliar (hipóteses do explorador): (a) o embed server do S24 morre inteiro — o player é client (estado de mídia, evento, popover); (b)
RadioEmbed.tsxnão é editado, é deletado (o iframe era 100% do arquivo) e o spec do iframe morre com ele; (c) a mensagem literal não encaixa emContentShareKind(article|video|instagram) — não criarkindnovo, a mensagem é fixa emsrc/lib/radio.ts; (d)JingleCardsé compartilhado com/jingles, então o listener deradio:playtem de ser inócuo fora da home (pausa só o próprio áudio ativo, sem estado global); (e) o repo não temonErrorde mídia em lugar nenhum — a detecção de erro precisa de promise + evento + timeout manual; (f) o stubzeno.fm/player/**do fixture fica morto e o stub novo é do stream (stream.zeno.fm/**), só requisitado após o gesto.
Abordagem recomendada
flowchart LR
HOME["(home)/page.tsx (RSC, intocado)<br/>getPublishedJingleItems() · cache tag jingles"] --> SEC["JingleHomeSection (server)<br/>header/copy/kill switch intactos; só troca o filho"]
SEC --> RP["RadioPlayer (client · novo)<br/>article[data-radio][data-state] · audio preload=none<br/>idle → connecting → playing | error"]
RP --> LIB["src/lib/radio.ts (puro, client-safe)<br/>stream · página · título · mensagem · eventos · timeout"]
RP --> SHARE["RadioShareControl (privado do arquivo)<br/>Popover + useCopyFeedback + wa.me"]
RP -. "radio:play / radio:pause" .-> JC["JingleCards (client, também em /jingles)<br/>pausa só o próprio áudio ativo"]
JC -. "jingle:play {id}" .-> RP
RP --> STREAM["stream.zeno.fm (302 CDN, token por request)<br/>requisitado só depois do gesto"]
FIX["fixtures/e2eTest.ts<br/>stream.zeno.fm → MP3 stub"] -.-> STREAM
OLD["RadioEmbed.tsx (iframe zeno) — deletado"] -.-> RP
Opções consideradas: A) RadioPlayer.tsx novo ao lado da seção + src/lib/radio.ts puro, deletando RadioEmbed.tsx; B) estender RadioEmbed.tsx in-place; C) mover para um diretório novo src/components/radio/.
Recomendação: A — o concern "rádio" ganha um dono só (o arquivo novo), o iframe sai sem deixar código morto para o knip, o contrato puro (URLs/literais/eventos/timeout) fica testável fora do React, e a seção muda uma linha (<RadioEmbed compact/> → <RadioPlayer compact/>). D2 detalha.
Rejeitadas: B porque o arquivo do iframe não tem uma linha aproveitável (o nome "Embed" mentiria e o contrato antigo ficaria ao lado do novo); C porque cria topologia nova para um componente que pertence à seção de jingles já existente (o manifest cobre src/components/jingles), sem ganho.
Componentes / mudanças
src/lib/radio.ts(novo; puro, client-safe): dono do contrato da rádio —RADIO_STREAM_URL = 'https://stream.zeno.fm/hys86kx6k16tv',RADIO_PAGE_URL = 'https://zeno.fm/radio/jorge-solla-1313/',RADIO_TITLE = 'Rádio Jorge Solla 1313',RADIO_SHARE_MESSAGE = 'Ouça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/',RADIO_CONNECT_TIMEOUT_MS = 12_000,buildRadioShareWhatsAppUrl()(reusabuildWhatsAppTextShareUrldesrc/lib/phone.ts) e as constantes de broadcastRADIO_PLAY_EVENT='radio:play',RADIO_PAUSE_EVENT='radio:pause',JINGLE_PLAY_EVENT='jingle:play'+type AudioExclusivityDetail = { source: 'radio' } | { source: 'jingle'; id: number }. Nenhum import de React/DOM (testável em unit puro).src/components/jingles/RadioPlayer.tsx(novo;'use client'): props{ compact?: boolean }(mesmo contrato doRadioEmbed, a seção não reaprende nada). Dono do estado (idle|connecting|playing|error), do<audio preload="none" src={RADIO_STREAM_URL}>, do timeout de conexão, do listener dejingle:playe do dispatch deradio:play/radio:pause; renderiza o port do design (D6) comdata-radio,data-state={status}earia-label="Player da Rádio Jorge Solla 1313".RadioShareControlé um subcomponente privado do mesmo arquivo (3 usos internos: ações desktop, linha inferior mobile, linha compact) — nada exportado.src/components/jingles/JingleCards.tsx: adiciona (1) o dispatch deJINGLE_PLAY_EVENTcom{ source: 'jingle', id }notoggleao iniciar um card e (2) um efeito com listener deRADIO_PLAY_EVENTque pausa somente o próprio áudio ativo (ref doactiveId, sem estado global) e limpa oactiveId. Fora da home (em/jingles) o listener é inócuo: nenhumradio:playacontece na página e ojingle:playemitido não tem consumidor — comportamento idêntico ao de hoje. Reusa os refs/mocks já existentes.src/components/jingles/JingleHomeSection.tsx: troca import/uso (<RadioEmbed compact={!hasJingles}/>→<RadioPlayer compact={!hasJingles}/>) e atualiza o comentário S22→S25. Header/copy condicional porhasJingles,data-home-section="sound",aria-labelledby, grid, "Ver todos" ecompactintactos.src/components/jingles/RadioEmbed.tsx: deletado (iframe inteiro sai; sem código morto para o knip).src/app/(frontend)/styles.css: só o comentário de:1158-1161(hoje cita RadioEmbed/zeno) passa a descrever oRadioPlayer;.campaign-sound-title, tokens--campaign-line/--campaign-band/--pt-red/--pt-yellow/--campaign-mutede utilitários existentes bastam — nenhuma regra nova.src/app/(frontend)/(home)/page.tsx: intocado (leitura cacheada,slice(0,3), cache tagjingles, MetaPixel).- Testes:
tests/unit/radio.unit.spec.ts+tests/unit/radioPlayer.unit.spec.tsx(novos),tests/unit/radioEmbed.unit.spec.tsx(deletado), ajustes emjinglePlayer.unit.spec.tsx/jingleHomeSection.unit.spec.tsx, reescrita dos dois e2e, fixturetests/e2e/fixtures/e2eTest.tsescripts/lib/e2e-affected-manifest.mjs(D5). - Migration: sem migration — nada em
src/collections/src/globals;push: false; semgenerate:types/generate:importmap. - Access / Consent: n/a — superfície pública read-only, sem PII nova, sem formulário, sem write path, sem chave de
Consent. - UI: Impeccable C — port classe-a-classe do design gate (
docs/plans/radio-player-proprio-ui-design.html, Cenas 01–05), traduzido para utilitários/tokens do app (as classes do protótipo.radio-player/.radio-art/.play-button/.share-button/.live-dot/.spinnernão existem no app);JINGLE_FOCUS_RINGcomo anel de foco; nenhuma estrutura visual inventada (D6 registra as combinações não desenhadas e a regra de reuso).
Dados → forma (se aplicável) — N/A
A intenção declara "Vou apresentar dados? Não": sem agregação, sem PII, sem contador/analytics/"tocando agora". A superfície é um player de áudio e um atalho de compartilhamento.
Decisões de engenharia
D1 — Contrato de exclusividade rádio↔jingle (caro: acoplamento entre componentes clientes irmãos + fronteira com /jingles)
Opções: A) broadcast em window via CustomEvent com constantes exportadas (padrão do repo: SIGNATURE_CREATED_EVENT em SignatureCounter.tsx:10,98; SPEECH_EXCERPT_REQUEST_EVENT em speechExcerptSelection.ts:15); B) contexto React; C) elevar o estado da rádio/jingles para um client wrapper da seção; D) ref global de módulo.
Recomendação: A. src/lib/radio.ts exporta RADIO_PLAY_EVENT, RADIO_PAUSE_EVENT, JINGLE_PLAY_EVENT e AudioExclusivityDetail. A rádio dispara radio:play no clique do play (antes de conectar — o jingle para no gesto, garantindo "um áudio por vez" mesmo se o stream falhar) e radio:pause em toda saída de playing|connecting (toggle, jingle, erro); escuta jingle:play e, se estiver playing|connecting, pausa/cancela o timeout e volta a idle. JingleCards dispara jingle:play com { source: 'jingle', id } ao iniciar um card e escuta radio:play num efeito com activeIdRef (pausa apenas o áudio ativo do próprio componente e limpa o activeId); sem estado global, sem storage, sem subscription cruzada. Em /jingles o listener nunca recebe radio:play (não há rádio na página) e o jingle:play emitido não tem consumidor: zero mudança de comportamento. O detail é ignorado pelos listeners (mínimo), mas o id fica assertável nos testes.
Rejeitadas: B porque JingleHomeSection é server e os dois players são clientes irmãos — um provider exigiria um wrapper client novo por cima da seção (toca o kill switch) e moveria a barreira de hidratação; C pela mesma razão mais o custo de trocar o dono do estado de JingleCards (afeta /jingles); D por colidir entre bundles/SSR (estado compartilhado por request no servidor) e não ter precedente no repo.
D2 — Onde vive o player (caro: fronteira de módulo + delete)
Opções: A) src/components/jingles/RadioPlayer.tsx novo + src/lib/radio.ts puro, deletando RadioEmbed.tsx; B) editar RadioEmbed.tsx in-place; C) mover para src/components/radio/.
Recomendação: A — dono único do concern "rádio" (como JinglePlayer é o da página de jingles, sem colisão de nome), contrato puro testável fora do React, JingleHomeSection troca uma linha. RadioEmbed.tsx é deletado (não é rename: o iframe era o arquivo inteiro) e tests/unit/radioEmbed.unit.spec.tsx é substituído por radioPlayer.unit.spec.tsx — sem referência órfã para o knip. focusRing.ts continua o dono do anel.
Rejeitadas: B porque perpetua um nome que descreve o que saiu e manteria dois contratos no mesmo arquivo (iframe vs. player), sem uma linha aproveitável; C porque duplica a topologia e o manifest (src/components/jingles já mapeia frontendJingles) sem ganho.
D3 — Estados e detecção de erro (caro-ish: aceite "nunca spinner infinito")
Opções: A) idle|connecting|playing|error com play() promise + evento error + timeout de conexão nomeado; B) só promise/error, sem timeout; C) waiting/stalled como sinal de erro.
Recomendação: A. play() resolve → playing (junto do evento playing, que é o sinal audível); rejeição do play() ou evento error → error; RADIO_CONNECT_TIMEOUT_MS = 12_000 armado por efeito enquanto status === 'connecting' e limpo em qualquer saída (pausa do usuário, playing, erro, unmount) — se estourar, pause() + error. Um token de tentativa (attemptRef) invalida promises superseded (pausar durante connecting ou pedir retry não pode deixar o estado antigo vencer) — mesma preocupação que JingleCards.tsx:136-139 resolve com o check de id. Retry a partir de error chama audio.load() e play() de novo: como o stream responde 302 com token por request, o retry precisa de uma requisição nova (não dá para reaproveitar a URL/tentativa falha).
Rejeitadas: B porque o modo de falha real é a conexão que "aceita" e nunca entrega áudio — sem timeout o spinner fica infinito, violando o aceite; C porque waiting/stalled disparam em re-buffer normal de stream ao vivo e acenderiam o erro em reprodução saudável. ended também é ignorado neste player (rádio é ao vivo; diferente do jingle finito, que usa onEnded).
D4 — Compartilhar (barato: reuso)
Opções: A) popover próprio dentro do RadioPlayer (privado RadioShareControl) reusando Popover, useCopyFeedback e buildWhatsAppTextShareUrl, com o literal de radio.ts; B) reusar ContentShareButton inteiro; C) reusar SpeechExcerptShare como está.
Recomendação: A. buildRadioShareWhatsAppUrl() = buildWhatsAppTextShareUrl(RADIO_SHARE_MESSAGE) (abre no WhatsApp do próprio remetente, sem destinatário, target="_blank" rel="noopener noreferrer"). Conteúdo do popover = Cena 05: título "Compartilhar a rádio", caixa com a mensagem literal, botão WhatsApp (primário) e "Copiar link" com o feedback de useCopyFeedback (+ live region sr-only), alinhado align="end". Copiar copia a mensagem literal (com o link dentro), mantendo o rótulo do gate via copyFeedbackLabels — mesmo precedente do SpeechExcerptShare (copia a mensagem, rotula "Copiar link"); trigger com JINGLE_FOCUS_RING em duas variantes do design: outline (Cena 01, ações inline) e link (Cena 02, linha inferior). Sem Sheet de ponteiro coarse: o gate desenha popover e o precedente ContentShareButton usa popover no mobile (Radix portaliza, não é clipado).
Rejeitadas: B porque o trigger circular absoluto/labels fixos/posição de canto não servem ao layout do player e o kind de contentShare.ts (prefixos pinados em tests/unit/contentShare.unit.spec.ts:27-32) não modela mensagem literal — criar um kind novo mudaria um contrato pinado por nada; C porque as props/shape de fala (videoId/offsetSeconds/startSeconds/...) e a mensagem derivada de speechShare.ts não têm tradução para a rádio — adaptar seria reescrever o componente, e o gate pede outro conteúdo (título, caixa do literal, dois botões).
Alternativa avaliada (copiar só a URL): rejeitada — o literal é o que circula e é o texto exibido na caixa do gate; a paridade com o WhatsApp é o ponto.
D5 — Testes (caro: contrato pinado em unit + e2e + fixture + manifest)
Opções: A) reescrever os contratos (deletar o spec do iframe, novos specs da lib/player, adaptar os compartilhados, reescrever os dois e2e, trocar o stub do fixture e mapear src/lib/radio); B) manter o spec do iframe como histórico e só adicionar novos; C) não tocar a fixture (deixar o stub do player, não stubbear o stream).
Recomendação: A, com o contrato novo exatamente assim:
tests/unit/radioEmbed.unit.spec.tsx→ deletado.tests/unit/radio.unit.spec.ts(novo): literais exatos (RADIO_STREAM_URL,RADIO_PAGE_URL,RADIO_TITLE,RADIO_SHARE_MESSAGE,RADIO_CONNECT_TIMEOUT_MS),buildRadioShareWhatsAppUrl()conferido contrabuildWhatsAppTextShareUrl(oráculo) e nomes das três constantes de evento.tests/unit/radioPlayer.unit.spec.tsx(novo; mock deHTMLMediaElement.prototype.play/pause/loadcomojinglePlayer.unit.spec.tsx:38-53+vi.mock('next/image')comojingleHomeSection.unit.spec.tsx:12-17): idle (audio[preload="none"],srcdo stream, título, "Ao vivo", rótulos acessíveis de play/share); connecting→playing (eventoplayinge resolução da promise); toggle pausa +radio:pause; erro por rejeição doplay(); erro por timeout com fake timers (avançar 12s sem resolver → "A rádio não conectou" + "Tentar novamente" + link "Ouvir no Zeno" comRADIO_PAGE_URL+ compartilhar); retry chamaload()+play()e volta a connecting;jingle:playpausa a rádio;aria-pressed/aria-busy/aria-labelpor estado;data-state.tests/unit/jinglePlayer.unit.spec.tsx(adaptar): novos casos "emitejingle:playcom o id ao tocar" e "radio:playpausa o card ativo"; e "sem card ativo,radio:playnão chamapause".tests/unit/jingleHomeSection.unit.spec.tsx(adaptar): onde hoje lê[data-radio] iframepassa a exigir[data-radio]comaudio[preload="none"]e nenhumiframe;max-w-[880px]só no caso 0 jingles; asserções fail-closed ("Jingles oficiais"/"Ver todos"/cards) inalteradas.tests/e2e/fixtures/e2eTest.ts: removerhttps://zeno.fm/player/**(o iframe morreu — request sobrante passa a falhar o guard, virando rede de regressão) e adicionarhttps://stream.zeno.fm/**comroute.fulfill({ path: tests/fixtures/jingle-tone.mp3, contentType: 'audio/mpeg' })(o stream só é requisitado depois do gesto; o stub mantém a suíte hermética e o eventorequestcontinua disparando para a asserção "só depois do play"; o MP3 curto já existe e é usado pelo upload de jingles); ignorarendedno player (D3) mantém o estado "Em reprodução" estável no stub, como num stream ao vivo.tests/e2e/frontend.e2e.spec.ts:560-616(reescrever; manter ordemstory < sound < cardse overflow 390):[data-radio]visível;iframecount 0;audiocount 1 compreload="none"esrc === RADIO_STREAM_URL; "Rádio Jorge Solla 1313" e "Ao vivo" visíveis;zenoRequests(matcherzeno.fm) 0 no load; play pelo nome acessível (Ouvir Rádio Jorge Solla 1313) → pollzenoRequests.length > 0,aria-pressed="true"e "Em reprodução"; a 390 sem overflow; o pin de altura 168/150px sai (não há frame reservado) e entra o tamanho do play (56px desktop / 48px no 390).tests/e2e/frontendJingles.e2e.spec.ts:202-207(kill switch):[data-radio]visível,iframe0,audio1 (preload="none"), 0 cards, sem "Ver todos" e heading "Sintonize com a Rádio 1313".tests/e2e/frontendJingles.e2e.spec.ts:248-291(com jingles): 3 cards,iframe0,zenoRequests === 0eaudioRequests === 0antes de gesto; play na rádio → stream request > 0 + "Em reprodução"; play no 1º card → rádioaria-pressed="false"/"Pronta para tocar" e carddata-state="playing"; play na rádio de novo → carddata-state="stopped"(exclusividade nos dois sentidos); overflow 390.scripts/lib/e2e-affected-manifest.mjs:99-108: adicionarsrc/lib/radioao entry do S21 (junto desrc/lib/jingle), para o diff da lib acordarfrontendJingles; o CSS comentado já acordafrontendviasrc/app/(frontend).- Greps de fechamento:
RadioEmbed,zeno.fm/player,iframeemsrc/components/jinglesesrc/lib= 0; menções adata-radio-state= 0. Rejeitadas: B porque spec morto de iframe vira ruído e o gate rodaria um contrato inexistente; C porque o stub do player sem consumidor é peso morto e, sem stub do stream, o clique de play no e2e bateria na rede real/CI (guard de console externo quebra) ou ficaria flaky.
D6 — Composição responsiva do port (barato-ish: fidelidade ao gate sem inventar) (decisão extra porque o gate não desenha todas as combinações)
Opções: A) compact = Cena 02 em qualquer largura (cartão centralizado em max-w-[880px]); full = Cena 01 no md+ e o arranjo de linha da Cena 02 no mobile; B) compact = só geometria do S24 e layout por viewport; C) desenhar uma terceira composição para desktop compacto.
Recomendação: A — é a leitura que usa só as duas composições aprovadas: a Cena 02 é rotulada "0 jingles" (o estado compacto) e o mobile do full herda o arranjo mobile da Cena 02. Mapeamento bloco a bloco:
- Cena 01 (full, md+):
articleoverflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_10px_28px_rgb(71_19_14/7%)] mt-9 p-3 md:p-4; gridmd:grid md:grid-cols-[112px_1fr_auto] md:items-center md:gap-5;radio-artsize-[76px] md:size-28com disco =<span aria-hidden>absolute inset-[13%] rounded-full border border-black/8 bg-white shadow-[0_7px_18px_rgb(71_19_14/10%)]+<Image src="/campaign-kit/radio/radio-jorge-solla-1313-logo.png" ... className="relative z-[1] aspect-square w-[74%] rounded-full object-cover" />(logo oficial da rádio, C208 — corte circular como o zeno exibe); badge "Ao vivo"text-[10px] md:rounded-full md:bg-(--pt-red)/8 md:px-3 md:py-1 md:text-[11px]com live-dotsize-2 ... shadow-[0_0_0_4px_rgb(162_28_28/11%)]; "Rádio online"hidden text-xs text-(--campaign-muted) md:inline; títulofont-[family-name:var(--font-exo2)] text-lg leading-tight font-black tracking-[-0.025em] md:text-[28px] md:leading-none; statustext-sm(muted idle /font-bold text-(--pt-red)com dot em reprodução; spinner em connecting); açõesflex items-center gap-4 pr-2com shareoutline+ playsize-12 md:size-14(bg-(--pt-yellow),text-(--pt-yellow-ink),shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)], ícone 21/25px,motion-reducecomoJingleCards). - Cena 02 (compact):
mx-auto mt-8 max-w-[880px] p-3; linhaflex items-center gap-3(art 76, títulotext-lg, playsize-12com pause earia-pressed); statustext-xs; sharelinkna linha inferiormt-3 flex justify-end border-t border-(--campaign-line) pt-2. Nofullmobile valem os mesmos valores mobile (o share outline inline só aparece emmd+). - Cena 03 (connecting): mesmo footprint; spinner
size-5 rounded-full border-2 border-(--pt-red)/18 border-t-(--pt-red) animate-spin [animation-duration:0.8s] motion-reduce:animate-none+ "Conectando à rádio…" (role="status"); playopacity-60aria-busy="true"aria-label="Conectando". - Cena 04 (error): branch próprio sem arte/badge — ícone
grid size-10 place-items-center rounded-full bg-(--pt-red)/10 text-(--pt-red)(lucideInfoIcon), título "A rádio não conectou", copy "Tente de novo ou ouça na página da rádio.", açõesflex flex-wrap items-center gap-2: "Tentar novamente" (bg-(--pt-red) text-white min-h-11 rounded-lg), "Ouvir no Zeno" (link sublinhadoRADIO_PAGE_URL) e shareoutline ml-auto. - Cena 05 (popover): descrito em D4. Rejeitadas: B porque a Cena 02 difere estruturalmente (flex, tamanhos, share na linha inferior) — tratá-la como pura geometria não portaria o gate; C porque inventa composição que o designer não aprovou.
Fases verificáveis
- Tracer — lib + player + troca (≈3–4 h): criar
src/lib/radio.ts; criarRadioPlayer.tsxcom estados, áudiopreload="none", timeout (RADIO_CONNECT_TIMEOUT_MS), dispatch/listener de eventos e o port das Cenas 01–05 (D6); trocar o filho emJingleHomeSection.tsx; deletarRadioEmbed.tsxetests/unit/radioEmbed.unit.spec.tsx; escrevertests/unit/radio.unit.spec.tsetests/unit/radioPlayer.unit.spec.tsx; adaptartests/unit/jingleHomeSection.unit.spec.tsx; reescrever o comentário destyles.css. Checkpoint:pnpm test:unit tests/unit/radio.unit.spec.ts tests/unit/radioPlayer.unit.spec.tsx tests/unit/jingleHomeSection.unit.spec.tsx tests/unit/campaignHome.unit.spec.tsxverde +pnpm typecheck; conferência visual no dev server do worktree (1280 e 390) contra as cenas. - Coordenação rádio↔jingle (≈1–2 h): listener/dispatch em
JingleCards.tsx(D1) e os dois casos novos emtests/unit/jinglePlayer.unit.spec.tsx. Checkpoint:pnpm test:unit tests/unit/jinglePlayer.unit.spec.tsx tests/unit/radioPlayer.unit.spec.tsxverde; conferir no dev server que tocar um jingle para a rádio e vice-versa (e que/jinglessegue igual). - Testes e2e + gates (≈2–3 h): trocar o stub no fixture (D5), reescrever os dois e2e, adicionar
src/lib/radioao manifest, greps de fechamento; rodarpnpm test:e2e -- frontend frontendJingles(oupnpm test:e2e:affected, que passa a selecionar os dois specs porsrc/components/jingles/src/app/(frontend)/src/lib/radio). Fechamento:pnpm gate:fast; entradadocs/changelog/2026-09-21-s25.md(uma entrada curta, registrando a remoção do embed S24 e o ganho de zero terceiros antes do gesto); push viapnpm push(a CI roda o gate:ci; o PR arma auto-merge só comchecksverde).
Rabbit holes / Não escopo (engenharia)
- Refatorar
ContentShareButton/SpeechExcerptShare/copyFeedback/contentSharepara "unificar": o dono do compartilhamento da rádio é oRadioPlayer(viaradio.ts+buildWhatsAppTextShareUrl); os existentes ficam como estão, okinddecontentShare.tsnão ganha valor novo e as labels pinadas seguem intactas. - Proxy/stream próprio, CDN, cache de áudio, troca de provedor: o
<audio>aponta direto parastream.zeno.fm; nada de infra de streaming. - Web Audio/
AnalyserNode/visualizador: o stream não manda CORS — impossível, fora de escopo. - "Tocando agora", API do Zeno, polling de metadados, duração/timeline, barra de progresso, volume: rádio ao vivo não promete programação nem duração; nome fixo + "Ao vivo".
- Analytics de reprodução/contador de ouvintes, GA, pixel novo: nenhum dado nosso é coletado; MetaPixel da home intocado.
- Consent/CSP/banner de terceiro: nenhum request de terceiro antes do gesto; nada a consentir.
- Redesenhar a seção de som, header/copy, grade ou
/jingles; tocarJingleIntro/JinglePageHeader/JingleEmptyState/JinglePlayer; mexer em(home)/page.tsx,getPublishedJingleItemsou na tagjingles. - Abstrair estilos compartilhados (módulo de "play control", wrapper de share genérico): <3 call sites;
RadioPlayerusa constantes locais +JINGLE_FOCUS_RING, comoJingleCardsfaz comPLAY_CONTROL/DOWNLOAD_CONTROL. - Reintroduzir iframe, fallback embed ou link "abrir o player do Zeno" fora do estado de erro (o link "Ouvir no Zeno" existe só no erro, decisão de produto).
- Feature flag/A-B embed↔player, rota nova, query param: produto decidiu; não fica caminho latente.
- Tocar no manifest além de adicionar
src/lib/radioao entry do S21; novos specs e2e, novos specs unit além dos listados, ou teste int (não há access/write path). - Renomear
JinglePlayer/mover parasrc/components/radio/: churn de topologia e de manifest sem ganho.
Riscos e mitigação
- Interceptação do áudio no e2e:
route.fulfillde mídia no Chromium do Playwright é o ponto frágil do stub. Primário:stream.zeno.fm/**→tests/fixtures/jingle-tone.mp3(audio/mpeg); se a versão pinada não interceptar mídia, o fallback documentado é reduzir a asserção e2e a "connecting + request > 0 após o gesto" (oplaying/erro/retry já estão cobertos no unit com mocks) — nunca deixar o spec flaky. - Timeout falso positivo:
RADIO_CONNECT_TIMEOUT_MS = 12_000é folgado para 302 + CDN (normal < 2 s); estouro vira erro fail-soft com "Tentar novamente", não beco sem saída. A constante exportada dá o dial sem mexer no componente. - Promise superseded: token de tentativa (
attemptRef) impede que umplay()/timeout antigo sobrescreva o estado após pausa/retry — mesmo cuidado queJingleCards.tsx:136-139; coberto por unit. - Dois triggers de share no layout responsivo: só um fica visível por breakpoint (
hidden=display:none, fora da árvore de acessibilidade); o estado do popover é local a cada instância — sem conflito. Teste de teclado no unit (Tab alcança um trigger por viewport lógico). - Safari/iOS
play(): rejeição (gesto, codec ou política) cai no estado de erro com alternativas; sem autoplay, semautoPlay, sem som antes do clique. - 302 com token por request: o retry sempre passa por
audio.load()+play()(requisição nova); nada de reaproveitar tentativa falha nem cachear URL assinada. endedignorado: a rádio é ao vivo e não termina; o stub curto do e2e depende disso para manter "Em reprodução" estável. Se um dia o comportamento importar, é um evento defensivo novo — débito consciente, documentado no comentário do player.- Stub antigo removido como rede de regressão: qualquer request residual a
zeno.fm/player/**deixa de ser stubbado e derruba o guard de console/erro externo do fixture — prova ativa de que o iframe morreu. JingleCardscompartilhado com/jingles: listener sempre ativo, mas pausa apenas o áudio ativo do próprio componente e não cria estado global; unit cobre "sem card ativo,radio:playnão chamapause" e o e2e de/jingles/kill switch segue afirmando a página intocada.- Regressão de a11y/estado:
data-state,aria-pressed,aria-busy,aria-labelerole="status"pinados emradioPlayer.unit.spec.tsx;JINGLE_FOCUS_RINGem todos os controles (play, share, retry, link). - Arte resolvida (NEEDS ASSET fechado pelo C208): a logo oficial da rádio entrou em
public/campaign-kit/radio/radio-jorge-solla-1313-logo.png(C208, 2026-09-21); o player a exibe em corte circular (o contrato do zeno), no mesmo quadrado do gate — troca de uma única<Image src>/alt, sem tocar em layout. A estrela do kit segue sendo o ativo da campanha, não da rádio. - CSS/comentário órfão: a varredura de fechamento (
RadioEmbed|zeno.fm/player|iframeemsrc/= 0) mais o comentário destyles.css:1158-1161reescrito evitam documentação mentirosa; knip/ciclos cobrem o delete no gate. - Cache/ISR: inalterado; os specs navegam
/?e2e=<ts>como hoje e a home continua lendo uma única vezgetPublishedJingleItems().
Aceite de engenharia
- Aceite de produto da intenção ainda coberto — player próprio único (e2e:
[data-radio]+audio preload="none",iframe0; unit:src/título/rótulos), zero request antes do gesto (e2e:zenoRequests === 0no load e > 0 após o play), exclusividade rádio↔jingle nos dois sentidos (unitjinglePlayer/radioPlayer+ e2efrontendJingles), mensagem literal no WhatsApp e ao copiar (unitradio+radioPlayer), fail-soft com "Tentar novamente"/"Ouvir no Zeno" e timeout de 12 s (unit com fake timers), kill switch/compact preservados (unitjingleHomeSection+ e2e kill switch), 390 sem overflow (e2e nos dois specs),/jinglese MetaPixel intocados (nenhum arquivo seu tocado). - Invariantes AGENTS/engineering-standards — home estática via
getPublishedJingleItems()/tagjinglessem leitura ou cache novo; sem PII/Consent/Consentnovo; sem schema/migration (push: falseintocado); sem mudança de URL pública (o link do Zeno é conteúdo, não rota); dono editado sem twin (RadioEmbeddeletado,RadioPlayerúnico) e sem abstração <3 call sites; identificadores em inglês, copy pt-BR preservada;preload="none"mantém a postura de não carregar terceiros antes do gesto. - Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum access/write path; a entrega é apresentação). Cobertura prevista: unit
radio/radioPlayer/jinglePlayer/jingleHomeSection(reescritos/novos,radioEmbeddeletado) e e2efrontend/frontendJinglesreescritos com o stub de stream no fixture esrc/lib/radiono manifest;campaignHome.unit.spec.tsxsegue como está;pnpm gate:fast+pnpm test:e2e -- frontend frontendJinglessão o gate da entrega.
Self-score decision-quality (0–5, gate ≥4)
4/5. As seis decisões (exclusividade, fronteira de módulo, estados/timeout, compartilhar, testes, port responsivo) têm opções, recomendação com critério verificável e rejeitadas nomeadas; os rabbit holes estão cortados com o dono certo e o depth check reusa buildWhatsAppTextShareUrl, useCopyFeedback, Popover, JINGLE_FOCUS_RING, tokens e o precedente de CustomEvent. Perde 1 ponto por duas incertezas reais: (a) o gate não desenha as combinações "full no mobile" e "compact no desktop" — D6 resolve por reuso explícito das duas composições aprovadas, mas é inferência, não desenho; (b) o stub de áudio do e2e depende da interceptação de mídia do Chromium do Playwright, mitigada com fallback documentado, porém não eliminada. Nenhuma das duas muda o outcome de produto.
Simplify + débitos (triage)
- Já resolvido no simplify (não reabrir):
RADIO_PAUSE_EVENT(broadcast sem consumidor) eAudioExclusivityDetail(detail nunca lido) removidos; lógica de parada do card extraída emstopActiveCarde reusada pelo listener deradio:playe pelotoggle;endedignorado documentado no comentário do<audio>(rádio ao vivo); unit do branchonErrore da variantecompactadicionados; asserção espelhada doradio.unit.spec.tsremovida. - Explicitamente fora (defer com gatilho): unificar os SVGs de play/pause do
RadioPlayercom os doJingleCards— DRY prematuro (2 call sites) e port literal do design gate; revisitar quando surgir a 3ª superfície com controle de play (ou se o projeto adotar os ícones lucide).