41 KiB
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 +canReadCommunicationCatalogna rota de mídia);advisor/leadernegados com404silencioso, nunca403que confirma existência. - Contrato da Câmara congelado em bytes:
q|mode|year|topic|scope|phase|municipality|duration|pagecontinua 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
Consentnovo, sem escrita (a fonte é read-only), sem item de nav novo. mode=temamantém a degradação honesta (aviso + busca literal) e a expansão só roda depois do gate.
- Gate do acervo fail-closed (
- O que reavaliar (hipóteses do plano de intenção vs. código):
parseAcervoSource/AcervoSourcehoje vivem no contrato das gravações (recordingListUrl.ts:44,107), mas a fonte nova é do domíniospeech. Importar o vocabulário de fonte derecordingsdentro despeech*criaria ciclo (recordingsjá importaspeechListUrl/speechListFilters). O vocabulário compartilhado do acervo sai parasrc/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).loadSpeechDetailPageDatanão filtraoriginhoje (speechPageData.ts:266-275): um id web em/acervo/[id]renderizaria o player da Câmara quebrado — fechar é parte desta entrega (D3).clearSpeechOmniboxdescarta tudo (speechOmnibox.ts:274-276), inclusive um futurosource: 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
designerantes do markup (D6/§Design). - O artefato não mostra
/acervo/internetcomo rota de lista: a fonte web é?source=internetno mesmo alternador (gate confirmou), e só o detalhe ganha segmento próprio (internet) — como o C199 fez comgravacoes(D3).
Abordagem recomendada
flowchart LR
SW["alternador 3 fontes<br/>?source=internet"] --> URL["speechListUrl (dono)<br/>q|mode|year|topic|scope|municipality|duration + source/sort aditivos"]
URL --> WH["buildSpeechListWhere<br/>origin = source internet ? web : camara"]
WH --> FIND["payload.find speech<br/>user · overrideAccess:false"]
FIND --> VM["WebSpeech*ViewModel<br/>excerpt + plataforma + capa"]
VM --> LIST["WebSpeechResultList<br/>mark · chips · contagem · AcervoSortSelect"]
DETURL["/acervo/internet/[id]"] --> DET["loadWebSpeechDetailPageData<br/>origin web"]
DET --> PLAY["WebSpeechDetailPlayer<br/>Arquivo espelhado · privado"]
PLAY --> ARQ["internet/[id]/arquivo<br/>buildPrivateMediaResponse · Baixar"]
PLAY --> CAPA["internet/[id]/capa<br/>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<br/>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/buildAcervoSourceHrefmovem desrc/utilities/recordings/recordingListUrl.tsparasrc/lib/acervoSource.ts(AcervoSource = 'camara' | 'enviadas' | 'internet',ACERVO_SOURCE_PARAM,parseAcervoSourcecom o mesmo fail-closed,buildAcervoSourceHref). Motivo:speechListUrlprecisa do vocabulário e não pode importar derecordings(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. SpeechListStateganhasource?: 'internet'(ausente = Câmara; o tipo não vira obrigatório para não quebrar as construções existentes).parseSpeechListParamsderivaisWeb = parseAcervoSource(params) === 'internet';source: 'internet'só entra no estado quando o valor é exatamente esse.- Serialização canônica:
source=internetsempre serializado quando o estado é web (é o que seleciona a fonte); a Câmara segue sem o param.sortsó serializa quando web e ≠recentes. - Fase é só da Câmara: com
source=internet,parseSpeechListParamsignoraphase→ o canônico não o carrega →?source=internet&phase=Xredireciona 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 emacervo/page.tsx:200;enviadassegue o branch C199;internetentra 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.tspassa a importar de@/lib/acervoListSort(as gravações mantêm o contrato em bytes:RECORDING_SORT_OPTIONS/RecordingSortKeydeixam de ser exportados porque não há outro consumidor;recordingSortOrderfica no domínio erecordingSortIsDurationdelega aacervoSortIsDuration(state.sort)).speechListUrl.tsganhawebSpeechSortOrder(state)(recentes→-speechAt,duracao_maior→-durationSeconds,duracao_menor→durationSeconds).AcervoSortSelect(components/campaign/shared/AcervoSortSelect.tsx, client) recebestategené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'emcampaignPaths.ts, comcampaignInternetSpeechHref(id)(a URL que o card/detalhe usam), e o detalhe Câmara segue/acervo/<id>. Sem rota de lista/acervo/internet(o alternador aponta?source=internet); um/acervo/internetdigitado cai em[id]com id não-numérico →notFound. loadSpeechDetailPageData(Câmara) passa a consultar{ id: { equals: speechId }, origin: { equals: 'camara' } }→SpeechNotFoundErrorpara 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/<id>(dois segmentos); o detalhe seta o próprio chrome viaSetCampaignPageChrome(precedentegravacoes/[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:findByIDdepth: 1selecionando{ origin, title, mirroredMedia },404seorigin !== '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, servindothumbnailinline (download: false; 404 quando ausente — o placeholder do card decide antes, pela presença do id).webSpeechDownloadFilename({ title, storedFilename })emsrc/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ópriostoredFilenamequando o título é vazio.privateMedia.tsjá considera áudio/vídeo/imagem inline — nada a mudar; oContent-Dispositioncontinua sendo o do dono (filename+filename*).- Cascata/limpeza: nada novo — o
beforeDeletedoSpeech(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 } };phasessó é 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 && canReadCommunicationCatalogantes de qualquer chamada externa);payload.find speechcomwhere: buildSpeechListWhere(state, themeTerms),sort: webSpeechSortOrder(state),selectweb (+searchTextsó quandoq, paramatchedTextSearch/evidência de tema) euser/overrideAccess:false; paginaçãospeechPageSize;loadSegmentsForSpeeches(dono),loadMunicipalityLabelsByIds+municipalityNameMap(donos); sem cortes/VOD/poster (C217 e admin são outros donos). RetornoWebSpeechAcervoPageDataespelhandoSpeechAcervoPageData(rows/state/redirectHref/totalDocs/totalPages/filterOptions/themeUnavailable/themeApplied).loadWebSpeechDetailPageData(payload, user, speechId, query?):findcom{ id, origin: 'web' }edepth: 1(mirroredMedia/thumbnailcommimeType/filename); segmentos viaspeechSegment(mesmo select/sortorder); montaWebSpeechDetailViewModel.loadWebSpeechTitleForActorparagenerateMetadata(leitura só de título, precedente das gravações).speechViewModels.ts:WebSpeechListRecord/WebSpeechDetailRecord+toWebSpeechListItemViewModel/toWebSpeechDetailViewModel, reusandoformatSpeechAt/formatSpeechDuration/municipalityViewModels/topicViewModels(mesmo módulo),pickThemeMatch/pickMatchingSegment/buildHighlightedExcerpt/splitHighlightedParts(speechHighlight/speechSearch—keywordsausente na web é tratado pelo próprio helper) e umwithSeekQuery(href, segment, query)interno extraído para os dois hrefs de detalhe (o atualbuildWatchHrefda Câmara mantém assinatura e bytes; o web ébuildWebSpeechWatchHref).thumbnailUrldo VM web =campaignInternetSpeechCoverHref(id)quandothumbnailexiste; senãonull(o card renderiza o placeholder "SEM CAPA").platformvira{ value, label }viawebSpeechPlatformLabel.
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
designerpara estender o artefato com (1) a variante de player para mídia de áudio (<audio controls>no mesmo box escuro com o badge "Arquivo espelhado · privado") e (2) as cores dos chips de plataforma Instagram e Áudio (e a regra de fallback para plataforma sem cor). O implementador não inventa esses visuais; se o designer não responder, para no ponto (nunca "segue sem"). AcervoSourceToggle(mover decomponents/campaign/recording/paracomponents/campaign/shared/— o alternador agora é do acervo, não das gravações): 3 abas comaria-current,grid w-full grid-cols-3no mobile com rótulos curtos (Câmara | Enviadas | Internet) emd:inline-flexcom os longos (Falas da Câmara | Gravações enviadas | Falas na internet), conforme cenas 01/03.SpeechAcervoFiltersestendido (defaults Câmara):omniboxLabel,omniboxPlaceholder,municipalityFacetLabel('Município citado' na web),showPhase(false na web),filtersAriaLabele omunicipalityLabelrepassado aos builders do omnibox (buildSpeechOmniboxChips/buildSpeechOmniboxSuggestionSeeds) para o chip/sugestão lerem "Município citado". O restante (modo tema, chips, popovers, pending) é o mesmo.- Ajuste no dono do omnibox:
clearSpeechOmniboxpassa a preservarsource: 'internet'(hoje descarta tudo — "limpar" na web cairia na Câmara); o default da Câmara continua{ page: 1 }(pin existente verde). WebSpeechResultCard/WebSpeechResultList(components/campaign/speech/): grid do artefato (thumb/placeholder, chip de plataforma, data · duração, título, trecho comSpeechHighlightParts, chips de tema, "Ver fala"); a capa reusaSpeechResultThumbnail(skeleton/lazy) apontando para/capa; sem capa, placeholder no mesmo slot com o rótulo "SEM CAPA" (o artefato manda o slot fixo; nada de<img>quebrada).WebSpeechDetailPlayer(client,speech/): box escuro com o badge "Arquivo espelhado · privado";<video controls>para mídia de vídeo e<audio controls>para áudio (mesma busca/estado ativo noonTimeUpdate); "Baixar" (downloadHref) e "Abrir na origem" (sourceUrl,rel="noreferrer", oculto quando nulo); box "Origem" (plataforma · canal/autor · "Publicado em "); barra/lista de transcrição clicável com o segmento ativo.- Transcrição clicável sem gêmeo: mover
RecordingTranscriptSegmentButton→components/campaign/shared/CampaignTranscriptSegmentButton.tsxe afinar o tipo do segmento para o contrato estrutural ({ startSeconds, startLabel, parts }), atualizando os 2 imports das gravações (RecordingDetailPlayer,RecordingSpeakerTranscript) — o valor dovariante os bytes são os mesmos (precedente C219 movendoCampaignThemeRetryButtonparashared/). - Página/branches:
AcervoHeader(subtitle com a terceira fonte) + branch web local (WebSpeechSource, comoRecordingsSource) comCampaignListPendingBoundary, barra estendida comkey={buildSpeechFiltersKey(state)}, aviso de tema degradado (SpeechThemeFallbackNotice+CampaignThemeRetryButton),AcervoSortSelect(hint "Falas sem duração aparecem apenas em Mais recentes."),CampaignListResults,WebSpeechResultList,CampaignListEmptyState(vazio honesto com "Limpar busca e filtros" →buildAcervoSourceHref('internet')) eCampaignListFooter("falas na internet encontradas"). O ramo da Câmara (e o das gravações) não muda de bytes. - Copy do chrome:
campaignPageChromeCatalog.acervo.subtitlee o<p>do header passam a citar as três fontes ("Busque nas falas da Câmara, nas gravações da equipe ou nas falas encontradas na internet."). - Detalhe web (
internet/[id]/page.tsx): "← Voltar para Falas na internet" (artefato) →buildAcervoSourceHref('internet');SetCampaignPageChromecom título próprio; estados do artefato (o "sem permissão" é o redirect fail-closed do gate, como no C219; o "carregando" é oloading.tsxdo acervo, que cobre as rotas aninhadas).
D7 — Sem migration, sem Consent, sem escrita.
Decisão: A — read-only: nenhuma collection/campo/action/transação nova; internetSpeechMedia já existe com access próprio (read: canReadSpeech) e os campos web já existem no Speech.
Por quê: a ingestão (C215) é a dona da escrita; esta entrega só lê e serve o que já está catalogado.
Rejeitadas: qualquer migration (nada de schema muda); Consent novo (sem PII nova); rota de escrita/upsert no app (o CLI do C215 é o dono).
Decisões baratas (com gatilho de revisitação):
- Capa sem rota de download dedicada: a rota
capaserve inline (imagem não vira download); se um dia a assessoria pedir "baixar a capa", é o mesmobuildPrivateMediaResponsecomdownload. - Poster da capa no player de vídeo: não especificado no artefato — não fazer; revisitar se o designer estender.
- Extração do ramo da Câmara em componente local (
CamaraSource) quando o arquivoacervo/page.tsxpassar de ~3 branches — extração local, sem módulo novo (gatilho do S5 do C219). municipalityLabelno omnibox por parâmetro opcional, não por cópia do adaptador; se um 3º label aparecer, virar config.- Sem alias
/acervo/internet: o alternador aponta?source=internet; um alias só teria custo de redirect e chrome. - Sem estado de processamento por item (decisão de produto da intenção): a lista web só mostra o que o C215 considera completo; falhas ficam no relatório da ingestão.
Componentes / mudanças
src/lib/acervoSource.ts(novo, puro/client-safe):AcervoSource3-way,ACERVO_SOURCE_PARAM,parseAcervoSource(fail-closed),buildAcervoSourceHref. Sem import de@/utilities(evita ciclo e mantém lib limpa).src/lib/acervoListSort.ts(novo, puro):ACERVO_SORT_OPTIONS+ labels,AcervoSortKey,parseAcervoSort,acervoSortIsDuration.src/lib/webSpeech.ts(editar):webSpeechDownloadFilename({ title, storedFilename }).src/lib/campaignPaths.ts(editar):CAMPAIGN_COMMUNICATION_ACERVO_INTERNET,campaignInternetSpeechHref,campaignInternetSpeechFileHref(com?download=1),campaignInternetSpeechCoverHref.src/lib/campaignPageChrome.ts(editar): subtitle doacervocom a terceira fonte (copy).src/utilities/speech/speechListUrl.ts(editar):source?: 'internet'+sort?: AcervoSortKeyno estado; params aditivos; parse fail-closed e por fonte (fase só Câmara); serialização canônica;webSpeechSortOrder.src/utilities/speech/speechListFilters.ts(editar):buildSpeechFacetWherederivaorigindestate.source(web/camara); fase só Câmara; gatedurationSeconds existsquando sort de duração.buildSpeechListWhereIncludingCutOriginscontinua Câmara-only.src/utilities/speech/speechPageData.ts(editar):loadSpeechFilterOptions(payload, user, origin);loadWebSpeechAcervoPageData,loadWebSpeechDetailPageData,loadWebSpeechTitleForActor;loadSpeechDetailPageDataganhaorigin: camara.src/utilities/speech/speechViewModels.ts(editar): famíliaWebSpeech*(records, list/detail VMs e builders) +buildWebSpeechWatchHref(comwithSeekQueryinterno).src/utilities/speech/speechOmnibox.ts(editar):municipalityLabelopcional nos builders;clearSpeechOmniboxpreservasource.src/utilities/recordings/recordingListUrl.ts(editar): importa source/sort dos libs; mantém o contrato em bytes e os toggles; remove os exports sem consumidor.src/components/campaign/shared/(novos/movidos):AcervoSourceToggle.tsx(movido),AcervoSortSelect.tsx(novo),CampaignTranscriptSegmentButton.tsx(movido e com tipo estrutural).src/components/campaign/speech/(novos):WebSpeechResultCard.tsx,WebSpeechResultList.tsx,WebSpeechDetailPlayer.tsx; (editar)SpeechAcervoFilters.tsxcom as props de copy.- Rotas (novas):
acervo/internet/[id]/page.tsx,acervo/internet/[id]/arquivo/route.ts,acervo/internet/[id]/capa/route.ts; (editar)acervo/page.tsx(dispatch 3 vias + branch web + header/subtitle). - Removidos:
RecordingSortSelect.tsx,RecordingTranscriptSegmentButton.tsx(movido),AcervoSourceToggle.tsx(movido). - Migration: nenhuma (D7).
- Access / Consent: nada muda: gate da página
communicationCataloge das rotascanReadCommunicationCatalog;findByID/findcomuser+overrideAccess:false; semConsent/PII novos; sem rota pública. - UI: Impeccable C — port classe-a-classe de
docs/plans/acervo-fonte-falas-web-ui-design.html(cenas 01–04) com odesignernos triggers (a) (áudio + chips Instagram/Áudio) e (c) (crítica final no fechamento). Shells reusados:CampaignListOmnibox/CampaignSearchModeControl/CampaignHeaderFilterPopover/CampaignListPending*/CampaignListEmptyState/CampaignListFooter/CampaignListPagination.
Dados → forma (se aplicável)
- N/A por decisão da intenção (
acervo-fonte-falas-web.md:50): resultados de busca e facetas são recorte editorial, não métrica; nenhum score de relevância/similaridade é exposto (e a contagem é paginação, não vaidade). Nada a apresentar; nenhuma forma nova.
Fases verificáveis
- Tracer / contrato + server (~1–1,5 dia).
src/lib/acervoSource.ts,src/lib/acervoListSort.ts;speechListUrl(source/sort);speechListFilters(origin/gates);recordingListUrlmigrando para os libs;speechPageData(opções por origem + loaders web + blindagem do detalhe Câmara);speechViewModels(VMs web);campaignPaths;webSpeechDownloadFilename.- Prova:
tests/unit/acervoSource.unit.spec.ts(novo: 3-way fail-closed + hrefs),tests/unit/acervoListSort.unit.spec.ts(novo), extensões despeechListUrl.unit.spec.ts(source/sort web + pins da Câmara intocados + fase ignorada na web),speechListFilters.unit.spec.ts(origin por estado, gate de duração),speechOmnibox.unit.spec.ts(clear preservando source, label),speechViewModels.unit.spec.ts(VMs web),webSpeech.unit.spec.ts(nome de download), ajuste de imports derecordingListUrl.unit.spec.ts;tests/int/webSpeechAcervo.int.spec.ts(novo: lista só web/facetas/tema injetado/paginação/opções; detalhe web; id web 404 no detalhe Câmara; id Câmara 404 no detalhe web; capa ausente; filename do download).
- UI port (~1–1,5 dia).
- Primeiro: dispatch do
designer(Passo 3d, trigger (a)) para a variante de áudio + cores de chip Instagram/Áudio; só depois do retorno, o markup dessas peças. AcervoSourceToggle3 fontes;SpeechAcervoFilterscom copy;AcervoSortSelect;CampaignTranscriptSegmentButtonshared;WebSpeechResultCard/WebSpeechResultList/WebSpeechDetailPlayer; rotasinternet/[id](+arquivo/capa); página com dispatch e estados.- Prova:
tests/e2e/campaignSpeechAcervo.e2e.spec.tsestendido (describes novos: switcher de 3 fontes; lista web com chip de plataforma/mark/contagem; faceta estreitando + vazio honesto; detalhe com player/transcrição/Baixar/Abrir na origem/atribuição;arquivo/capasó para papéis do acervo, com range, download nomeado e 404 paraadvisor; fala web invisível na Câmara e 404 em/acervo/<id>); fixtures de mídia real (bytes válidos, padrão detests/int/webSpeechIngest.int.spec.ts);pnpm devmanual contra as cenas 01–04.
- Primeiro: dispatch do
- Gates / fechamento (~0,5 dia).
scripts/lib/e2e-affected-manifest.mjs(entry do acervo ganhasrc/lib/acervoSourceesrc/lib/acervoListSort— hoje um diff nesses arquivos cairia no smoke da home);pnpm gate:fast; depois a cascata cheia (pnpm lint,pnpm format:check,tsc --noEmit,pnpm exec knip,pnpm check:cycles— ciclorecordings ↔ speeché o risco nomeado,pnpm test,pnpm build);pnpm test:e2e:affected; crítica dodesigner(trigger (c)) comDesign tierno PR;pnpm push; entrada emdocs/changelog/<data>-c216.md.
Quota: ~3–4 dias eng, dentro do appetite herdado. Se apertar, os cortes são o municipalityLabel (chip da web lê "Município" como a Câmara — divergência de copy) e o placeholder "SEM CAPA" (card sem thumb) — nunca a blindagem do detalhe Câmara, o gate das rotas de mídia nem os pins de bytes da Câmara.
Rabbit holes / Não escopo (engenharia)
- Ingestão/descoberta/download/transcrição/classificação (C215/C218); skill de atualização (C218).
- Cortes e links compartilháveis a partir da fala web (C217);
SpeechCut/origens de corte para web. - Diarização, edição de transcrição, publicação externa; geração de thumbnail (só a da origem); transcode/masters; signed URLs.
- Merge multi-fonte/busca global única; item de nav novo; mexer no feed do site ou na Central de Conteúdos (C211).
- Abrir a web em
speechCoverage/findSpeechExcerpts/Sollinha (a blindagem C215 mantémorigin: camaralá; C216 é só a fonte do acervo). - Segundo cadastro de pessoa (canal/autor é texto de origem); tornar
channelumContact. - Qualquer mudança de contrato/comportamento da Câmara (lista, barra, fase, detalhe, cortes, poster, VOD) — o teste de bytes é o pino.
loadSpeechFilterOptionscom cache/prefetch; SSR de áudio/vídeo playback (browser é território do browser; o e2e só assere o contrato HTML).- Alias de rota
/acervo/internet(lista) — não existe por design.
Riscos e mitigação
- Regressão da Câmara por estender componentes/contratos compartilhados. Os params são aditivos e a serialização da Câmara não muda (pins de
speechListUrl.unit.spec.tsintocados); as props novas da barra têm default = Câmara e o call site da Câmara não passa nada; os ints/e2e da Câmara continuam verdes. Qualquer diferença de bytes no?q=/?t=/detalhe é bug de PR, não débito. - Ciclo
recordings ↔ speechao mover o vocabulário de fonte. Mitigado por colocar fonte/sort emsrc/lib/*(lib não importa@/utilitieshoje; verificado);pnpm check:cyclesno gate e no CI. - Rota nova engolindo/colidindo com o
[id]da Câmara ou com o chrome. Segmento estáticointernetvence o dinâmico em/acervo/internet/<id>; o regex do chrome não casa o detalhe (2 segmentos) e o detalhe seta o próprio chrome — comogravacoes/[id]. - Vazamento de mídia privada. Só rotas sob
/campanhacom o gate do catálogo;origin !== 'web'→ 404; semmediapública; toda negação é 404 silencioso (não confirma existência). - Nome de download hostil (título com aspas/CRLF/tamanho). Helper puro sanitiza, preserva a extensão e cai no nome armazenado;
privateMediaContentDispositionjá emitefilename*(RFC 5987) — teste unitário e e2e do header. - Sort de duração com linhas sem duração. Gate
durationSeconds existsnowhere(o Postgres ordena NULLS FIRST) + hint no controle, como no C219; int cobre. - Barra web com rótulo errado/
sourceperdido ao limpar.clearSpeechOmniboxpreservasource(unit pina);municipalityLabeldefault mantém a Câmara. - Mídia de áudio no artefato (variante nova) sem desenho. Trigger (a) do
designerantes do markup; sem resposta, o trabalho para — não se inventa player/chip. - Fixture e2e com mídia real. Seguir o padrão do int do C215 (MP4/MP3/JPEG mínimos válidos) para o range/download exercitarem bytes de verdade.
- Manifest e2e desatualizado.
src/lib/acervoSource/src/lib/acervoListSortentram no entry da vertical;campaignSpeechAcervojá é o spec mapeado e já está no curado (OPS86), então o PR nunca roda zero e2e. payload-types/migration: nada muda (sem schema) — nenhum HARD-STOP de migration nesta entrega.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto: alternador de 3 fontes com
aria-current; busca/destaque emode=termo|tema; facetas ano/tema/alcance/duração/municípios citados; contagem e paginação; chip de plataforma; capa ou placeholder; detalhe com player privado, transcrição clicável (clique posiciona), "Baixar", "Abrir na origem" e atribuição (plataforma + canal/autor + data); estados vazio/carregando/sem permissão; mobile. - Invariantes AGENTS/engineering-standards:
user+overrideAccess: falseem toda leitura; mídia nunca pública;advisor/leaderfail-closed (404); semConsent/PII/segundo cadastro; sem item de nav; sem escrita/transação nova; copy pt-BR / identificadores em inglês; semopenai/*fora dodesigner(OPS123). - Contrato da Câmara congelado: pins de
speechListUrl/speechListFilters/speechOmnibox/speechViewModels/speechAcervo.int/e2e da Câmara verdes sem adaptação de bytes. - Blindagem do detalhe Câmara (
origin: camara) com int provando 404 para id web; fala web invisível na lista/opções/Câmara. - Testes de domínio previstos: unit (source/sort/URL/filtros/omnibox/VMs/filename), int (
webSpeechAcervo), e2e (switcher/lista/detalhe/rotas de mídia/gates). - S4 do C215 fechado:
dispositionFilenamederivado do título no download da fala web. - Manifest e2e atualizado (
src/lib/acervoSource,src/lib/acervoListSortna vertical). - Design: artefato estendido pelo
designer(áudio + chips Instagram/Áudio) antes do markup e crítica final (trigger c) registrada comoDesign tierno PR;DEGRADED⇒ para antes do push (em--auto: sem PR, Issueblocked). - Gates:
tsc --noEmit,pnpm lint(0 warnings),pnpm format:check,pnpm exec knip,pnpm check:cycles,pnpm test,pnpm build,pnpm test:e2e:affected;pnpm push; entrada emdocs/changelog/.
Simplify — triage dos revisores (2026-09-24)
Aplicados na sessão (não reabrir): extração de withSeekQuery (os dois hrefs de detalhe), inline de acervoSortIsDuration no gate de duração (wrapper webSpeechSortIsDuration removido), remoção de exports sem consumidor (AcervoSortState, RawAcervoSourceParams, TranscriptSegment, className do pill, externalId morto no select, as const redundante), loadWebSpeechMediaForActor compartilhado pelas rotas arquivo/capa, reuso de loadSegmentsForSpeeches no detalhe web, hoist de webSourceParams, speechHasActiveFilters nos dois ramos da página, extração de CamaraSource (gatilho do S5 do C219 vencido) e WebSpeechesSource; e2e com extensão coerente de áudio e sem asserts-isca. Também corrigido no e2e um bug real da fronteira server→client (o sort recebia toHref função): o AcervoSortSelect escolhe o serializador pelo source do estado dentro do client component.
| ID | Resumo | Origem | Score | Tipo | Destino |
|---|---|---|---|---|---|
| F1 | Gate fail-closed das rotas de mídia privada copiado em 4–5 rotas (as duas novas já compartilham) | simplify/reuse | 4 | expensive_lock | registrar — docs/plans/escala-dry-pos-c216.md F1 (Issue nova depends: [1292]) |
| F2 | Scaffold de resultados repetido nos três ramos da página do acervo | simplify/reuse | 3 | cheap_polish | registrar — mesmo lote, F2 |
| F3 | Excerpt/chips duplicados entre SpeechResultCard e WebSpeechResultCard |
simplify/reuse | 2 | cheap_polish | registrar — mesmo lote, F3 (gatilho antecipado: 3º card de fala) |
| D1 | speechHasActiveFilters × recordingHasActiveFilters (domínios diferentes) |
simplify/reuse | 2 | defer_trigger | defer — gatilho: 3ª lista com predicado de filtros ativos ou divergência de param entre estados |
| D2 | Bytes MP4/MP3 de fixture copiados entre os int specs de fala web (2 sites) | simplify/reuse | 2 | defer_trigger | defer — gatilho: 3º spec de fala web precisar dos bytes |
| D3 | firstParam idêntico em acervoSource/acervoListSort (2 linhas) |
simplify/quality | 1 | cheap_polish | descartar (camadas impedem dono comum; custa mais que a cópia) |
Crítica final do designer (trigger c, 2026-09-24)
certificado (tier primário — openai/gpt-5.6-sol). A primeira crítica encontrou 5 bloqueantes mecânicos (painel desktop da barra, ordem das facetas da web, atribuição inline no mobile, marcador ativo do segmento, vazio da cena 04) + 1 não-bloqueante (play overlay na capa); todos aplicados e reconfirmados sem bloqueante novo. O mobile do omnibox segue o B184 do shell (barra sticky sem borda) — a cena 03 do artefato foi corrigida pelo próprio designer para refletir o shell, sem fork. O play overlay e a variante web do segmento (border-l-2 border-primary) são o delta visual desta entrega na superfície compartilhada.
Self-score de decision-quality
4,5/5.
- Decisões caras com rejeitadas: as 7 decisões (contrato/fonte, ordenação, rota do detalhe + blindagem, mídia privada + nome de download, loaders/VMs, UI/barra, sem migration) têm opções, recomendação e rejeitadas ancoradas em contrato/código (
recordingListUrl.ts:44,107, ciclorecordings → speech,speechPageData.ts:266-275sem origin,speechOmnibox.ts:274-276descartando source, defer S4 do C215, C219 rejeitando generalização precoce do contrato). As baratas ficam com gatilho explícito. - Cabe no appetite: ~3–4 dias herdados; tracer no contrato+server antes do port de UI; sem infra/provedor/migration novos; reusa classificador de tema, segmentos, labels, shells e o dono de I/O privado. Os dois cortes de contenção estão nomeados.
- Rabbit holes nomeados: cortes/C217, descoberta/C218, merge multi-fonte, segundo cadastro, transcode/masters, thumbnails, signed URLs, abrir a web em cobertura/Sollinha, alias de lista.
- Depth check: reusa
buildPrivateMediaResponse/resolvePrivateMediaStaticDir,loadSegmentsForSpeeches,loadMunicipalityLabelsByIds,SpeechHighlightParts/speechHighlight,CampaignList*,CampaignSearchModeControl,useCampaignListFilterNavigation; extrai os dois vocabulários para lib pura (3º consumidor prometido no C219) e move parashared/só o que ganhou 3º consumidor, em vez de criar gêmeos. - Intenção preservada: a engenharia não reescreveu o produto — a terceira fonte entra no mesmo acervo, com a mesma busca/filtros, detalhe com player/download/origem e mídia privada; o que ficou de fora (cortes, descoberta, merge, edição) é exatamente o que a intenção cortou.
Perde 0,5 porque uma superfície visual (player de áudio + chips Instagram/Áudio) depende de extensão do artefato pelo designer antes do markup: o plano declara o gate e a parada em vez de inventar o visual — e a execução em --auto fica condicionada a esse retorno.