From 425361d278983c849714ba5806cb878634d97b9e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 15:53:08 -0300 Subject: [PATCH] =?UTF-8?q?feat(C216):=20terceira=20fonte=20do=20acervo=20?= =?UTF-8?q?=E2=80=94=20falas=20na=20internet=20(Closes=20#1292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/changelog/2026-09-24-c216.md | 1 + docs/plans/acervo-fonte-falas-web-impl.md | 223 +++++++++++ .../acervo-fonte-falas-web-ui-design.html | 325 +++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 8 + .../acervo/gravacoes/[id]/page.tsx | 2 +- .../comunicacao/acervo/gravacoes/page.tsx | 2 +- .../acervo/internet/[id]/arquivo/route.ts | 69 ++++ .../acervo/internet/[id]/capa/route.ts | 62 +++ .../comunicacao/acervo/internet/[id]/page.tsx | 93 +++++ .../(app)/comunicacao/acervo/page.tsx | 168 +++++++- .../campaign/recording/AcervoSourceToggle.tsx | 40 -- .../recording/RecordingDetailPlayer.tsx | 4 +- .../recording/RecordingSortSelect.tsx | 51 --- .../recording/RecordingSpeakerTranscript.tsx | 4 +- .../campaign/shared/AcervoSortSelect.tsx | 62 +++ .../campaign/shared/AcervoSourceToggle.tsx | 43 ++ .../CampaignTranscriptSegmentButton.tsx} | 24 +- .../campaign/speech/SpeechAcervoFilters.tsx | 53 ++- .../campaign/speech/SpeechResultThumbnail.tsx | 4 + .../campaign/speech/WebSpeechDetailPlayer.tsx | 187 +++++++++ .../campaign/speech/WebSpeechPlatformPill.tsx | 34 ++ .../campaign/speech/WebSpeechResultCard.tsx | 101 +++++ .../campaign/speech/WebSpeechResultList.tsx | 11 + src/lib/acervoListSort.ts | 33 ++ src/lib/acervoSource.ts | 40 ++ src/lib/campaignPageChrome.ts | 3 +- src/lib/campaignPaths.ts | 13 + src/lib/webSpeech.ts | 43 +- src/utilities/recordings/recordingListUrl.ts | 68 +--- src/utilities/speech/speechListFilters.ts | 20 +- src/utilities/speech/speechListUrl.ts | 73 +++- src/utilities/speech/speechOmnibox.ts | 21 +- src/utilities/speech/speechPageData.ts | 244 +++++++++++- src/utilities/speech/speechViewModels.ts | 191 ++++++++- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 343 ++++++++++++++++ tests/int/webSpeechAcervo.int.spec.ts | 371 ++++++++++++++++++ tests/unit/acervoListSort.unit.spec.ts | 39 ++ tests/unit/acervoSource.unit.spec.ts | 32 ++ tests/unit/campaignPageChrome.unit.spec.ts | 8 +- tests/unit/recordingListUrl.unit.spec.ts | 14 - tests/unit/speechListFilters.unit.spec.ts | 47 +++ tests/unit/speechListUrl.unit.spec.ts | 95 +++++ tests/unit/speechOmnibox.unit.spec.ts | 22 ++ tests/unit/speechViewModels.unit.spec.ts | 94 +++++ tests/unit/webSpeech.unit.spec.ts | 43 ++ 46 files changed, 3203 insertions(+), 226 deletions(-) create mode 100644 docs/changelog/2026-09-24-c216.md create mode 100644 docs/plans/acervo-fonte-falas-web-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/capa/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/page.tsx delete mode 100644 src/components/campaign/recording/AcervoSourceToggle.tsx delete mode 100644 src/components/campaign/recording/RecordingSortSelect.tsx create mode 100644 src/components/campaign/shared/AcervoSortSelect.tsx create mode 100644 src/components/campaign/shared/AcervoSourceToggle.tsx rename src/components/campaign/{recording/RecordingTranscriptSegmentButton.tsx => shared/CampaignTranscriptSegmentButton.tsx} (64%) create mode 100644 src/components/campaign/speech/WebSpeechDetailPlayer.tsx create mode 100644 src/components/campaign/speech/WebSpeechPlatformPill.tsx create mode 100644 src/components/campaign/speech/WebSpeechResultCard.tsx create mode 100644 src/components/campaign/speech/WebSpeechResultList.tsx create mode 100644 src/lib/acervoListSort.ts create mode 100644 src/lib/acervoSource.ts create mode 100644 tests/int/webSpeechAcervo.int.spec.ts create mode 100644 tests/unit/acervoListSort.unit.spec.ts create mode 100644 tests/unit/acervoSource.unit.spec.ts diff --git a/.gitignore b/.gitignore index 81745da7..be986008 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ next-env.d.ts /reelMedia /recordingMedia /contentMedia +/internetSpeechMedia # TSE open-data zip cache (pnpm db:seed:tse) /data/tse/ diff --git a/docs/changelog/2026-09-24-c216.md b/docs/changelog/2026-09-24-c216.md new file mode 100644 index 00000000..196bdd20 --- /dev/null +++ b/docs/changelog/2026-09-24-c216.md @@ -0,0 +1 @@ +**C216 — Terceira fonte do acervo: "Falas na internet" (2026-09-24):** o alternador do acervo passa a ter três fontes — "Falas da Câmara", "Gravações enviadas" e "Falas na internet" — e a terceira abre sobre o catálogo web do C215 com a paridade de busca e filtros da Câmara: busca textual com destaque, `mode=termo|tema`, ano, tema, alcance, duração, municípios citados, contagem, paginação e ordenação. Contrato sem migration e sem twin: o vocabulário de fonte saiu de `recordingListUrl` para o dono puro `lib/acervoSource` (3 vias, desconhecido→Câmara, fail-closed) e o de ordenação para `lib/acervoListSort` (2º consumidor do vocabulário do C219, com o mapeamento de Payload por domínio); `speechListUrl` ganha `source?: 'internet'` e `sort?` de forma **aditiva** (os bytes canônicos da Câmara ficam intocados, a fase é ignorada na web) e `speechListFilters` deriva `origin: web` do estado, mantendo a fase só na Câmara e o gate de duração (`durationSeconds exists`) nos sorts de duração. Loaders e view models ficam nos donos (`speechPageData` ganha `loadWebSpeechAcervoPageData`/`loadWebSpeechDetailPageData`/`loadWebSpeechTitleForActor` e opções de filtro por origem; `speechViewModels` ganha a família `WebSpeech*`) e o detalhe da Câmara passa a filtrar `origin: camara` — um id web vira 404 honesto, e vice-versa. Rotas novas em `/campanha/comunicacao/acervo/internet/[id]` (+ `/arquivo` e `/capa`), precedente `gravacoes/[id]`: a mídia espelhada privada é servida pelo dono `buildPrivateMediaResponse` com gate `canReadCommunicationCatalog`, range/416/404 silencioso e **nome de download derivado do título** (`webSpeechDownloadFilename` fecha o defer S4 do C215); a capa usa a mesma rota autenticada (nunca `/api/media/file`) e o card sem thumbnail renderiza "SEM CAPA". UI (Impeccable C, porte do design aprovado `docs/plans/acervo-fonte-falas-web-ui-design.html`, estendido pelo `designer` na cena 05 com o player de áudio e os chips de plataforma Instagram/Áudio): `AcervoSourceToggle` vira shell de 3 abas em `shared/` (grid de rótulos curtos no mobile), `AcervoSortSelect` compartilhado (serializador escolhido pelo discriminador de fonte dentro do client component — função nunca cruza a fronteira server→client) e `CampaignTranscriptSegmentButton` movido para `shared/` com tipo estrutural; `SpeechAcervoFilters` ganha props de copy (defaults exatos da Câmara, sem Fase na web), `WebSpeechResultCard/List` portam a cena 01 e `WebSpeechDetailPlayer` a 02/05 (vídeo ou áudio nativo no mesmo palco com o badge "Arquivo espelhado · privado", "Baixar", "Abrir na origem", box de origem com plataforma + canal/autor + data e transcrição clicável que posiciona o player). Verificação: unit (`acervoSource`, `acervoListSort`, URL/filtros/omnibox/VMs web e `webSpeechDownloadFilename`, com os pins da Câmara intocados), int `webSpeechAcervo` (lista só web, facetas, ordenação, tema injetado, rotas privadas no VM, 404 cruzado entre origens, gate de papéis), e2e do `campaignSpeechAcervo` estendido (switcher, card com pill/capa/placeholder, facetas + vazio honesto, ordenação com hint, detalhe com player/transcrição/download/origem, `arquivo`/`capa` com range/download nomeado/negação) e manifest e2e com os vocabulários e shells novos; `pnpm gate:fast`, `pnpm test`, `pnpm build` e os e2e locais (`--workers=1`). Crítica final do `designer` (trigger c) registrada no PR. diff --git a/docs/plans/acervo-fonte-falas-web-impl.md b/docs/plans/acervo-fonte-falas-web-impl.md new file mode 100644 index 00000000..7e029354 --- /dev/null +++ b/docs/plans/acervo-fonte-falas-web-impl.md @@ -0,0 +1,223 @@ +# Impl: C216 — Terceira fonte do acervo: "Falas na internet" + +Status: aprovado +Atualizado em: 2026-09-24 +Issue: #1292 +Intenção: docs/plans/acervo-fonte-falas-web.md +Appetite restante: herdado (~3–4 dias eng; um outcome verificável — a assessoria alterna para "Falas na internet" no mesmo acervo, busca/filtra com a paridade da Câmara e assiste/baixa a fala encontrada, sabendo de onde ela veio) + +## Leitura da intenção + +- **Outcome:** a terceira fonte do acervo existe e é navegável: alternador de três fontes no `/campanha/comunicacao/acervo`; "Falas na internet" com busca textual com destaque, `mode=termo|tema`, ano, tema, alcance, duração e municípios citados (paridade no que a fonte tem), contagem e paginação; o detalhe da fala abre a mídia espelhada privada com player, transcrição clicável por segmento, "Baixar", "Abrir na origem" e atribuição (plataforma + canal/autor + data); chip de plataforma na lista e capa da origem (ou placeholder). Gate fail-closed do acervo; mídia nunca pública; mobile utilizável. +- **O que NÃO negociar:** + - Gate do acervo fail-closed (`requireCampaignPageActor({ gate: 'communicationCatalog' })` na página + `canReadCommunicationCatalog` na rota de mídia); `advisor`/`leader` negados com `404` silencioso, nunca `403` que confirma existência. + - **Contrato da Câmara congelado em bytes**: `q|mode|year|topic|scope|phase|municipality|duration|page` continua parseando/serializando exatamente como hoje; nenhum comportamento da Câmara muda (lista, barra, vazio, detalhe, cortes, Sollinha, cobertura). + - Mídia espelhada jamais pública: nunca `/api/media/file`; serving só pela rota autenticada sob `/campanha`. + - Sem segundo cadastro de pessoa, sem `Consent` novo, sem escrita (a fonte é read-only), sem item de nav novo. + - `mode=tema` mantém a degradação honesta (aviso + busca literal) e a expansão só roda depois do gate. +- **O que reavaliar (hipóteses do plano de intenção vs. código):** + - `parseAcervoSource`/`AcervoSource` hoje vivem no contrato das **gravações** (`recordingListUrl.ts:44,107`), mas a fonte nova é do domínio `speech`. Importar o vocabulário de fonte de `recordings` dentro de `speech*` criaria **ciclo** (`recordings` já importa `speechListUrl`/`speechListFilters`). O vocabulário compartilhado do acervo sai para `src/lib/acervoSource.ts` (D1). + - `RecordingSortSelect`/`RECORDING_SORT_OPTIONS` (C219) são o 3º consumidor antecipado no próprio C219 ("extrair quando C216 chegar — 3º consumidor"): a ordenação vira vocabulário puro compartilhado (D2). + - `loadSpeechDetailPageData` não filtra `origin` hoje (`speechPageData.ts:266-275`): um id web em `/acervo/[id]` renderizaria o player da Câmara quebrado — fechar é parte desta entrega (D3). + - `clearSpeechOmnibox` descarta tudo (`speechOmnibox.ts:274-276`), inclusive um futuro `source`: sem o ajuste, "limpar" uma busca web cairia na Câmara (D6). + - O defer S4 do C215 ("nome do arquivo espelhado genérico (`source.mp4`) — gatilho: C216 define o nome de download") vence agora (D4). + - A variante visual de player para **áudio** e as cores dos chips das plataformas **Instagram/Áudio** não existem no artefato aprovado (que cobre vídeo + YouTube/Rádio): trigger (a) do `designer` antes do markup (D6/§Design). + - O artefato não mostra `/acervo/internet` como rota de lista: a fonte web é `?source=internet` no mesmo alternador (gate confirmou), e só o detalhe ganha segmento próprio (`internet`) — como o C199 fez com `gravacoes` (D3). + +## Abordagem recomendada + +```mermaid +flowchart LR + SW["alternador 3 fontes
?source=internet"] --> URL["speechListUrl (dono)
q|mode|year|topic|scope|municipality|duration + source/sort aditivos"] + URL --> WH["buildSpeechListWhere
origin = source internet ? web : camara"] + WH --> FIND["payload.find speech
user · overrideAccess:false"] + FIND --> VM["WebSpeech*ViewModel
excerpt + plataforma + capa"] + VM --> LIST["WebSpeechResultList
mark · chips · contagem · AcervoSortSelect"] + + DETURL["/acervo/internet/[id]"] --> DET["loadWebSpeechDetailPageData
origin web"] + DET --> PLAY["WebSpeechDetailPlayer
Arquivo espelhado · privado"] + PLAY --> ARQ["internet/[id]/arquivo
buildPrivateMediaResponse · Baixar"] + PLAY --> CAPA["internet/[id]/capa
capa privada"] + DET --> ORIG["Origem: plataforma · canal/autor · Abrir na origem"] + + LIB["src/lib/acervoSource + src/lib/acervoListSort"] -.-> URL + LIB -.-> WH + CAM["superfícies Câmara
origin: camara (blindagem C215)"] -.-> FIND +``` + +**Opções consideradas:** A) a fonte web como **dimensão do contrato da fala** (estender `speechListUrl`/`speechListFilters`/`speechOmnibox`/`speechPageData`/`speechViewModels` com `source`), detalhe em rota própria e mídia privada por fala reusando `buildPrivateMediaResponse` | B) um contrato gêmeo da fonte web (`webSpeechListUrl`, `webSpeechOmnibox`, `webSpeechPageData`, `webSpeechViewModels`, `webSpeechListFilters`) | C) uma rota de lista separada por fonte (e um detalhe despachado dentro de `/acervo/[id]`). +**Recomendação:** A — o material é a **mesma unidade de catálogo** (`speech`, as mesmas facetas, os mesmos segmentos, a mesma busca trigram, o mesmo `SpeechAcervoFilters` pode ser reusado porque o estado é o mesmo tipo) e a intenção cortou explicitamente a lista separada; a "fonte" já é dimensão do contrato em `recordingListUrl` (`?source=enviadas`) desde o C199. O que é genuinamente novo (rota do detalhe, mídia privada por fala, card com capa/plataforma, ordenação) entra no dono do concern, sem twin. +**Rejeitadas:** B porque duplica parse/serialização do MESMO par `collection`×facetas, e forçaria gêmeos de omnibox, filtros e loaders — a doutrina "edit the owner, don't twin" e o critério real da intenção ("sem renomear/quebrar params existentes") não pedem um segundo contrato, pedem params aditivos; C porque fragmenta o acervo (anti-goal confirmado no gate) e misturar os dois details acoplados em `/acervo/[id]` arriscaria o player/VOD/cortes da Câmara. + +### Decisões de engenharia + +**D1 — A fonte web é dimensão do contrato da fala; o vocabulário de fonte sai para `src/lib/acervoSource.ts`.** +`Decisão: A — speechListUrl ganha source?: 'internet' (só serializa quando web) e sort?; os params source|sort entram no paramNameSet de forma aditiva; parse de source fail-closed (desconhecido→Câmara); valor ?source=internet; vocabulário compartilhado em lib pura.` +`Por quê: os bytes canônicos da Câmara ficam intocados (pins existentes verdes); a web é um recorte da mesma tabela/facetas, e um único ponto traduz source internet → origin web na where.` +`Rejeitadas: B (webSpeechListUrl gêmeo) porque duplica o parse/serialize das MESMAS facetas e força gêmeos de omnibox/loaders; C (rota de lista separada) porque a intenção confirmou alternador único.` + +- **Ajuste ao código (registrado):** `parseAcervoSource`/`AcervoSource`/`buildAcervoSourceHref` **movem** de `src/utilities/recordings/recordingListUrl.ts` para `src/lib/acervoSource.ts` (`AcervoSource = 'camara' | 'enviadas' | 'internet'`, `ACERVO_SOURCE_PARAM`, `parseAcervoSource` com o mesmo fail-closed, `buildAcervoSourceHref`). Motivo: `speechListUrl` precisa do vocabulário e não pode importar de `recordings` (ciclo: `recordings → speechListUrl/speechListFilters`); o lib já é a casa dos contratos puros/client-safe e não importa `@/utilities` (verificado). Os call sites atualizam o import (`AcervoSourceToggle`, `acervo/page.tsx`, `gravacoes/page.tsx`, `gravacoes/[id]/page.tsx`, unit das gravações); sem re-export falso. +- `SpeechListState` ganha `source?: 'internet'` (ausente = Câmara; o tipo não vira obrigatório para não quebrar as construções existentes). `parseSpeechListParams` deriva `isWeb = parseAcervoSource(params) === 'internet'`; `source: 'internet'` só entra no estado quando o valor é exatamente esse. +- Serialização canônica: `source=internet` **sempre** serializado quando o estado é web (é o que seleciona a fonte); a Câmara segue sem o param. `sort` só serializa quando web e ≠ `recentes`. +- **Fase é só da Câmara:** com `source=internet`, `parseSpeechListParams` ignora `phase` → o canônico não o carrega → `?source=internet&phase=X` redireciona para o canônico (fail-closed). Sem web, tudo como hoje. +- **Fail-closed de valor desconhecido:** `source=xyz` → Câmara no dispatch e no parse; o canônico omite o param → redirect (mesmo desfecho de hoje, quando o param era "unsupported"). +- Página: `parseAcervoSource(params)` vira despacho de 3 vias em `acervo/page.tsx:200`; `enviadas` segue o branch C199; `internet` entra no branch novo; o resto é Câmara. + +**D2 — Ordenação compartilhada (extrair o dono, não copiar).** +`Decisão: A — vocabulário em src/lib/acervoListSort.ts (ACERVO_SORT_OPTIONS/labels + AcervoSortKey + parseAcervoSort fail-closed + acervoSortIsDuration) e componente shared/AcervoSortSelect parametrizado por toHref + hint.` +`Por quê: é o 3º consumidor prometido no defer do C219; o mapeamento para o sort do Payload fica por domínio (recentes → -createdAt nas gravações, → -speechAt na web; duração → ±durationSeconds com gate durationSeconds exists); assim os buckets/ordenação nunca divergem entre fontes.` +`Rejeitadas: copiar a vocab para o módulo de speech (gêmea de conhecimento idêntico — mudança de rótulo/valor divergiria as fontes em silêncio); não implementar sort (quebraria classe-a-classe a cena 01 do artefato, que tem o select).` + +- `recordingListUrl.ts` passa a importar de `@/lib/acervoListSort` (as gravações mantêm o contrato em bytes: `RECORDING_SORT_OPTIONS`/`RecordingSortKey` deixam de ser exportados porque não há outro consumidor; `recordingSortOrder` fica no domínio e `recordingSortIsDuration` delega a `acervoSortIsDuration(state.sort)`). `speechListUrl.ts` ganha `webSpeechSortOrder(state)` (`recentes`→`-speechAt`, `duracao_maior`→`-durationSeconds`, `duracao_menor`→`durationSeconds`). +- `AcervoSortSelect` (`components/campaign/shared/AcervoSortSelect.tsx`, client) recebe `state` genérico (`{ page: number; sort?: AcervoSortKey }`) + `toHref` + `hint`; mostra o hint quando o sort ativo é de duração. `RecordingSortSelect.tsx` é removido (o ramo das gravações passa o hint atual; a web passa "Falas sem duração aparecem apenas em Mais recentes."). +- Gate do sort de duração no `where`: `{ durationSeconds: { exists: true } }` — sem ele o Postgres abriria "Duração (maior)" com as linhas sem duração (NULLS FIRST); na Câmara é inerte (não parseia sort). + +**D3 — Detalhe da fala web em rota própria; detalhe da Câmara blindado.** +`Decisão: A — /campanha/comunicacao/acervo/internet/[id] (+ /arquivo e /capa), precedente gravacoes/[id]; loadSpeechDetailPageData ganha origin: camara no where (id web em /acervo/[id] vira 404).` +`Por quê: o detalhe da Câmara é acoplado (VOD, SpeechDetailPlayer, cortes C174, taquigrafia) e o da web é outro produto (mídia espelhada, atribuição, download) — separá-los mantém cada um com seus donos e fecha o risco de id web renderizar o player da Câmara.` +`Rejeitadas: despachar os dois detalhes no /acervo/[id] existente (mistura dois fluxos acoplados no mesmo arquivo, risco de regressão no player/VOD/cortes da Câmara; o parâmetro oculto de source no detalhe não existe hoje).` + +- Segmento `internet` (decidido): `CAMPAIGN_COMMUNICATION_ACERVO_INTERNET = '/campanha/comunicacao/acervo/internet'` em `campaignPaths.ts`, com `campaignInternetSpeechHref(id)` (a URL que o card/detalhe usam), e o detalhe Câmara segue `/acervo/`. Sem rota de lista `/acervo/internet` (o alternador aponta `?source=internet`); um `/acervo/internet` digitado cai em `[id]` com id não-numérico → `notFound`. +- `loadSpeechDetailPageData` (Câmara) passa a consultar `{ id: { equals: speechId }, origin: { equals: 'camara' } }` → `SpeechNotFoundError` para id web (o int pina). +- O chrome não precisa de rule nova: o regex genérico `/^\/campanha\/comunicacao\/acervo\/[^/]+$/` (`campaignPageChrome.ts:330`) não casa `/acervo/internet/` (dois segmentos); o detalhe seta o próprio chrome via `SetCampaignPageChrome` (precedente `gravacoes/[id]`). + +**D4 — Mídia privada por fala (arquivo + capa) com nome de download derivado do título.** +`Decisão: A — internet/[id]/arquivo (player + ?download=1) e internet/[id]/capa, ambos com o gate do acervo (getCampaignUser + canReadCommunicationCatalog, findByID overrideAccess:false, 404 quando origin !== 'web'), via buildPrivateMediaResponse; dispositionFilename puro derivado do título — fecha o S4 do C215.` +`Por quê: o dono do I/O privado já existe e serve range/416/404/download/Cache-Control; a rota por fala conhece o título para nomear o arquivo e é o único lugar que cruza fala × mídia com o gate do catálogo.` +`Rejeitadas: rota única por media id (não conhece a fala → não tem nome de download nem o gate do acervo); /api/media/file (contrato público anônimo por definição — vazaria a collection privada).` + +- `arquivo/route.ts`: `findByID` `depth: 1` selecionando `{ origin, title, mirroredMedia }`, `404` se `origin !== 'web'` ou sem mídia; `staticDir = resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG)` (helper exportado do dono); `download = searchParams.get('download') === '1'`; `dispositionFilename = webSpeechDownloadFilename({ title, storedFilename: media.filename })`. +- `capa/route.ts`: mesma forma, servindo `thumbnail` inline (`download: false`; 404 quando ausente — o placeholder do card decide antes, pela presença do id). +- `webSpeechDownloadFilename({ title, storedFilename })` em `src/lib/webSpeech.ts` (puro, unit-testado): preserva a extensão do arquivo armazenado, remove control chars/aspas/CRLF, colapsa separadores, limita o tamanho e cai no próprio `storedFilename` quando o título é vazio. +- `privateMedia.ts` já considera áudio/vídeo/imagem inline — nada a mudar; o `Content-Disposition` continua sendo o do dono (`filename` + `filename*`). +- Cascata/limpeza: nada novo — o `beforeDelete` do `Speech` (C215) já apaga mídia e capa. + +**D5 — Loaders e view models nos donos (`speechPageData`/`speechViewModels`).** +`Decisão: A — generalizar loadSpeechFilterOptions(payload, user, origin) (fase só Câmara) e adicionar loadWebSpeechAcervoPageData/loadWebSpeechDetailPageData no mesmo módulo; adicionar a família WebSpeech*ViewModel + builders puros no mesmo speechViewModels, reusando os helpers privados.` +`Por quê: collection, facetas, segmentos, labels e o gate de tema são o mesmo conhecimento; um módulo gêmeo duplicaria (e divergiria) os helpers privados formatSpeechAt/formatSpeechDuration/municipalityViewModels/topicViewModels/pickThemeMatch.` +`Rejeitadas: webSpeechPageData.ts/webSpeechViewModels.ts gêmeos (twin de conhecimento com helpers privados duplicados); loader web dentro do componente/página (não testável por camada).` + +- `loadSpeechFilterOptions(payload, user, origin)`: `where { origin: { equals: origin } }`; `phases` só é preenchido para `'camara'`; anos/municípios saem do conjunto da fonte (para a web, `phases: []` — a faceta Fase não aparece e o omnibox não semeia fases). +- `loadWebSpeechAcervoPageData(payload, user, searchParams, expandTheme = expandSpeechSearchTheme)`: `resolveSpeechListUrl` (que já lê `source=internet`); gate de tema (`mode=tema && q && canReadCommunicationCatalog` antes de qualquer chamada externa); `payload.find speech` com `where: buildSpeechListWhere(state, themeTerms)`, `sort: webSpeechSortOrder(state)`, `select` web (+`searchText` só quando `q`, para `matchedTextSearch`/evidência de tema) e `user`/`overrideAccess:false`; paginação `speechPageSize`; `loadSegmentsForSpeeches` (dono), `loadMunicipalityLabelsByIds`+`municipalityNameMap` (donos); **sem** cortes/VOD/poster (C217 e admin são outros donos). Retorno `WebSpeechAcervoPageData` espelhando `SpeechAcervoPageData` (rows/state/redirectHref/totalDocs/totalPages/filterOptions/themeUnavailable/themeApplied). +- `loadWebSpeechDetailPageData(payload, user, speechId, query?)`: `find` com `{ id, origin: 'web' }` e `depth: 1` (`mirroredMedia`/`thumbnail` com `mimeType`/`filename`); segmentos via `speechSegment` (mesmo select/sort `order`); monta `WebSpeechDetailViewModel`. `loadWebSpeechTitleForActor` para `generateMetadata` (leitura só de título, precedente das gravações). +- `speechViewModels.ts`: `WebSpeechListRecord`/`WebSpeechDetailRecord` + `toWebSpeechListItemViewModel`/`toWebSpeechDetailViewModel`, reusando `formatSpeechAt`/`formatSpeechDuration`/`municipalityViewModels`/`topicViewModels` (mesmo módulo), `pickThemeMatch`/`pickMatchingSegment`/`buildHighlightedExcerpt`/`splitHighlightedParts` (`speechHighlight`/`speechSearch` — `keywords` ausente na web é tratado pelo próprio helper) e um `withSeekQuery(href, segment, query)` interno extraído para os dois hrefs de detalhe (o atual `buildWatchHref` da Câmara mantém assinatura e bytes; o web é `buildWebSpeechWatchHref`). +- `thumbnailUrl` do VM web = `campaignInternetSpeechCoverHref(id)` quando `thumbnail` existe; senão `null` (o card renderiza o placeholder "SEM CAPA"). `platform` vira `{ value, label }` via `webSpeechPlatformLabel`. + +**D6 — UI: portar o artefato; estender a barra da Câmara por copy condicional (sem twin).** +`Decisão: A — SpeechAcervoFilters ganha props de copy com default = Câmara (label/placeholder/aria/label da faceta Município citado/showPhase) e speechOmnibox ganha municipalityLabel opcional; componentes novos só os da fonte web (card/lista/player).` +`Por quê: o estado e o omnibox são o MESMO contrato (SpeechListState) — diferente do C219, que rejeitou estender a barra da Câmara para as gravações porque o contrato era outro; aqui estender por copy é reuso do dono, e o default preserva os bytes/comportamento da Câmara.` +`Rejeitadas: WebSpeechAcervoFilters próprio (twin de ~150 linhas sobre o mesmo estado/omnibox — a mecânica é a dos shells compartilhados); mover/reescrever a barra da Câmara (anti-goal: a Câmara é a referência intocada).` + +- **Design gate (Passo 3d — trigger (a)):** o artefato cobre vídeo + chips YouTube/Rádio. Antes do markup, despachar o `designer` para estender o artefato com (1) a variante de player para mídia de **áudio** (`