diff --git a/docs/changelog/2026-09-21-s26.md b/docs/changelog/2026-09-21-s26.md new file mode 100644 index 00000000..532abc87 --- /dev/null +++ b/docs/changelog/2026-09-21-s26.md @@ -0,0 +1 @@ +**S26 — Card do jingle: título completo e crédito do artista (2026-09-21):** o crédito de quem fez a música deixa de virar reticências. Os três jingles oficiais chegam em produção como `Jorge Solla 1313 (feat. Felipe Forrozeiro)` (e Nagib Barroso, e É O MT), mas o card impunha `truncate` de uma linha e o nome do parceiro sumia (`Jorge Solla 1313 (fe…`). A correção é só de exibição — nenhum título publicado foi reescrito: uma função pura nova em `src/lib/jingle.ts` (`splitJingleTitle`) extrai o segmento final `(feat. …)` do título (case-insensitive no rótulo, artista com o casing preservado, sem crédito vazio/base vazia; sem o segmento o título volta verbatim) e o `JingleCards` — dono único do card na home e em `/jingles`, sem twin — porta classe-a-classe o design aprovado (`docs/plans/jingle-titulo-credito-artista-ui-design.html`): o heading perde `truncate` e ganha `leading-[1.02]` + `wrap-anywhere` (24px em todos os viewports, sem o bump `md:text-[26px]`), o bloco vira `items-start` e o crédito ganha `
` própria (13px, peso 700, `--campaign-muted`) sempre visível parado e tocando. O `JingleViewModel` ficou intacto: o título completo segue alimentando os aria-labels de play/pausa, progresso e download e o fallback do `coverAlt`; capa, áudio, slug, ordem, published, filename do download, grid e o player da rádio (S25) não mudaram — e jingle sem crédito (`Axé`) não reserva linha vazia, só deixa de cortar. Testes: unit da função pura (3 títulos reais + bordas de segmento duplicado/`FEAT.`/espaços/base vazia), 3 unit RTL novos no card (base sem truncamento + crédito parado e tocando, aria-labels com o título completo e fallback sem linha) e e2e novo no `frontendJingles` (crédito e título-base em `/jingles` e na home, crédito ao tocar, aria completo, `download` inalterado e prova de não-clipping com `whiteSpace`/`scrollWidth` medidos). Verificação: `pnpm gate:fast` verde (4227 unit), e2e local do `frontendJingles` 5/5 verdes, e crítica final do `designer` (tier `openai/gpt-5.6-sol`) **paridade confirmada** contra o app renderizado a 390/1280 (parado, tocando, título longo e fallback sem crédito).
diff --git a/docs/plans/jingle-titulo-credito-artista-impl.md b/docs/plans/jingle-titulo-credito-artista-impl.md
new file mode 100644
index 00000000..a8ad6d3d
--- /dev/null
+++ b/docs/plans/jingle-titulo-credito-artista-impl.md
@@ -0,0 +1,134 @@
+# Impl: Card de jingle — título completo e crédito "(feat. …)" visível (S26)
+
+Status: aprovado (modo --auto)
+Atualizado em: 2026-09-21
+Issue: #1240
+Intenção: docs/plans/jingle-titulo-credito-artista.md
+Appetite restante: herdado (~0,5 dia eng; um outcome — título-base integral em até 2 linhas + crédito do artista sempre visível; sem ajuste de escopo, cortes em Rabbit holes)
+
+## Leitura da intenção
+
+- **Outcome:** no card de jingle (home e `/jingles`) o título-base para de truncar e o segmento final `(feat. ` própria (`mt-1.5 text-[13px] leading-[1.25] font-bold text-(--campaign-muted)`) entre heading e a linha de estado. `wrap-anywhere` (Tailwind 4.1.18) porque o fail-safe exige quebrar palavra longa dentro do card; `line-clamp-*` está proibido pelo design.
+
+**D4. Estratégia de testes (custo de spec).**
+
+- _Opções:_ A) misturar um título com `feat.` nos fixtures do primeiro teste do `frontendJingles` | B) teste novo focado dentro do mesmo spec serial | C) novo spec e2e (+ entrada no manifest).
+- _Recomendação:_ B — o spec serial já é o dono das linhas de jingle (publica via REST, `createJingle` em `frontendJingles.e2e.spec.ts:70-90`) e o manifest já mapeia `src/lib/jingle` e `src/components/jingles` para `frontendJingles`; o teste novo publica 1 jingle com `(feat. …)` e pina `/jingles` + home sem reindexar asserções de contagem/índice existentes (`toHaveCount(2)`, `cardStates(['stopped','playing'])`, downloads).
+- _Rejeitadas:_ A porque muda counts e índices já pinados e mistura escopos; C porque paga entrada nova no manifest e cria um segundo dono das linhas de jingle. Unit do card usa fixture própria (sem tocar no `JINGLES` compartilhado, para não recontar `getAllByText('Pronto para tocar')`).
+
+### Componentes / mudanças
+
+- **`splitJingleTitle`** (`src/lib/jingle.ts`, novo export puro): regex ancorada no fim `/\s*\(\s*feat\.\s+([^()]+?)\s*\)\s*$/i`; `base = title.slice(0, match.index).trim()`, `artist = match[1].trim()`; se match e ambos não vazios → `{ base, credit: 'feat. ' + artist }`; senão → `{ base: title, credit: null }`. Sem import novo (client-safe como o resto do módulo); retorno inferido (sem tipo exportado sem consumidor — knip).
+- **`JingleCards`** (`src/components/jingles/JingleCards.tsx`, dono único do card): no `map`, `const { base, credit } = splitJingleTitle(jingle.title)`; heading (`:191-193`) renderiza `{base}` com as classes de D3; ` ` de crédito logo após o heading, só quando `credit`; bloco `:179` vira `items-start`; estados `:194-201`, aria-labels `:184/:208/:237`, download `:233-241` e filename `:242-244` intocados.
+- **Sem mudanças:** `JinglePlayer.tsx`, `JingleHomeSection.tsx` (`headingLevel="h4"`), `jingleReads.ts` e a URL pública — o card compartilhado resolve as duas superfícies.
+- **Testes:** `tests/unit/jingle.unit.spec.ts` (describe novo de `splitJingleTitle`); `tests/unit/jinglePlayer.unit.spec.tsx` (describe novo com fixture própria de `(feat. …)`, parado e tocando, aria completo, sem `truncate`); `tests/e2e/frontendJingles.e2e.spec.ts` (teste novo no describe serial).
+- **`docs/changelog/2026-09-21-s26.md`:** entrada curta de entrega.
+- **Migration:** nenhuma (nenhum schema; sem `generate:types`).
+- **Access / Consent:** N/A — leitura pública, sem formulário, sem PII e sem chave de Consent.
+- **UI:** Impeccable B — port classe-a-classe do artefato aprovado (`docs/plans/jingle-titulo-credito-artista-ui-design.html`, cenas 01/02/03) sobre o card existente; crítica final do designer (tier `openai/gpt-5.6-sol`).
+
+### Port do design aprovado (spec das classes)
+
+- Título-base: Exo 2, 24px, peso 900, line-height 1.02, letter-spacing -0.02em, `overflow-wrap: anywhere`; SEM `truncate`; até 2 linhas nos títulos reais (o estresse passa de 2 e nada corta — crédito continua inteiro). O design mostra desktop e mobile em 24px (sem bump `md:text-[26px]`). A cena 03 usa 22px apenas como recurso de cena — não é alvo do port.
+- Crédito: ` ` própria sob o heading, `feat. Nome do artista` SEM parênteses, 13px (mt-1.5/6px), peso 700, cor `--campaign-muted` (#6c615e). Nunca truncado.
+- Layout do bloco: `flex items-start gap-4`; heading, crédito e linha de estado empilhados em `min-w-0 flex-1`. Sem crédito: nenhuma linha vazia reservada.
+- Aria-labels preservam o título COMPLETO: `Tocar/Pausar jingle ` fica fora do branch de estado; unit pina crédito + "Em reprodução" juntos.
+- **Regressão nos testes pinados do VM:** D1 mantém o shape; rodar a suíte unit inteira (nada de `toEqual`/literais muda).
+- **Altura/ritmo do card com título de 2 linhas:** `items-start` mantém o botão no topo e o bloco cresce no fluxo; cena 03 prova que nada sobrepõe/estoura; grid `md:grid-cols-3` segue.
+- **Fonte menor no desktop (26→24px):** design fixa 24px; conferir a cena 01 e que nenhum seletor/teste dependa de `md:text-[26px]`.
+- **Home cacheada:** mudança só de exibição; nenhuma tag/revalidação tocada; e2e da home navega com `/?e2e=`.
+- **A11y do crédito:** é texto real (heading + crédito anunciados), sem `aria-hidden` e sem duplicar o crédito no heading.
+
+## Aceite de engenharia
+
+- [ ] `splitJingleTitle` puro em `src/lib/jingle.ts`, client-safe e sem import novo; contrato `{ base, credit }` com crédito pronto para exibição (`feat. …`) e nunca crédito vazio/base vazia.
+- [ ] Card único (`JingleCards`) usa a função; heading sem `truncate`, com `leading-[1.02]`/`wrap-anywhere` e sem `md:text-[26px]`; bloco `items-start`; ` ` de crédito só quando há crédito.
+- [ ] Aria-labels de play/pausa, progresso e download continuam com `jingle.title` completo; fallback de `coverAlt` inalterado.
+- [ ] Nada muda em schema/migration/Consent; títulos, capas, áudio, slug, ordem, published e download filename intocados; grid, player da rádio e metadata de `/jingles` intocados.
+- [ ] Testes: unit puro (3 títulos reais + bordas), unit do card (parado e tocando + aria completo), e2e com `(feat. …)` (crédito visível, aria completo, download inalterado) nas duas superfícies.
+- [ ] `pnpm gate:fast` verde; e2e do `frontendJingles` verde; CI verde.
+- [ ] Changelog `docs/changelog/2026-09-21-s26.md` e PR com `Closes #1240`.
+
+## Self-score decision-quality (gate ≥4)
+
+| Critério | Nota | Justificativa |
+| ----------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| 1. Decisões caras têm rejeitadas | 5 | D1 (onde derivar: VM × pura × inline), D2 (contrato/regra do split, incl. case-sensitivity) e D4 (estratégia de teste: fixture × teste novo × spec novo) com opções, recomendação e rejeitadas justificadas; D3 é fill-in do design e está registrado. |
+| 2. Abordagem cabe no appetite | 5 | ~0,5 dia: uma função pura no dono (`src/lib/jingle.ts`), classes/DOM no dono do card e três arquivos de teste estendidos; sem schema/migration/rota/componente/collection novo, sem novo spec e sem entrada no manifest. |
+| 3. Rabbit holes nomeados | 5 | Campo/schema de artista, curadoria de títulos, clamp/truncate de fail-safe, redesign da seção/player da rádio, splitter genérico, novo spec/manifest, snapshots e telemetria explicitamente cortados. |
+| 4. Depth check (reusa donos/shells) | 5 | Nenhum módulo/componente novo: a regra entra no módulo puro existente, o card segue dono único do markup (home + `/jingles`), a leitura/cache não muda e os testes estendem os donos; sem twin. |
+| 5. Intenção (aceite) preservada | 4 | Todo o aceite mapeado; desvio visual deliberado documentado — o design derruba o bump `md:text-[26px]` (24px em todos os viewports) — e o crédito sem parênteses já era recomendação assumida no gate; nenhum afeta o outcome de produto. |
+
+Média: **4,8** (≥4).
diff --git a/src/components/jingles/JingleCards.tsx b/src/components/jingles/JingleCards.tsx
index 8ae487a5..27528b36 100644
--- a/src/components/jingles/JingleCards.tsx
+++ b/src/components/jingles/JingleCards.tsx
@@ -3,7 +3,7 @@
import Image from 'next/image'
import { useRef, useState } from 'react'
-import type { JingleViewModel } from '@/lib/jingle'
+import { splitJingleTitle, type JingleViewModel } from '@/lib/jingle'
import { formatSpeechClock } from '@/lib/speechClock'
import { cn } from '@/lib/utils'
@@ -143,6 +143,7 @@ export const JingleCards = ({
+ {credit}
+
diff --git a/src/lib/jingle.ts b/src/lib/jingle.ts
index 3d840077..14d52834 100644
--- a/src/lib/jingle.ts
+++ b/src/lib/jingle.ts
@@ -50,6 +50,26 @@ export const jingleDownloadFilename = (
return `${DOWNLOAD_PREFIX}-${base}.${safeExtension}`
}
+const FEAT_CREDIT_PATTERN = /\s*\(\s*feat\.\s+([^()]+?)\s*\)\s*$/i
+
+/**
+ * S26 — display-only split of the featured artist. Published titles carry the
+ * guest as a trailing `(feat. …)` segment; the card shows the base on its own
+ * heading and the credit on a second line. The stored title is never rewritten:
+ * the full string keeps feeding the aria-labels and the cover alt, and a title
+ * without the segment falls back to itself with no credit line.
+ */
+export const splitJingleTitle = (title: string) => {
+ const match = title.match(FEAT_CREDIT_PATTERN)
+ if (!match) return { base: title, credit: null }
+
+ const base = title.slice(0, match.index).trim()
+ const artist = match[1].trim()
+ if (!base || !artist) return { base: title, credit: null }
+
+ return { base, credit: `feat. ${artist}` }
+}
+
/**
* A card only exists when both media are readable: an unpopulated/missing
* cover or audio fails closed (the jingle is skipped, never rendered broken).
diff --git a/tests/e2e/frontendJingles.e2e.spec.ts b/tests/e2e/frontendJingles.e2e.spec.ts
index c48052b6..3af435f5 100644
--- a/tests/e2e/frontendJingles.e2e.spec.ts
+++ b/tests/e2e/frontendJingles.e2e.spec.ts
@@ -308,4 +308,53 @@ test.describe('Frontend jingles (S21)', () => {
await expect(seeAll).toBeVisible()
await expect(seeAll).toHaveAttribute('href', '/jingles')
})
+
+ // S26 — the artist credit never disappears: its own line under the base
+ // title, untruncated, stopped and playing, on /jingles and the home; the
+ // full title (credit included) keeps feeding the aria-labels.
+ test('shows the featured artist credit without truncating the title', async ({
+ page,
+ request,
+ }) => {
+ const headers = await adminHeaders(request, BASE_URL)
+ await unpublishEveryJingle(request, headers)
+ const slug = uniqueSlug()
+ const fullTitle = 'Jorge Solla 1313 (feat. Felipe Forrozeiro)'
+ await createJingle(request, headers, { title: fullTitle, slug })
+
+ await page.goto('/jingles')
+ const card = page.locator('article[data-jingle]')
+ await expect(card).toHaveCount(1)
+ const heading = card.getByRole('heading', { name: 'Jorge Solla 1313', exact: true })
+ await expect(heading).toBeVisible()
+ // Visibility alone does not catch CSS clipping: the heading must wrap
+ // (never `nowrap`) and its text must fit the box without hidden overflow.
+ const headingLayout = await heading.evaluate((el) => ({
+ whiteSpace: getComputedStyle(el).whiteSpace,
+ overflows: el.scrollWidth > el.clientWidth + 1,
+ }))
+ expect(headingLayout.whiteSpace).not.toBe('nowrap')
+ expect(headingLayout.overflows).toBe(false)
+ await expect(card.getByText('feat. Felipe Forrozeiro')).toBeVisible()
+ await expect(page.getByRole('button', { name: `Tocar jingle ${fullTitle}` })).toBeVisible()
+ await expect(page.getByRole('link', { name: `Baixar ${fullTitle} em MP3` })).toHaveAttribute(
+ 'download',
+ `jorge-solla-1313-${slug}.mp3`,
+ )
+ await expect(page.getByText(`jorge-solla-1313-${slug}.mp3`)).toBeVisible()
+
+ // Playing keeps the credit on the card.
+ await page.getByRole('button', { name: `Tocar jingle ${fullTitle}` }).click()
+ await expect(card.getByText('Em reprodução')).toBeVisible()
+ await expect(card.getByText('feat. Felipe Forrozeiro')).toBeVisible()
+
+ // The home reuses the same card and the same credit.
+ await page.goto(`/?e2e=${Date.now()}`)
+ const homeCard = page.locator('[data-home-section="sound"] article[data-jingle]')
+ await expect(homeCard).toHaveCount(1)
+ await expect(
+ homeCard.getByRole('heading', { name: 'Jorge Solla 1313', exact: true }),
+ ).toBeVisible()
+ await expect(homeCard.getByText('feat. Felipe Forrozeiro')).toBeVisible()
+ })
})
diff --git a/tests/unit/jingle.unit.spec.ts b/tests/unit/jingle.unit.spec.ts
index 084db5ea..7aa36002 100644
--- a/tests/unit/jingle.unit.spec.ts
+++ b/tests/unit/jingle.unit.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { jingleDownloadFilename, toJingleViewModel } from '@/lib/jingle'
+import { jingleDownloadFilename, splitJingleTitle, toJingleViewModel } from '@/lib/jingle'
describe('jingleDownloadFilename', () => {
it.each([
@@ -24,6 +24,31 @@ describe('jingleDownloadFilename', () => {
})
})
+describe('splitJingleTitle', () => {
+ it.each([
+ ['Jorge Solla 1313 (feat. Felipe Forrozeiro)', 'Jorge Solla 1313', 'feat. Felipe Forrozeiro'],
+ ['Jorge Solla 1313 (feat. Nagib Barroso)', 'Jorge Solla 1313', 'feat. Nagib Barroso'],
+ ['Jorge Solla 1313 (feat. É O MT)', 'Jorge Solla 1313', 'feat. É O MT'],
+ ['Jorge Solla 1313 (FEAT. É O MT)', 'Jorge Solla 1313', 'feat. É O MT'],
+ ['Jorge Solla 1313 ( feat. É O MT )', 'Jorge Solla 1313', 'feat. É O MT'],
+ ['Jorge Solla 1313 (feat. Felipe Forrozeiro) ', 'Jorge Solla 1313', 'feat. Felipe Forrozeiro'],
+ ['Jorge Solla 1313 (feat. A) (feat. B)', 'Jorge Solla 1313 (feat. A)', 'feat. B'],
+ ])('splits %s', (title, base, credit) => {
+ expect(splitJingleTitle(title)).toEqual({ base, credit })
+ })
+
+ it.each([
+ 'Axé',
+ 'Forró',
+ 'Jorge Solla 1313 (ao vivo)',
+ 'Jorge Solla 1313 (feat. )',
+ '(feat. Felipe Forrozeiro)',
+ 'Jorge Solla 1313 (feat. X) ao vivo',
+ ])('keeps %s verbatim without a credit line', (title) => {
+ expect(splitJingleTitle(title)).toEqual({ base: title, credit: null })
+ })
+})
+
describe('toJingleViewModel', () => {
const jingle = { id: 7, title: 'Axé', slug: 'axe' }
diff --git a/tests/unit/jinglePlayer.unit.spec.tsx b/tests/unit/jinglePlayer.unit.spec.tsx
index f2da77e1..ae8d6967 100644
--- a/tests/unit/jinglePlayer.unit.spec.tsx
+++ b/tests/unit/jinglePlayer.unit.spec.tsx
@@ -26,6 +26,17 @@ const JINGLES: readonly JingleViewModel[] = [
const playMock = vi.fn(() => Promise.resolve())
const pauseMock = vi.fn()
+// S26 — own fixture: the specs above count the "Pronto para tocar" texts of
+// the shared array, so the credited title stays out of `JINGLES`.
+const FEAT_JINGLE: JingleViewModel = {
+ id: 9,
+ title: 'Jorge Solla 1313 (feat. Felipe Forrozeiro)',
+ coverUrl: '/api/media/file/capa-forro.jpg',
+ coverAlt: 'Capa do jingle Jorge Solla 1313 (feat. Felipe Forrozeiro) de Jorge Solla 1313',
+ audioUrl: '/api/media/file/forro.mp3',
+ downloadFilename: 'jorge-solla-1313-forro.mp3',
+}
+
const cards = () => Array.from(document.querySelectorAll
title verbatim"] --> R["jingleReads.getPublishedJingleItems()"]
+ R --> VM["JingleViewModel.title
(completo, intocado)"]
+ VM --> CARD["JingleCards (dono único do card)
home h4 + /jingles"]
+ VM --> ARIA["aria-labels play/pausa, progresso e download
+ fallback de coverAlt"]
+ CARD --> SPLIT["splitJingleTitle
(pura, src/lib/jingle.ts)"]
+ SPLIT --> BASE["heading: base"]
+ SPLIT --> CR["p.credit: 'feat.