31 KiB
Impl: Acervo: encontrar cortes — na navegação, na fala e na busca
Status: aprovado Atualizado em: 2026-09-16 Issue: #1084 Intenção: docs/plans/acervo-encontrar-cortes.md Appetite restante: herdado (~1,5–2 dias eng)
Leitura da intenção
- Outcome: a assessoria reencontra um corte existente e o reaproveita pelos três caminhos — subitem de nav (biblioteca a um clique), seção "Cortes desta fala" no detalhe da fala, e cortes aninhados sob a fala na busca do acervo — sem recortar de novo. É descoberta e reuso; nenhum modelo de dados novo.
- O que NÃO negociar: gate
speechCatalog(communicator/coordinator/candidatepassam;advisor/leaderfail-closed); corte sempre pertence a uma fala (FKSET NULL, UI degrada); corte não vira grupo/lista separada na busca; nenhum internals de corte (media/status cru/error) vaza;/corte/<id>público intocado; semConsent, sem collection, sem migration. - O que reavaliar: (a) o C168 não deixou item de nav nem breadcrumb — o dono não achou a edição (D9 do C168: só CTA na lista + chrome); a árvore
CampaignNavItemé plana e não tem subitens; (b) não existe seção de cortes no detalhe da fala nem query cortes-por-fala; (c) a busca só cobrespeech.searchText/keywords— corte não casa; (d) o "Selecionar trecho" é interno ao player (SpeechDetailPlayer.tsx:449-461), sem trigger externo.
Abordagem recomendada
flowchart TD
subgraph nav["D1/D5/D7 — descoberta pela navegação"]
NAV[nav.ts: CampaignNavItem.subItems em Comunicação] --> SIDEBAR[CampaignSidebar: SidebarMenuSub]
NAV --> OVERFLOW[CampaignBottomNav: drawer mobile flatten]
end
subgraph speech["D2/D4 — descoberta pela fala de origem"]
SPEECH[acervo/id/page.tsx] --> GATE[requireCampaignPageActor gate speechCatalog]
GATE --> LOADSPEECHCUTS[loadSpeechCutsForSpeech: speechCut where speech=id depth 0 sort -createdAt]
LOADSPEECHCUTS --> VM[toSpeechCutViewModel]
VM --> SECTION["Cortes desta fala" populado/empty]
SECTION -->|empty CTA| CTA[SpeechExcerptCta → evento → SpeechDetailPlayer.requestSelection]
end
subgraph search["D3 — descoberta pela busca"]
ACERVO[acervo/page.tsx] --> LOADACERVO[loadSpeechAcervoPageData]
LOADACERVO --> BATCH[loadSpeechCutsForSpeeches: where speech in ids]
BATCH --> NESTED[SpeechResultList/SpeechResultCard: cutsBySpeech aninhado]
LOADACERVO -->|opção B, GATE| CUTQUERY[speechCut title/description like q depth 1]
CUTQUERY --> MERGE[merge dedupe por speech id → card de origem mínimo]
end
Opções consideradas:
- A — Estender a árvore de nav com subitens estáticos + aninhar cortes na fala/busca (dono único de cada concern).
- B — Tipo dedicado
CommunicationNavItempara a vertical + renderer paralelo. - C — Abas Falas|Cortes na home da Comunicação + grupo "Cortes" separado na busca.
Recomendação: A — a navegação vira dado aditivo (subItems), então o mesmo nav.ts alimenta os dois consumidores (sidebar desktop/sheet e drawer mobile) sem duplicar verdade; a seção da fala e a busca reusam os loaders/VM do C168/C167 (toSpeechCutViewModel, SpeechCutStatusBadge, loadSpeechCutsForSpeeches sobre speech index). A árvore plana de hoje não expressa hierarquia e o pin getCampaignNav('communicator') é sobre os hrefs de topo (campaignNav.unit.spec.ts:68), preservado por uma extensão aditiva.
Rejeitadas: B porque um tipo/renderer dedicado à vertical fura a fonte única do nav.ts e obriga a manter dois caminhos de render (o vazamento que queremos evitar); C já rejeitada em produto — o acervo é facetado (speechListFilters.ts:34-57) e a biblioteca é só q+paginação (speechCutListUrl.ts), e o grupo "Cortes" recria a lista paralela que motivou o pedido.
Decisões de engenharia (caras de reverter)
D1 — Forma da árvore de nav (subitens estáticos).
Opções: A) adicionar subItems?: readonly CampaignNavSubItem[] a CampaignNavItem, com CampaignNavSubItem = { title; href } (sem ícone), e renderizar em CampaignSidebar (via SidebarMenuSub*) + CampaignBottomNav (drawer) | B) tipo dedicado CommunicationNavItem + renderer próprio | C) "grupo" separado, como staffSecondaryNav (Conceitos).
Recomendação: A — a extensão é aditiva e rasa: icon continua obrigatório no item de topo (nenhum <item.icon /> precisou de narrowing), e o sub-item segue sem ícone como no design (CENA 01: dois links indentados, só texto). Nomear o campo subItems (não children) evita colidir com a prop JSX children que o CampaignSidebarLink já usa para os saved-filters (CampaignSidebar.tsx:45-66). O pin expect(getCampaignNav('communicator').map(i => i.href)).toEqual([CAMPAIGN_COMMUNICATION_HOME]) (campaignNav.unit.spec.ts:65-72) permanece verde — ele só lê o nível de topo. Os dois consumidores leem a mesma estrutura: a sidebar renderiza SidebarMenuSub/SidebarMenuSubItem/SidebarMenuSubButton (Sidebar.tsx:450-514) sob o item; o drawer mobile "achata" o pai + subItems indentados. Sem estado de expansão (os dois sub-links são sempre visíveis nas cenas 1–4; o caret é decorativo): não há disclosure controlado, sem client state novo.
Alternativas rejeitadas: B porque duplica o render da nav e fura a fonte única; C porque o design pede filhos abaixo do pai com caret, não um grupo no pé (esse é o padrão do Conceitos) — e "grupo" não dá o ativo por prefixo do pai.
Ativo do pai: isCampaignNavActive já cobre /campanha/comunicacao/* por prefixo (nav.ts:126); o filho ativo usa o mesmo predicado no href do sub-item. Nada muda no helper.
D2 — Loader cortes-por-fala + VM.
Opções: A) loadSpeechCutsForSpeech(payload, user, speechId) em speechCutPageData.ts, where: { speech: { equals: speechId } }, depth: 0, sort: '-createdAt', user+overrideAccess: false, mapeando por toSpeechCutViewModel | B) query direto no componente da página | C) novo domínio utilities/speechCut/.
Recomendação: A — o relationship speech é index (SpeechCut.ts:71-81), então o filtro é barato; depth: 0 basta porque na página da fala a origem é a própria página (não precisamos do speech expandido) e as ações só precisam de id/title/status/durationLabel. Por isso o VM é o toSpeechCutViewModel (speechCut.ts:277-304) e não o toSpeechCutLibraryItemViewModel (:342), que assume speech expandida e cairia em origin: null — usar o library VM aqui seria carregar um contrato que a seção não renderiza. Limite: limit: 0, pagination: false (todos os cortes daquela fala, mais recentes primeiro) — o número de cortes por fala é limitado pelo esforço humano; sem teto/paginação no v1, com gatilho de revisão se uma fala passar de ~50 cortes. select enxuto (sem media/error/createdBy).
Alternativas rejeitadas: B porque query no componente sem user+overrideAccess:false fura o access e é intestável; C porque o C167/C168 já são donos (utilities/speech/) — módulo gêmeo é exatamente o anti-padrão "edite o dono, não gema um irmão".
D3 — Busca: aninhamento sem grupo separado + a questão B (corte casa, fala não casa).
Opções: A (núcleo, obrigatório) — cortes aninhados sob a fala que já casou na busca de falas: batch loader loadSpeechCutsForSpeeches(payload, user, ids) com where: { speech: { in: ids } } (precedente loadSegmentsForSpeeches, speechPageData.ts:110-141), agrupado num Map<number, SpeechCutViewModel[]> consumido por SpeechResultList/SpeechResultCard | B (aberta, design CENA 05) — 2ª query speechCut casando title/description like q, depth: 1 para trazer a fala de origem, fundida na mesma lista | C grupo "Cortes" separado. Recomendação: A entra com certeza (é o aceite literal da intenção). B fica como decisão de produto a confirmar no GATE — a intenção e o design (CENA 05) recomendam incluir; o plano deixa B isolado numa etapa própria que pode ser dropada sem tocar o núcleo. Se confirmada, B implementa-se assim: loadSpeechCutsMatchingQuery(payload, user, q) (where: { or: [{ title: { like: q } }, { description: { like: q } }] }, depth: 1, overrideAccess: false) → agrupa por speech.id → dedupe por id: se a fala já está na página de resultados, não adiciona card novo e apenas mescla o corte no cutsBySpeech; se não está, acrescenta um card de origem mínimo (CENA 05: "Fala de origem · data · tipo", a nota honesta "A fala não contém o termo exato; ela aparece porque um corte vinculado corresponde à busca.", "Ver fala" e o corte aninhado). O card mínimo é um VM próprio (SpeechCutOriginResultViewModel) e não um SpeechListItemViewModel fabricado — o speech a depth 1 no corte não traz searchText/segmentos/excerpt para montar o card padrão honestamente. O rodapé "N falas encontradas" continua contando as falas do resultado canônico; quando B adiciona origens, o rótulo acompanha (a confirmar com produto junto com B).
Alternativas rejeitadas: C porque recria a lista paralela (anti-goal explícito); em B, reusar SpeechResultCard para a origem porque exigiria inventar excerpt/segmentos a partir de dados parciais — dado fabricado é pior que um card mínimo honesto.
D4 — CTA "Selecionar trecho" do vazio.
Opções: A) SpeechExcerptCta (client) que emite uma intenção (evento) e rola o player para a vista; o SpeechDetailPlayer (dono do estado selection) escuta e chama requestSelection() (liga a seleção, idempotente) | B) anchor href="#speech-player" que só rola; a pessoa ainda clica no botão do player | C) mover o estado de seleção para o servidor / renderizar o toggle no lado server.
Recomendação: A — o estado de seleção mora no player (SpeechDetailPlayer.tsx:137, toggleSelection em :255); a ponte não duplica lógica, só dispara a ação que o player já tem. Contrato nomeado (SPEECH_EXCERPT_REQUEST_EVENT, junto de src/lib/speechExcerptSelection.ts), useEffect no player adiciona/remove o listener e chama um requestSelection() que liga a seleção quando null (distinto do toggle, que alterna); o CTA renderiza o Button com a copy literal e faz scrollIntoView do [data-slot="speech-player"]. É testável por unit (o contrato/reducer do CTA) sem browser.
Alternativas rejeitadas: B porque entrega só metade do design (o botão grande do vazio seria só um scroll, exigindo dois cliques); C porque sobe estado de um player autônomo para a página sem necessidade e acopla RSC a uma interação local.
D5 — Chrome/breadcrumb e o "não encontrei a edição".
Opções: A) nav com subitens (D1) + confirmar o chrome existente + manter o "Voltar aos cortes" do detalhe | B) redesenhar breadcrumbs na vertical | C) criar rota nova de biblioteca.
Recomendação: A — a causa do "não encontrei" era ausência de item de nav (D9 do C168) e nenhuma pista na fala; o chrome da biblioteca já está correto desde o C168: a regra exata pathname === CAMPAIGN_COMMUNICATION_CORTES (campaignPageChrome.ts:293) vem antes da regex genérica do detalhe do acervo (:297), e a regex ^/campanha/comunicacao/acervo/[^/]+$ não captura cortes/[id] (dois segmentos), então o detalhe do corte resolve null e seta o próprio título via SetCampaignPageChrome (cortes/[id]/page.tsx:65) — igual ao detalhe da fala. O detalhe do corte já tem "Voltar aos cortes" (:68-73). O que entra é a subárvore de nav (e o subitem "Biblioteca de cortes" ativo). Ordem das regras inalterada.
Alternativas rejeitadas: B porque o produto não pediu breadcrumb completo e a vertical não tem trilha hoje; C porque a rota já existe e o problema é descobribilidade, não a rota.
D6 — Testes e manifest.
Opções: A) unit de nav/chrome + int do loader cortes-por-fala/nested + e2e do gate e da seção | B) só int | C) e2e de browser para o CTA e o aninhamento.
Recomendação: A — unit: campaignNav.unit.spec.ts (communicator tem subItems com os dois caminhos; pai ativo quando o filho /acervo/cortes está ativo; overflow de staff inclui os subitens de Comunicação) e campaignPageChrome.unit.spec.ts (lista cortes resolve título "Cortes"; /acervo/cortes/42 resolve null). int: speechCut.int.spec.ts ganha loadSpeechCutsForSpeech (todos os cortes da fala, -createdAt, VM depth-0 sem vazar error; vazio quando não há corte; fail-closed para advisor/leader) e o nested da busca (mapa por fala; em B, merge/dedupe por id). e2e HTTP (campaignSpeechCut.e2e.spec.ts): a página da fala renderiza "Cortes desta fala" com o título do corte aninhado; a biblioteca segue 200 para communicator e fail-closed para advisor/leader (já coberto hoje — o manifesto dispara pelo mesmo prefixo). O manifest NÃO muda: nav → src/components/campaign/shell já mapeia para campaignPermissionProfileHttp (e2e-affected-manifest.mjs:290); seção/lista/busca ficam em (campaign)/.../comunicacao, src/components/campaign/speech, src/utilities/speech, src/lib/speech (:305-313).
Alternativas rejeitadas: B porque nav/chrome são contratos puros baratos e a regressão do "não achei" é exatamente de navegação; C porque o CTA é um evento testável em unit e o e2e de browser exigiria player real sem ganho sobre o HTTP+unit.
D7 — Mobile.
Opções: A) subitens no sheet da sidebar para communicator + subitens no drawer "Mais" para staff, sem tocar nos 5 slots | B) novo slot no bottom nav | C) esconder a biblioteca no mobile.
Recomendação: A — o bottom nav é fixo em 5 itens (nav.ts:138-146; pin campaignNav.unit.spec.ts:97-112) e não muda. communicator não é staff, então CampaignSidebar monta no mobile (CampaignSidebar.tsx:77) e o sheet offcanvas mostra "Comunicação" com os subItems (D1). coordinator/candidate são staff: no mobile a sidebar não monta e getCampaignOverflowNav inclui "Comunicação" no drawer (nav.ts:156-160) → o drawer passa a renderizar o pai e os subItems indentados a partir da mesma estrutura. A seção da fala e o resultado aninhado já são responsivos (CENA 06 empilha; classes existentes).
Alternativas rejeitadas: B porque fura o pin dos 5 slots e o bottom nav é navegação primária, não atalho de vertical; C porque o pedido é justamente o contrário (tornar achável).
Componentes / mudanças
Constantes, nav e chrome:
src/lib/campaignPaths.ts— sem constante nova de rota; adicionarcampaignSpeechCutDetailHref(id)(${CAMPAIGN_COMMUNICATION_CORTES}/${id}) e usá-lo emSpeechCutLibraryCard.tsx:47e no bloco aninhado — hoje o path do detalhe é hardcoded e passaria a ter 2+ call sites.src/components/campaign/shell/nav.ts—CampaignNavSubItem = { title; href };CampaignNavItem.subItems?;subItemsno item Comunicação destaffNav:70ecommunicatorNav:98→[{ 'Acervo de falas', CAMPAIGN_COMMUNICATION_ACERVO }, { 'Biblioteca de cortes', CAMPAIGN_COMMUNICATION_CORTES }]. Nenhum outro item ganha subitem.src/components/campaign/shell/CampaignSidebar.tsx— renderizaritem.subItemscomSidebarMenuSub/SidebarMenuSubItem/SidebarMenuSubButton(filho ativo com fundo branco + ring como no design), preservando oschildrende saved-filters (:109-114).src/components/campaign/shell/CampaignBottomNav.tsx— no drawer de overflow, renderizar pai +subItemsindentados (mesma estrutura denav.ts), fechando o drawer ao navegar.src/lib/campaignPageChrome.ts— sem mudança (catálogocortes:124-127e regra:293corretos; ordem inalterada).src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx— mantém o CTA "Biblioteca de cortes" (produto recomendou manter como atalho redundante); a busca passa a renderizar cortes aninhados (D3).
Domínio (utilities/server) — dono único, sem twinning:
src/utilities/speech/speechCutPageData.ts—loadSpeechCutsForSpeech(payload, user, speechId): Promise<SpeechCutViewModel[]>(D2);loadSpeechCutsForSpeeches(payload, user, ids): Promise<Map<number, SpeechCutViewModel[]>>(D3-A, precedenteloadSegmentsForSpeeches); em B,loadSpeechCutsMatchingQuery(payload, user, q)com os cortes +speechadepth: 1.src/utilities/speech/speechPageData.ts—loadSpeechAcervoPageDatapré-carrega o mapa de cortes dos ids da página e devolvecutsBySpeech; em B, funde as origens dos cortes que casaram (dedupe porspeech.id).
Componentes de UI (em src/components/campaign/speech/):
SpeechCutsForSpeechSection.tsx— cabeçalho "Cortes desta fala"; populado = lista (Título · Status · Duração · ação "Abrir corte") reusandoSpeechCutStatusBadge; vazio = título "Nenhum corte desta fala ainda", descrição "Selecione um trecho no player para criar o primeiro corte." e CTA "Selecionar trecho".SpeechExcerptCta.tsx(client) — disparaSPEECH_EXCERPT_REQUEST_EVENT(contrato emsrc/lib/speechExcerptSelection.ts) e rola até o player (D4).SpeechDetailPlayer.tsx—useEffectde listener →requestSelection()(liga a seleção quandonull); otoggleSelectioninterno e odata-slot="speech-excerpt-toggle"permanecem.- Bloco compacto do corte aninhado (status + duração + título + "Abrir corte →"), reusado na seção da fala e no resultado da busca; a fala só lista e linka (edição segue no C168).
SpeechResultList.tsx/SpeechResultCard.tsx— nova propcutsBySpeech: ReadonlyMap<number, readonly SpeechCutViewModel[]>; o card renderiza "N corte(s) desta fala" + o bloco aninhado quando a fala tem ≥1 corte. Em B, card de origem mínimo (SpeechCutOriginResultCard.tsx) para a origem que aparece só por corte.src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx— nova seção<SpeechCutsForSpeechSection>full-width após o grid (:208), recebendo os cortes deloadSpeechCutsForSpeech.
Migration / Access / Consent:
- Sem migration, sem collection, sem
Consent— nenhum schema muda. - Access: inalterado. Gate
requireCampaignPageActor({ gate: 'speechCatalog' })(campaignPageActor.ts:74) na página da fala e na busca (já existente); toda leitura nova rodapayload.findcomuser+overrideAccess: false(a matriz docanReadSpeechCut,access/speeches.ts:40-44, é a fronteira). - UI: Impeccable C (shape→craft→critique→polish) sobre as cenas novas/alteradas (nav + seção da fala + resultado aninhado); shells existentes (
CampaignPageShell,CampaignList*) reusados, sem shell novo.
Mapeamento das cenas do design
| Cena | Conteúdo | Componente |
|---|---|---|
| CENA 01 — nav subitems desktop | Comunicação com caret; "Acervo de falas" e "Biblioteca de cortes"; filho ativo com fundo branco + ring | nav.ts (subItems) + CampaignSidebar (SidebarMenuSub*) |
| CENA 02 — fala, "Cortes desta fala" populado | Título · Status · Duração · "Abrir corte" (4 estados) | SpeechCutsForSpeechSection + SpeechCutStatusBadge + campaignSpeechCutDetailHref |
| CENA 03 — fala, vazio | título + descrição + CTA "Selecionar trecho" | SpeechCutsForSpeechSection (empty) + SpeechExcerptCta |
| CENA 04 — busca, fala com corte aninhado | card da fala + "1 corte desta fala" + bloco do corte com "Abrir corte →" | SpeechResultList/SpeechResultCard (cutsBySpeech) + bloco aninhado |
| CENA 05 — busca, corte casa e fala não (opção B) | card de origem mínimo + nota honesta + corte correspondente | SpeechCutOriginResultCard + loadSpeechCutsMatchingQuery (produto no GATE) |
| CENA 06 — mobile | resultado aninhado + seção da fala empilhados | mesmos componentes (classes responsivas) + subitens no sheet/drawer (D7) |
| CENA 07 — biblioteca (destino) | glimpse do destino já existente (C168) | sem mudança de tela; alcançável pela nav (D1/D5) |
Dados → forma
- Fonte: collection
speechCutfiltrada porspeech(index);depth: 0na seção da fala (a origem é a página) e no aninhamento da busca (a origem é o card da fala);depth: 1apenas na query da opção B (para o card de origem mínimo). Sem agregação, sem KPI, sem gráfico. - DTO da seção:
SpeechCutViewModel(id, title, status + label, durationLabel, start/end) —mediaUrl/youtubeVideoIdpodem vir nulos adepth: 0e não são renderizados aqui. - DTO do aninhamento: o mesmo
SpeechCutViewModel, agrupado porspeech.idnoMap— o card da fala passa os cortes daquela fala. - Contagem honesta: "N cortes" na seção; "N corte(s) desta fala" no aninhamento; sem percentual/taxa.
Fases verificáveis
- Árvore de nav + subitens (D1/D5/D7):
subItemsemnav.ts; render emCampaignSidebare no drawer deCampaignBottomNav; helpercampaignSpeechCutDetailHref. Prova:pnpm test:unit(pins de nav/chrome) +pnpm gate:fast. - Seção da fala (D2/D4):
loadSpeechCutsForSpeech+SpeechCutsForSpeechSection(populado/vazio/CTA) +SpeechExcerptCta/bridge no player + a seção na páginaacervo/[id]. Prova: int novo emspeechCut.int.spec.ts(loader depth-0 sem vazarerror, ordenação, vazio, fail-closed) +pnpm gate:fast. - Busca com aninhamento (D3-A):
loadSpeechCutsForSpeeches+cutsBySpeechemspeechPageData/loadSpeechAcervoPageData+SpeechResultList/Cardaninhando. Prova: int do mapa por fala +pnpm gate:fast. - Opção B (D3, condicionada ao GATE):
loadSpeechCutsMatchingQuery+ dedupe/merge + card de origem mínimo. Prova: int de dedupe (fala que já casou não duplica) e da origem que aparece só por corte; se produto deferir, a fase vira no-op e o núcleo permanece verde. - E2E + polimento:
campaignSpeechCut.e2e.spec.ts(página da fala mostra "Cortes desta fala" com o corte; rotas internas fail-closed por papel — hoje já cobertas); Impeccable C sobre as cenas; changelogdocs/changelog/2026-09-16-c174.md;pnpm push. Prova:pnpm gate:fast+ e2e selecionado.
Rabbit holes / Não escopo (engenharia)
- Reusar
SpeechCutResultCard.tsx:33— hardcoda "Corte publicado" e é session-only; a seção/busca têm card próprio por status (SpeechCutStatusBadge). Não tocar. searchText/índice/migração emspeechCut— fora; vira item próprio no gatilho de volume do C168 (~5–10 mil cortes).- Query no componente / novo domínio
utilities/speechCut/— fora; o dono éutilities/speech/. - Segunda lista de cortes na busca (grupo "Cortes") — fora; só aninhamento sob a fala (opção C rejeitada).
- Editar título/descrição dentro da fala ou da busca — fora; a fala/busca só listam e linkam para o detalhe (C168).
CampaignNavItemcomiconopcional / renderer paralelo da nav — fora; a extensão ésubItemsaditivo e sem estado de expansão.- Rotas novas sob
/acervo/cortesque mexam na ordem das regras de chrome — fora; nenhuma regra muda. - Mudar o contrato público
/corte/<id>— intocável. - Reposicionar/editar os saved-filters (B18) na nav — fora; só os
subItemsde Comunicação entram, oschildrenatuais ficam iguais.
Débitos triados (simplify 2026-09-16)
- Já resolvido no simplify (não reabrir): dois subitens de nav ativos ao mesmo tempo (o
/acervoé prefixo do/acervo/cortes) → helper puroactiveCampaignSubItemHref(mais específico vence) + unit;buildSpeechTextWhere/CampaignNavSubItemcom export não usado (knip) → un-exportados; ramo mortobranches.length === 1removido;bg-white/hover:bg-whitefora dos tokens do tema →bg-card; CTA "Selecionar trecho" exibido quando o picker não existe (fala < 5 s) → propexcerptSelectionAvailable+ helpersexcerptSelectionDuration/isExcerptSelectionAvailablecompartilhados com o player;campaignSpeechCutDetailHrefmigrado para oSpeechCutLibraryCard; guard/initialExcerptRangeduplicados no player →requestSelection(useCallback); stub descrollIntoViewrestaurado no teste;searchTextselecionado condicionalmente (só comq);aria-labelnos "Abrir corte" repetidos;behavior:'smooth'explícito ignorando reduced-motion removido (o CSS decide). - Registrado (Issue nova): C180/#1097 (depende de #1084) —
selectenxuto + DTO estreito nos loaders de corte, predicado único busca↔VM e teto/estratégia doid inda origem. Plano curto:docs/plans/acervo-cortes-divida-pos-c174.md. - Defer com gatilho:
- Sub-lista de subitens duplicada (
CampaignSidebar×CampaignBottomNav) — gatilho: um 3º consumidor real desubItems(hoje os dois consomem primitivos distintos:SidebarMenuSub*×div/Link). [data-slot="speech-player"]literal entre o CTA e o player — gatilho: um 2º scroller externo do player ou rename do slot (a falha só degrada o scroll).
- Sub-lista de subitens duplicada (
- Descartado: rename
matchedTextSearch(score 1);OpenCutButton/countLabel/campaignSpeechCutDetailHrefcom <3 call sites; seletor de testebutton[data-start-seconds="43"](pré-existente do player, C166/C167).
Riscos e mitigação
- Regressão de nav/chrome (o "não achei"): unit pinando os
subItemsdo communicator e o ativo do pai/filho; chrome da lista/detalhe pinado (a regra exata permanece antes da regex genérica). - Pin exato do
campaignNav.unit.spec.ts: a extensão é aditiva no nível de topo; se o pin exigir ajuste, é para acrescentar asserções desubItems, nunca afrouxar otoEqualdos hrefs de topo. - Duplicar fala na busca (opção B): dedupe por
speech.idantes de adicionar card; teste int cobre a fala que já casou e recebe o corte sem virar card duplicado. - Card de origem fabricado (opção B): VM mínimo honesto, sem inventar
excerpt/segmentos; se o dado não basta, mostra menos. - CTA não aciona o player: contrato de evento nomeado + unit; o listener vive no dono do estado (
SpeechDetailPlayer), sem duplicar a seleção. - Mobile sem a vertical: communicator vê no sheet (não é staff); coordinator/candidate veem no drawer "Mais" — pinado no unit de overflow.
toSpeechCutLibraryItemViewModeladepth: 0: não usar nessa superfície;origin: nullali seria dado faltante, não degradação — usartoSpeechCutViewModel.- Manifest/e2e: manter arquivos nos prefixos já mapeados; nenhum prefixo novo → manifest inalterado.
Aceite de engenharia
| Aceite (intenção) | Evidência |
|---|---|
| "Comunicação" tem subitens visíveis "Acervo de falas" e "Biblioteca de cortes" | nav.ts (subItems) + CampaignSidebar/drawer; unit de nav |
| Biblioteca/edição a um clique de qualquer lugar da vertical | subitem de nav + CTA mantido no acervo; chrome "Cortes"; unit do chrome |
| Detalhe da fala mostra os cortes daquela fala (título, status, duração, link) | loadSpeechCutsForSpeech + SpeechCutsForSpeechSection; int do loader; smoke/e2e |
| Vazio honesto "Nenhum corte desta fala ainda" + descrição + CTA "Selecionar trecho" | empty state + SpeechExcerptCta; unit do bridge |
| Busca mostra cortes como subitens da fala, sem lista paralela | loadSpeechCutsForSpeeches + cutsBySpeech; int do aninhamento; e2e/rota |
| Corte casa e fala não casa aparece como origem (opção B) — decisão de produto no GATE | loadSpeechCutsMatchingQuery + card de origem; int de dedupe (fase 4 isolada) |
Gate speechCatalog; advisor/leader fail-closed, sem regressão no C168 |
página/loader com user+overrideAccess:false; int/e2e da matriz (existentes) |
FK SET NULL degrada sem quebrar |
seção usa só o corte; origin indisponível não é renderizado aqui |
Nenhum internals de corte vaza; /corte/<id> intocado |
toSpeechCutViewModel (sem error/createdBy); nenhuma mudança em rota pública |
Sem collection/Consent/migration |
nenhum schema alterado; aceite de escopo |
| Qualidade | pnpm gate:fast verde + pnpm push; changelog docs/changelog/2026-09-16-c174.md |
Invariantes respeitadas: Local API sempre com user + overrideAccess: false; leituras novas são single-collection (sem transação); pessoa/corte seguem seus donos (Contact/speechCut), sem cadastro paralelo; sem Consent novo; leader/advisor lockdown intocado; copy pt-BR e identificadores em inglês; nenhuma migration; dependências lib → utilities → components → app.
Self-score (decision-quality ≥4)
- Decisões caras com rejeitadas — 5/5: D1–D7 têm
Opções/Recomendação/Alternativas rejeitadas; o que é caro de reverter (forma da árvore de nav, contrato do loader, merge da busca, ponte CTA↔player, chrome) está explicitado com o que não fazer. - Cabe no appetite — 4/5: reuso integral dos shells,
SpeechCutStatusBadge,toSpeechCutViewModele dos helpers existentes; o custo real é a UI Impeccable C (nav + seção + aninhamento) e a opção B, que fica isolada numa fase. Pode apertar cortando a fase 4 se o produto deferir B. - Rabbit holes nomeados — 5/5:
SpeechCutResultCard,searchText/índice, query no componente, grupo "Cortes", edição inline na fala/busca, renderer paralelo de nav, rotas/ordem de chrome e contrato público estão fora, com gatilho onde aplicável. - Depth check reusa donos — 5/5:
nav.tsé a fonte única dos dois consumidores (sem twinning);utilities/speech/é o dono do C167/C168;requireCampaignPageActor/campaignPageChrome/SpeechCutStatusBadge/toSpeechCutViewModelreusados; nenhum wrapper pass-through novo. - Intenção de produto satisfeita — 5/5: cada aceite literal (subitens, seção da fala, aninhamento na busca, vazio/CTA, gate) tem evidência; a única decisão aberta (opção B) fica marcada como produto a confirmar no GATE, sem inventar comportamento.