26 KiB
Impl: Jingles de Jorge Solla no site público — ouvir e baixar (S21)
Status: aprovado Atualizado em: 2026-09-20 Issue: #1222 Intenção: docs/plans/jingles-site-publico.md Appetite restante: herdado (~1–1,5 dia eng) — sem infra nova, sem dependência de serviço externo; cortes nomeados em "Rabbit holes"
Leitura da intenção
- Outcome: uma página pública
/jingleslista os jingles publicados com capa, título e player que toca ali mesmo (um por vez, sem abrir terceiros) e cada jingle baixa direto o MP3 com nome de arquivo legível — gerenciável no CMS, com kill switch por item e por página, leve no celular e indexável com título/descrição próprios. - O que NÃO negociar:
- Kill switch fail-closed: cada jingle sai do ar por despublicação (checkbox), a página inteira fica vazia/honesta com zero publicados, o link do rodapé some e os arquivos nunca são apagados. Despublicado não aparece em lugar nenhum.
- Sem PII/LGPD: nenhum formulário, consent, Contact ou captura. Consent intocado.
- Um jingle por vez; sem autoplay; abrir a página não baixa os áudios (áudio só carrega após o play).
- Um MP3 por jingle serve para tocar e baixar; WAV não vai ao site; nenhum binário grande commitado — áudio/capa entram pela mídia existente (que não ganha collection paralela).
- Home e navegação principal intocadas; a descoberta é um link condicional no rodapé. Nada de loja/streaming, analytics, share kit, discografia ou páginas por jingle.
- O que reavaliar (hipóteses da "Direção no codebase"):
- "mídia existente" — confirmado, mas com o refinamento de depth 0 +
getCachedDocumentById(senão trocar o arquivo no admin deixa o card velho; ver D2). - "Precedente
/corte/[id](player)" — o corte usa<video controls>nativo; o artefato dos jingles pede controle próprio (play amarelo, progresso, status), então não há player para reusar — nasce um client component novo, pequeno. - "rodapé" — o
CampaignFooteré compartilhado com home/cards; esta entrega o edita (link condicional, propshowJingles; ele segue sync, ver D5). É a única mudança que aparece fora de/jingles— deliberada e mínima. - "e2e afetado" — a migration classifica todo PR da entrega como high-risk ⇒ CI roda só o conjunto curado; a escolha de entrar no curado é decisão registrada (D7).
- "mídia existente" — confirmado, mas com o refinamento de depth 0 +
Abordagem recomendada
flowchart LR
admin[Admin /admin · Jingle] -->|afterChange / afterDelete| bust[revalidateTag 'jingles']
page[/jingles RSC · campaign-site] --> list[getCachedPublishedJingles<br/>where published · depth 0 · tag 'jingles']
list --> docs[getCachedDocumentById media<br/>tag 'document_media:id']
docs --> player[JinglePlayer client<br/>preload none · um por vez]
list --> empty[JingleEmptyState<br/>200 honesto · noindex]
footer[CampaignFooter sync] -->|showJingles prop| has[hasPublishedJingles]
has --> list
Opções consideradas: A) collection jingle nova com checkbox published (padrão ShareLink) | B) reusar post/shareLink existentes | C) drafts/versions via _status.
Recomendação: A — uma collection própria com campos exatos (título, slug, capa, áudio, ordem, publicado), grupo Publicações, leitura anônima fail-closed por where published: true, escrita canManagePublishedContent e hooks que revalidam a tag jingles. É o precedente mais próximo (S19), sem carregar richText/taxonomia/drafts de Post.
Rejeitadas: B — post traz corpo richText, type/categoria, drafts e o controle eleitoral por tag hidden, acoplando o jingle a uma taxonomia de notícias e exigindo migration de enum + semântica de visibilidade compartilhada; shareLink é OG/destino externo, sem upload próprio de áudio. C — _status/versions adiciona cerimônia de drafts/preview/agendamento sem uso; o kill switch por published é o contrato estabelecido e mantém o mesmo access fail-closed.
Decisões de engenharia
D1. Modelo — collection jingle (nova) com published checkbox e order opcional.
- Opções: A) collection nova (ShareLink) | B) reusar Post/ShareLink | C)
_statusdrafts/versions. - Recomendação: A — campos mínimos (
title,slugúnico auto-slugificado,coverImageupload→media required,audioupload→media required,ordernumber opcional,publisheddefaultfalse);admin.group: 'Publicações';read: hasPayloadPanelAccess(req.user) ? true : { published: { equals: true } }; create/update/deletecanManagePublishedContent;afterChange/afterDelete→revalidateJinglesListing(). Ordenação['order', 'title'](semordervai ao fim —NULLS LASTno Postgres; divergência barata doorderrequired deSpeechSegment). - Rejeitadas: B (ver acima); C (sem ganho, mais superfície de query/estado).
D2. Leitura/cache — listagem unstable_cache depth 0 + mídia por getCachedDocumentById.
- Opções: A) listing depth 0 (tag
jingles) +getCachedDocumentById('media', id)(tagdocument_media:<id>) | B) listing depth 1 | C) página dinâmica. - Recomendação: A — trocar o arquivo de uma mídia no admin busta
document_media:<id>(Media.afterChange), então capa/áudio ficam frescos mesmo sem editar o jingle. O listing guarda só ids. - Rejeitadas: B — um arquivo trocado numa mídia existente não dispara hook do jingle e o card serviria filename/URL velhos até alguém editar o jingle; C — mata ISR/indexação e adiciona DB por request sem necessidade.
D3. Nome do download — helper puro derivado do slug.
- Opções: A)
jingleDownloadFilename(slug, sourceFilename)→jorge-solla-1313-<slug>.<ext>(ext do filename da mídia em lowercase, fallbackmp3; slug normalizado comslugifye fallbackjingle) | B)media.filenamecru | C) nome fixo. - Recomendação: A — o aceite pede nome legível; o design mostra
jorge-solla-1313-axe.mp3(o slug do Axé já éaxe); extensão real mantém honestidade. - Rejeitadas: B — filename de upload pode ter acento/espaço/maiúscula; C — todos baixariam o mesmo nome.
D4. Estado vazio — 200 honesto + noindex só quando vazio.
- Opções: A) 200 com estado honesto + "Voltar ao início" + rodapé sem "Jingles";
robots: noindex,nofollowquando vazio | B) 404 (padrão/corte) | C) 200 sempre indexável. - Recomendação: A (intenção: "acesso direto mostra estado honesto", "não é descoberta"). Só sobrescrever
robotsquando vazio — com conteúdo, omitirrobotspara oindex,follow/noindexde staging do layout raiz valer. - Rejeitadas: B — o acesso direto é legítimo (diferente do link não listado de um corte); C — anunciar "nenhum jingle publicado" em busca é pior que não aparecer.
D5. Rodapé — leitura cacheada na página + prop explícita no rodapé (sync).
- Opções: A)
CampaignFootervira async e lêhasPublishedJingles()| B) o rodapé segue sync recebendoshowJinglesde cada página, comhasPublishedJingles()(cacheado, tagjingles) resolvido na página | C) footer client comusePathname. - Recomendação: B — a execução mostrou que o async child quebra o teste-skeleton da home (
tests/unit/campaignHome.unit.spec.tsx, RTL não renderiza async components) e faria o rodapé — folha compartilhada e estática — depender do runtime do servidor. Com B o rodapé continua um leaf síncrono e a decisão de descoberta fica explícita na página, sempre pelo mesmo dono cacheado (hasPublishedJingles); os call sites são 3 linhas (home, cards, jingles). - Rejeitadas: A — verificada em execução: erro
CampaignFooter is an async Client Componentno teste da home e acoplamento desnecessário; C — transformaria o rodapé em client component só para saber a rota (JS desnecessário) e não resolve a leitura.
D6. Media.alt required para áudio — aceitar e documentar.
- Opções: A) comunicação preenche o alt também no áudio; campo do Jingle descreve isso | B) tocar em
Media(relaxar required por tipo). - Recomendação: A — fricção de 1 campo por jingle (3 hoje); o alt ainda ajuda no admin.
- Rejeitadas: B — o alt é contrato compartilhado de todas as mídias; exigiria lógica por MIME que não existe e mexe no owner sem evidência de produto.
D7. E2E — spec novo no conjunto curado + pin (precedentes C167/S19).
- Opções: A) criar
tests/e2e/frontendJingles.e2e.spec.tse adicionarfrontendJinglesaE2E_CURATED_SPECS+ pin eme2eAffectedManifest.unit.spec.ts| B) não adicionar. - Recomendação: A — a migration torna todo PR desta entrega high-risk (CI roda só o curado); sem a entrada, o spec que nasce neste PR não roda nele (ficaria só no verify full pós-merge) e PRs high-risk futuros tocando jingles não o acordariam.
- Rejeitadas: B — contraria os dois últimos deliveries com migration e deixa o contrato novo sem cobertura no caminho que importa.
D8. Leveza — preload="none" + controle próprio (não nativo, não metadata).
- Opções: A)
<audio preload="none">oculto por card + controle do artefato | B)preload="metadata"| C)<audio controls>nativo. - Recomendação: A — aceite literal ("abrir a página não baixa os áudios"); B dispara request de mídia no load; C não reproduz o card do artefato.
- Rejeitadas: B e C (acima).
D9. Exclusividade — um único client component JinglePlayer dono do estado.
- Opções: A) um componente client para a lista (estado
activeId+ refs; ao tocar, pausa os demais) | B) card client + contexto | C)<audio>global com source trocada. - Recomendação: A — 3 itens, sem contexto, estado onde é usado (engineering-standards).
- Rejeitadas: B (cerimônia por <3 call sites); C (complica progresso/duração por card e o seek).
D10. Rota e slug — estático /jingles + reserva no registro existente.
- Opções: A)
src/app/(frontend)/jingles/+'jingles'emSHARE_LINK_RESERVED_SLUGS(dono atual da reserva; o drift test é fail-closed) | B) lista de reservados paralela. - Recomendação: A — o Next dá precedência ao segmento estático sobre
[type]; a reserva impede que um shareLink capture/jingles. O slug do jingle não é URL (não valida reservados; só alimenta o nome do arquivo, sanitizado no helper). - Rejeitadas: B — twina um registro que o drift test já cobre.
Componentes / mudanças
src/collections/Jingle.ts(novo): collection acima (D1).admin.descriptionexplica o fluxo ("Suba o MP3 e a capa pela Mídia; desmarcar Publicado tira o jingle do ar sem apagar nada").src/payload.config.ts: registrarJingleno arraycollections(mesmo PR da migration).- Migration:
pnpm migrate:create add_jingle(rebase antes) →src/migrations/<ts>_add_jingle.{ts,json}+index.ts;pnpm migratelocal;pnpm generate:types(payload-types.ts). Tabela aditiva, sem backfill; FKs de mídiaON DELETE set null(mídia apagada ⇒ item some, fail-closed). src/lib/jingle.ts(novo, puro/client-safe):JingleViewModel(id,title,coverUrl,coverAlt,audioUrl,downloadFilename),toJingleViewModel({ jingle, cover, audio })→nullquando faltar URL (fallback de alt quando a mídia não tiver),jingleDownloadFilename(D3). Sem@/payload-types(precedentelib/recording.ts, formas estruturais).src/utilities/jingleReads.ts(novo;server-only; registrar no pin de top-level emcodebaseConventions.unit.spec.tscom comentário):getCachedPublishedJingles()(unstable_cache,where { published: true },sort: ['order','title'],depth 0,limit 0, taggetCollectionListingTag('jingle')=jingles, filtro explícito na query como no S19);getPublishedJingleItems()(resolve capa/áudio viagetCachedDocumentById('media', id)e mapeia comtoJingleViewModel, descartando itens sem mídia);hasPublishedJingles()(rodapé).src/utilities/documents.ts:revalidateJinglesListing = () => revalidateCollectionListing('jingle')(dono do vocabulário de tags; sem import de config).src/lib/shareLink.ts: adicionar'jingles'aSHARE_LINK_RESERVED_SLUGS+ atualizar o literal emtests/unit/shareLink.unit.spec.ts(D10).src/app/(frontend)/jingles/layout.tsx:data-theme="campaign-site"+h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)(precedente(home)/layout.tsx; o root travaoverflow-hidden).src/app/(frontend)/jingles/page.tsx: RSC estático/ISR;generateMetadata(title "Jingles de Jorge Solla · ", description própria, canonical viaabsoluteSitePath, OG com a capa do primeiro jingle absoluta e fallback do globalmetadata,robotssó no vazio — e o arquivo chamaresolveSiteMetadata, exigido pelo guard de convenções); compõe header + hero + lista/JingleEmptyState+CampaignFooter current="jingles".src/components/jingles/JinglePageHeader.tsxeJingleIntro.tsx(server, page-local): header cream compublic/campaign-kit/marca-positiva-completa.png(vianext/image) + selo "Jingles oficiais"; hero com blobs, eyebrow "A trilha da nossa caminhada", h1, copy e o cartão "Um por vez. Ao tocar outro, o anterior para." (SVG de nota). Não é oSiteHeader(vermelho/breadcrumb) e não se generaliza agora — gatilho de revisitação: uma segunda página campaign-site precisar do mesmo header.src/components/jingles/JinglePlayer.tsx('use client'): recebereadonly JingleViewModel[]; port do artefato classe-a-classe em Tailwind (data-state,.jingle-card→utilities, capa quadrada com gradiente, play 56px amarelo com sombra 3D, status "Pronto para tocar"/"Em reprodução" com bolinha, trilha 6px + valor vermelho, "Tempo/Duração" uppercase viaformatSpeechClock— reuso do dono do relógio —, "Baixar MP3"min-h-11com borda vermelha 32%, filename truncado). Um<audio preload="none">por card; ao tocar um, pausa os outros;play().catchreverte o estado (nunca deixaaria-pressedpreso); botão comaria-labelTocar/Pausar +aria-pressed; ícones SVG inline do artefato (play/pause/download);focus-visibleamarelo;motion-reducedesliga transições.src/components/jingles/JingleEmptyState.tsx(server): nota cortada, copy "Nenhum jingle publicado por enquanto", "Voltar ao início" (min-h-11).src/components/CampaignFooter.tsx: segue sync; propsshowJingles?: boolean(defaultfalse) ecurrent?: 'jingles'; item "Jingles" condicional comaria-current="page"e destaque bold/branco (D5). Home e cards resolvemhasPublishedJingles()(cached) e passam a prop; a página de jingles passaitems.length > 0.- Testes:
tests/unit/jingle.unit.spec.ts(helper/mapper),tests/unit/jinglePlayer.unit.spec.tsx(RTL com mock deHTMLMediaElement, precedentespeechDetailPlayer.unit.spec.tsx: play/pausa/exclusividade/tempos/preload=none/href+download),tests/int/jingle.int.spec.ts(mock denext/cachecomo no S19),tests/e2e/frontendJingles.e2e.spec.ts, + atualizações dos pins (shareLink,codebaseConventions,e2eAffectedManifest). - Fixture e2e/int:
tests/fixtures/jingle-tone.mp3(~2–5 KB, MP3 válido gerado uma vez com ffmpeg e commitado) para o áudio; capa segue o padrão do PNG 1×1 base64 do spec do S19. Rejeitadas: base64 gigante no spec (ilegível) e geração em runtime (acopla ffmpeg ao e2e). - Manifesto/curated: entries novas em
E2E_AFFECTED_MANIFEST— jingles (src/app/(frontend)/jingles,src/lib/jingle,src/utilities/jingleReads.ts,src/utilities/documents.ts,src/collections/Jingle.ts,src/components/jingles) →frontendJingles; esrc/components/CampaignFooter.tsx→['frontend', 'frontendJingles'](o rodapé renderiza na home e em/jingles).frontendJinglesentra emE2E_CURATED_SPECScom comentário S21 + pin do array emtests/unit/e2eAffectedManifest.unit.spec.ts(D7). - Changelog:
docs/changelog/2026-09-20-s21.md(uma entrada curta, formato do agregado). - Operação (fora do código): os 3 MP3 e as capas são conteúdo — a comunicação sobe no admin (produção) após o merge; a conversão WAV→MP3 é local/ops e nada é commitado. Nenhum seed novo no repo; e2e/int se auto-semeiam e limpam no
afterAll.
Dados → forma
N/A — a intenção já registra "Vou apresentar dados? Não": é superfície de mídia (áudio + capa), sem dado agregado, sem PII e sem decisão desbloqueada por números. Nada a definir em forma de apresentação; a "forma" aqui é o card/player do artefato aprovado.
Fases verificáveis
- Tracer / schema+server (~0.4 dia) —
Jingle+ registro +pnpm migrate:create add_jingle+pnpm migrate+pnpm generate:types;revalidateJinglesListing;src/lib/jingle.ts;jingleReads.ts+ pin de top-level;'jingles'reservado + literal do unit. Testes: unit do helper/mapper; int do access fail-closed (anônimo não vê draft, não escreve;campaignUsernão escreve), filtro publicado, ordenação (orderasc, sem ordem ao fim, empate por título),revalidateTag('jingles')em create/update/delete, resolução das mídias e descarte de item sem mídia. Prova:pnpm gate:fast+pnpm test:int. - UI (~0.4 dia) — layout/page/header/hero/player/empty +
CampaignFooter; RTL do player. Verificação visual local em 390/1280 com 2–3 jingles semeados no admin local (converter WAV→MP3 só localmente; capas otimizadas do artefato como arquivos locais de teste). Sem dispatch do designer aqui: o artefato cobre a superfície e é portado classe-a-classe (triggers a/b/d não disparam; ícones vêm do artefato). - E2E + manifesto (~0.2 dia) — fixture MP3;
frontendJingles(seeding via REST admin comadminHeaders/seedTestUser, limpeza emafterAll; asserções: lista renderizada + rodapé com link +robots index,follow; nenhum request de áudio antes do play; play/pausa/exclusividade comaudio.pausedreal; download dispara comsuggestedFilename()=jorge-solla-1313-<slug>.mp3; kill switch despublica → 200 vazio honesto + rodapé sem "Jingles" +noindex, republica → volta); manifest entries + curated + pin. - Gates e fechamento (~0.2–0.3 dia) —
pnpm gate:fast→pnpm test:int→ e2e local (loop rápido:pnpm test:e2e --no-deps -- tests/e2e/frontendJingles.e2e.spec.ts --workers=1; canônico:pnpm test:e2e:affected, que em high-risk roda a suíte full local — OPS86) → crítica final do designer (trigger c) contra o app renderizado (390/1280 + estados vazio/tocando;DEGRADED⇒ para antes dopnpm push, comenta a Issue e flipablockedno modo--auto) → changelog →pnpm push→ PRCloses #1222.
Débitos do simplify (triage do Passo 6)
Achados dos dois revisores que não viraram fix da sessão:
| ID | Resumo | Origem | Score | Tipo | Destino |
|---|---|---|---|---|---|
| S1 | 3ª cópia da resolução de mídia do global metadata (corte, shareLinkReads, página) |
review estrutural | 2 | defer_trigger | gatilho: 4º consumidor ou mexer em uma das superfícies |
| S2 | Esqueleto "listing publicado + unstable_cache + tag" repetido entre posts.ts e jingles |
review estrutural | 2 | defer_trigger | gatilho: 3ª collection de listagem pública |
| S3 | src/utilities/documentReads.ts sem entry no E2E_AFFECTED_MANIFEST |
review estrutural | 2 | defer_trigger | gatilho: documentReads virar risk prefix ou mudar de contrato |
| S4 | Copy por breakpoint duplicada em spans no JingleIntro (literal do artefato) |
review qualidade | 1 | descartar | o artefato define as duas copies; não é débito |
Já resolvidos na sessão (não reabrir): corrida do play() superado (catch por id), isNotFoundError extraído para documentReads, publishedOrPanelAccess no dono do access, hasPublishedJingles derivado da lista renderizável, role="progressbar", ref callback estável, data-jingle-audio removido, hasItems, h1 no estado vazio, validação do slug, cobertura int de create/update/delete negados, comentário/async dos testes e comentário do .gitignore.
Rabbit holes / Não escopo (engenharia)
- Pré-carregar áudio (
preload="metadata"/"auto"ou<link rel=preload>): viola o aceite de leveza — D8 fixapreload="none". - Player rico (ondas, equalizador, visualizador, velocidade): o artefato fixa o controle; nada além de play/pausa/progresso/tempo.
- Upload de WAV / conversão no app / ffprobe de duração: um MP3 por jingle é decisão de produto; duração vem do browser em runtime.
- Collection paralela de mídia ou mudar
Media(mimeTypes, validação de áudio,altopcional): mídia existente, contrato compartilhado intocado (D6). - Discografia / página por jingle / letras / destaques: uma página, 3 cards. O slug do jingle não vira URL.
- CDN, Spotify/YouTube embed, "ouvir no…": hospedagem e mídia existentes.
- Analytics de play, contador de download, captura de dados/consent: LGPD N/A; não introduzir consent por engano.
- Share kit social dos jingles: item futuro se pedido.
- Redesign de home/nav ou seção de jingles na home: só o link condicional no rodapé.
- Rodapé dinâmico (
payload.findcru /force-dynamic): quebraria ISR da home — a leitura é cacheada por tag (D5). - Binários em
public/(a pasta vai para a imagem Docker) e seed automático dos 3 jingles no repo. - Colisão com
[type]/shareLink: o segmento estático novo é reservado (D10) — não criar rota[slug]paralela. - Duas fontes de verdade do relógio: não criar formatador novo;
formatSpeechClocké o dono (se a crítica de design reprovar o "02:45" vs "2:45", revisitar com gatilho).
Riscos e mitigação
- Home/cards estáticas e o rodapé: a flag do rodapé vem de leitura cacheada (tag
jingles) resolvida na página — nuncapayload.findcru, que tornaria a home dinâmica; o rodapé em si segue sync (D5, divergência registrada). A tabelajinglesexiste desde a migration (build-after-migrate); falha de DB continua falhando alto. - Link do rodapé stale após despublicar o último jingle: o mesmo
afterChangebusta a tagjinglesque alimentahasPublishedJingles; o e2e de kill switch cobre o flip (com no máximo um request stale, como S19). - Arquivo/capa trocados no admin sem editar o jingle: coberto por D2 (
document_media:<id>); teste int garante que o item usa a URL resolvida na leitura. - Mídia apagada com jingle publicado: FK
ON DELETE set null+ descarte de item sem mídia no mapper ⇒ fail-closed (não renderiza player quebrado). - Fixture MP3 precisa decodificar no Chromium (asserção de
paused): fixture válida e pequena; se o headless se mostrar instável, manter as asserções dearia-pressed/exclusividade e marcar a de playback com o motivo no spec. - Fricção do
Media.altno áudio: descrições nos campos do Jingle orientam a comunicação (D6); nenhum código de Media muda. - Guardas fail-closed que este PR precisa atualizar juntas: drift de reservados (
shareLink.unit), pin de top-level (codebaseConventions.unit) e pin do curado (e2eAffectedManifest.unit) — esquecer qualquer uma quebra a própria CI e o aviso é o teste. - E2E local high-risk = suíte full (OPS86) consome orçamento: loop de iteração no spec direto; o
test:e2e:affectedroda uma vez antes do push, com o tempo reservado no appetite.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto (lista publicados; play sem sair; um por vez; download MP3 com nome legível; kill switch item+página com rodapé condicional; sem autoplay e sem baixar áudio ao abrir; indexável com title/description próprios; LGPD N/A).
- Invariantes AGENTS/engineering-standards: access explícito e fail-closed;
lib/pura sempayload-types;utilitiesserver-bound comserver-only; top-level pinado; tag vocabulary emdocuments.ts; migration aditiva viamigrate:create+push:false; sem binário grande/public/;pnpm push(nuncagit pushnu); changelog só a entrada nova. - Testes de domínio previstos (unit/int) onde access/write paths mudam: int do fail-closed + hooks de revalidação; unit do helper; RTL do player; e2e do contrato público (play/exclusividade/leveza/download/kill switch).
- Designer (trigger c) certificado antes do push;
Design tierregistrado no PR (ouDEGRADED⇒ parada/sign-off).
Self-score decision-quality (gate ≥4)
- Decisões caras com rejeitadas (5/5): D1–D7 (modelo, cache, nome, vazio, rodapé, Media.alt, curado) têm opções, recomendação e o porquê de cada rejeitada; D8–D10 idem em forma compacta.
- Cabe no appetite (4/5): ~1,2 dia entre schema/server, UI e testes; nada de infra nova. O custo não-compressível é o e2e local full (OPS86) + crítica do design no fechamento — por isso todos os rabbit holes foram cortados.
- Rabbit holes nomeados (5/5): lista explícita (player rico, WAV, mídia paralela, discografia, CDN, analytics, rodapé dinâmico, binários, colisão de rota…).
- Depth check (5/5): reusa ShareLink (template de kill switch),
documentReads/documents(tags),lib/slug,formatSpeechClock,CampaignFooter,adminHeaders/seedTestUsere o padrão de fixture dos e2e; novos são só os donos que faltam (collection,lib/jingle,jingleReads, componentes page-local) — 1 top-level novo, pinado. - Intenção preservada (5/5): cada aceite de produto mapeia para um mecanismo do plano; a engenharia não reescreveu outcome (nenhum escopo novo, nenhuma PII/consent).
Média: 4,8/5 — aprovado.