Merge remote-tracking branch 'origin/main' into work/18

This commit is contained in:
Francisco Solla 2026-09-18 18:53:40 -03:00
commit edad9d7a9b
86 changed files with 23585 additions and 420 deletions

View file

@ -0,0 +1,122 @@
---
name: graficos-dados
description: 'Transforma números que a comunicação recebeu (xlsx/csv/md/txt/texto colado) num gráfico PNG pronto para o Instagram — 1080×1350 feed, 1080×1080 quadrado ou 1080×1920 stories — com a paleta Jorge Solla, título-manchete, um único destaque de cor e guardrails de correção; dado ambíguo vira pergunta, nunca invenção.'
---
# Gráficos de dados para Instagram (C191)
Entrega **um gráfico PNG** por invocação, no tamanho do Instagram e com a cara
do mandato, a partir do dado **como ele veio** — planilha, CSV, markdown ou
texto solto colado no comando. A skill lê, entende a **relação** dos dados
(comparação, tempo, parte-de-um-todo, número único), **propõe o tipo** correto,
gera a peça com **título-manchete** e **um destaque vermelho**, e deixa a
comunicação transformar o gráfico no material final (legenda, carrossel,
contexto). É a **base visual confiável**, não a peça acabada.
O template visual é o artefato aprovado
`docs/plans/graficos-dados-instagram-ui-design.html`; o renderer
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe.
**Nunca invente estrutura visual** — o dono é o `designer`.
## Quando usar
- A comunicação pede "um gráfico disso", "transforma essa planilha em post",
"faz um gráfico para o Instagram" — e fornece os números (arquivo ou colado).
- **Não** use para: limpar/interpretar estatisticamente os dados, escrever a
legenda/carrossel, publicar/agendar, gerar dossiê/relatório em PDF (irmãos
C163/C186/C187) ou montar dashboard interativo. A skill **representa** o dado;
não o interpreta.
## Fluxo
1. **Receba o dado.** Texto colado → grave em
`data/graficos-instagram/<slug>-input.txt` (o diretório é gitignored) e aponte
com `--in=`. Arquivo → aponte o caminho direto (`xlsx`, `xls`, `csv`, `tsv`,
`md`, `txt`).
2. **Inspecione antes de desenhar:**
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --inspect
```
Devolve `{ rows, format, issues }` — a matriz parseada e os problemas.
3. **Decida o tipo e a copy.** A relação dos dados manda:
- **ranking / comparação → `bar`** (barras horizontais, ordenadas pelo valor,
rótulo direto e valor na ponta);
- **poucos períodos (≤4) → `column`** (base zero comum);
- **série de tempo contínua → `line`** (ponto final destacado);
- **uma única medida → `anchor`** (número grande + frase que explica).
Ambíguo (mais de um tipo plausível) → **pergunte**; não escolha no escuro.
Escreva a **manchete = takeaway** (não "Gráfico de X"), subtítulo opcional e a
**fonte** (viaja dentro da imagem).
4. **Gere o PNG:**
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> \
--headline="Um território concentra o maior resultado do período" \
--subtitle="Valores de 2022" \
--source="TSE 2022" --type=bar --size=feed \
--highlight="Ilhéus"
```
Saída default: `docs/research/graficos-instagram/<slug>-<data>-<size>.png`
(gitignored) + o `chart-spec.json` em `data/graficos-instagram/`. `--size`:
`feed` (1080×1350, default), `square` (1080×1080), `story` (1080×1920, com
faixas seguras de 250px no topo e na base). `--out=` sobrescreve o caminho.
5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/
tamanho/`--highlight` e regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
6. **Entregue o gráfico.** A comunicação finaliza a peça. O dado não persiste.
## Contrato do `chart-spec.json`
```json
{
"chartType": "bar",
"size": "feed",
"headline": "Um território concentra o maior resultado do período",
"subtitle": "Valores de 2022",
"source": "TSE 2022",
"note": "valores arredondados",
"highlight": "Ilhéus",
"rows": [{ "label": "Ilhéus", "value": 84 }]
}
```
`chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o
único vermelho `#c51414` (default: o maior valor).
## Guardrails (fail-closed)
O builder **recusa** em vez de desenhar algo enganoso:
- **Barras e colunas partem do zero**; valor negativo é recusado; sem 3D/eixo
truncado.
- **Até 7 pontos** por gráfico; em **stories**, o ranking comporta **até 5**
pontos (a adaptação vertical reduz um ponto, conforme o design aprovado).
Acima disso, resuma as categorias e explique.
- **Número-âncora:** a manchete é o takeaway (não repete o número) e o valor
aparece uma vez, grande, seguido da frase que explica.
- **Pizza não é gerada no v1** — pedido `pie` é recusado com sugestão de barras.
- **Dado faltando/ambíguo → pergunta, nunca completa.** O builder sai com
`{ needsQuestion: true, issues: [...] }` e não gera imagem.
- **Sem fonte, sem peça:** `--source` é obrigatório.
- Legibilidade: headline ≥48px e rótulos ≥30px no canvas 1080; contraste ≥4.5:1
no texto e ≥3:1 nas marcas; **cor nunca é a única pista** (o destaque tem
valor e posição).
- **Dado de campanha nunca sai da máquina:** render 100% local, sem MCP/serviço
remoto por default.
- **Nada é commitado:** entrada, `chart-spec` e PNG vivem em
`data/graficos-instagram/` e `docs/research/graficos-instagram/`, ambos
gitignored (repo público).
## Troubleshooting
- **"dado ambíguo ou faltando"** → leia `issues` no JSON de saída: linha sem
rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente.
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa.
- **"pizza não é gerada"** → ofereça barras horizontais.
- **Número pt-BR:** `1.234,56` é lido como 1234,56; `1,5` como 1,5.
- **PNG grande:** a guarda de 8MB falha fechado; simplifique o gráfico.
## Referências
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html`.
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
`docs/plans/graficos-dados-instagram-impl.md`.
- Irmãos (mesmo padrão skill + command + builder local): C186/C187.

5
.gitignore vendored
View file

@ -57,6 +57,7 @@ next-env.d.ts
.env
/media
/reelMedia
# TSE open-data zip cache (pnpm db:seed:tse)
/data/tse/
@ -95,6 +96,10 @@ next-env.d.ts
# C190 — dossiê Solla por tema/área + boletim modelo: same rule (C186).
/data/dossie-solla-tema/
/docs/research/dossie-solla-tema/
# C191 — Instagram data charts: raw input + chart-spec intermediates + final
# PNG/SVG carry campaign-internal data; the public repo never commits them.
/data/graficos-instagram/
/docs/research/graficos-instagram/
# Local-only sensitive material (agents may read; never commit content)
/private/**

View file

@ -0,0 +1,9 @@
---
description: Gera um gráfico PNG pronto para o Instagram (1080×1350 feed, 1080×1080 quadrado ou 1080×1920 stories) a partir de dados em xlsx/csv/md/txt/texto colado, na paleta Jorge Solla, com título-manchete e um único destaque de cor
---
Carregue a skill `graficos-dados` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia os `$ARGUMENTS` (arquivo apontado ou números colados no comando), inspecione a matriz com `--inspect`, classifique a relação dos dados (comparação, tempo, número único), proponha o tipo e a manchete/fonte, gere o PNG no tamanho pedido com o builder local (`node scripts/build-chart-from-data.mjs`) e confira com a pessoa. A fonte canônica é `.agents/skills/graficos-dados/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: `/graficos-dados <arquivo-ou-dados>` — ex.: `/graficos-dados data/graficos-instagram/votos.csv` ou `/graficos-dados Ilhéus 84, Itabuna 68, Porto Seguro 53`. Dado ambíguo/faltando → pergunte; nunca invente.
$ARGUMENTS

View file

@ -0,0 +1 @@
**C191 — Gráficos de dados para Instagram (skill de comunicação) (2026-09-18):** a comunicação agora transforma números como eles vieram (`xlsx`/`csv`/`md`/`txt`/texto colado) num **gráfico PNG pronto para o Instagram**, na paleta Jorge Solla e no formato do canal. (1) **Superfície:** skill `.agents/skills/graficos-dados/SKILL.md` + command `/graficos-dados` (sem subagente — a etapa semântica cabe no agente principal) e o entry `scripts/build-chart-from-data.mjs` (`--in`/`--inspect`/`--spec` replay/`--headline`/`--source`/`--type`/`--size`/`--highlight`). (2) **Núcleo puro** `scripts/lib/chartData.mjs`: parse dos formatos com `xlsx`/`csv-parse` (já devDeps, zero dependência nova), classificação da relação dos dados (ranking→`bar`, poucos períodos→`column`, série→`line`, medida única→`anchor`) e guardrails **fail-closed** (base zero, ≤7 pontos — 5 em stories, pizza recusada, `--source` obrigatório, nada de valor negativo); dado faltando/ambíguo vira pergunta, nunca invenção. (3) **Dono do desenho** `scripts/lib/chartPrimitives.mjs`: os primitivos SVG do dossiê (`barChart`/`valueList`/`columnChart`/`stackedColumnChart` + `CHART_COLORS`, extraídos byte-idênticos de `dossieRender.mjs`, golden pin) mais o `lineChart` e a escala `proportionalPercent`; **sem segundo renderer** ("edite o dono"). (4) **Renderer** `scripts/lib/graficosInstagramRender.mjs` porta classe-a-classe o hi-fi aprovado (`docs/plans/graficos-dados-instagram-ui-design.html`) em 3 canvases — `1080×1350` feed (default), `1080×1080` quadrado e `1080×1920` stories com faixas seguras de 250px derivadas de `SIZES`; título-manchete, um único destaque `#c51414`, fonte e lockup provisório dentro da imagem. (5) **Chromium:** `scripts/lib/buildPdf.mjs` ganhou `screenshotHtmlPng` (aditivo, viewport exato + clip, `document.fonts.ready`, guarda fail-closed de 8 MB) sem tocar no caminho A4. (6) **Privacidade/determinismo:** render 100% local; entrada, `chart-spec.json` e PNG gitignored (`data/graficos-instagram/`, `docs/research/graficos-instagram/`); sem schema/migration/DB. Crítica de fechamento do `designer` certificada (`Design tier: openai/gpt-5.6-sol`), sem bloqueantes; ajustes absorvidos (âncora sem repetir o número na manchete; stories com ≤5 pontos). Testes: `chartData`/`chartPrimitives`(golden)/`graficosInstagramRender`/prosa da skill + pinos de CI (`SCRIPTS_SPEC_PINNED`). `pnpm gate:fast` verde; prettier/typecheck/lint/knip limpos. Débito de runtime (entry/PNG real e parse ambíguo pt-BR) registrado em `C191-runtime` (#1151).

View file

@ -0,0 +1 @@
**C192 — Busca semântica no acervo de falas (2026-09-18):** o acervo (`/campanha/comunicacao/acervo`) ganhou busca **por tema** ao lado da busca literal, sem índice vetorial nem backfill. (1) **Modo explícito e visível**: novo parâmetro de URL `mode=tema` (default `termo`, omitido — deep links da busca exata ficam byte-idênticos; `mode=tema` sem `q` canonicaliza fora), registrado em `speechListParamNames`; o seletor "Buscar por: Termo exato | Por tema" (segmented control, `aria-pressed`) é otimista e preserva facetas/página. (2) **Ponte semântica determinística**: `expandSpeechSearchTheme` (`src/utilities/ai/`, `server-only`) pede ao DeepSeek (`generateObject`, `deepseek-flash`, timeout 4s, temperatura 0.2) de 3 a 8 termos relacionados; nunca lança — sem chave, timeout, erro ou saída malformada resolve `null` (degradação), e lista vazia é resposta legítima (sem aviso). `normalizeSpeechThemeTerms` sanitiza a saída do modelo (`%`/`_`/`\`, termos <3 chars, dedupe, teto de 8/60 chars). (3) **Motor lexical no caminho do Payload**: `buildSpeechListWhereIncludingCutOrigins` OR-a `q` + termos expandidos (`searchText like` / `keywords contains`) com dedupe, mantém facetas AND, paginação por fala e o OR de origem de corte do C174 (ancorado só no `q` literal); `payload.find` segue com `user` + `overrideAccess:false` e `canReadSpeechCatalog` barra a chamada externa de ator negado (fail-closed). (4) **Proveniência honesta**: `toSpeechListItemViewModel` ganha `themeMatchTerm` e `matchKind='theme'`; o card de tema mostra selo "Tema" (+ "Termo exato" quando também casa literal) e o bloco "Por que apareceu" com o excerto real da fala e a frase marcada como faixa contínua (`findPhraseRanges`), sem score nem texto gerado; o CTA só oferece "Assistir no trecho" com casamento literal real. (5) **Degradação e vazio**: aviso discreto (`Alert pending`, literais da cena 04) com "Tentar por tema novamente" e resultados lexicais; o vazio honesto tem as saídas reformular/usar termo exato/limpar filtros (cena 03). (6) **Design**: o `designer` estendeu o artefato do gate (`docs/plans/acervo-busca-semantica-ui-design.html`, cenas 01/02) e certificou a implementação renderizada (desktop 1280 / mobile 390). (7) **Testes**: novos unit de `speechThemeTerms` e `expandSpeechSearchTheme` (mock de provider) e extensões de URL/where/espelho/view model/omnibox; int com expansor injetado (tema, fallback, guard de acesso); e2e do modo degradado sem chave (o `playwright.config.ts` agora zera `DEEPSEEK_API_KEY` no servidor e2e). Sem migration, sem collection/Consent novo, sem exposição pública. `pnpm gate:ci` verde (unit 3742, int 827, e2e do acervo 16).

View file

@ -0,0 +1 @@
**C193 (fix) — restaura `main` após rename concorrente com o C192 (2026-09-18):** o rebase do merge do C193 renomeou `canReadSpeechCatalog` → `canReadCommunicationCatalog`, mas o C192 (mergeado minutos antes) adicionou novas referências ao nome antigo em `src/utilities/speech/speechPageData.ts` (import + gate do modo tema). O merge rebase juntou as duas mudanças sem conflito textual e o `verify` do deploy pegou a quebra (`TS2305`); a branch do PR estava baseada antes do C192, então o CI do PR não via o arquivo. Fix mecânico: as 2 referências passam ao nome novo. `pnpm gate:fast` verde (364 arquivos / 3867 testes) + int `speechAcervo`/`reel` 22 verdes.

View file

@ -0,0 +1 @@
**C193 — Reel privado no CMS: modelo, arquivos privados e kill switch (2026-09-18):** a assessoria produz os Reels de tutorial fora do Teqo e eles não tinham onde existir. Entregou-se a base no `/campanha`: (1) **collection `reel`** (grupo `Comunicação`) com `title`, `feature` (lista fechada, começando `cards`), `status` `draft→published→unpublished`, e os artefatos como uploads 1:1 — `video` (MP4 sem áudio, primário, required), `videoWithAudio`, `narrationAudio`, `captions` (`.srt`), `cover` (required) — mais `transcript` em texto, `publishedAt` carimbado na transição para `published` e `createdBy` do ator; (2) **collection de upload privada `reelMedia`** separada de `media` (que é leitura pública por contrato, pinada no lockdown): `read/create/update/delete` só para `communicator`/`coordinator`/`candidate` + admin, registrada no plugin S3 (`reelMedia: true`) para não cair em disco efêmero em produção; (3) **rota autenticada de mídia** `GET /campanha/comunicacao/reels/[id]/media/[kind]` — sob `/campanha` de propósito, porque é o path do cookie `campaign-token` que `<video>`/`<img>` enviam; gate pelo mesmo predicado da vertical, Local API com `overrideAccess: false`, e **kill switch de verdade**: só `published` serve, `draft`/`unpublished` respondem 404 silencioso (sem vazar existência); streaming direto do bucket privado via `GetObjectCommand`/`Range` (helpers do `payload/internal`) com fallback de disco em dev/test, content-type allowlisted (o que não for mídia segura vira `application/octet-stream` + attachment, sem XSS no origin da campanha), `Content-Disposition` com `filename*` e `Cache-Control: private, no-store`; (4) **RBAC**: `canReadSpeechCatalog` → `canReadCommunicationCatalog` (o predicado é da vertical inteira; sem twin) com `src/utilities/access/reels.ts` (`canReadReel`/`canDeleteReel`, delete como predicado próprio) reexportado pelo barrel; (5) **migration `20260918_201009_add_reel`** (tabelas, enums, FKs, rels) aplicada local; (6) **testes**: unit do vocabulário/headers (9) e int do modelo + rota com matriz de 5 papéis, anônimo, Range 206, download, kill switch e 404s (9). Sem UI (Impeccable A), sem Consent, sem publicação em rede social e site público intocado. `pnpm gate:fast` verde (355 arquivos / 3724 testes) + knip limpo; follow-up do ramo S3 sem teste registrado em C198 (#1162).

View file

@ -0,0 +1,156 @@
# Impl: C192 — Busca semântica no acervo de falas
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1147
Intenção: docs/plans/acervo-busca-semantica.md
Appetite restante: herdado (~1–2 dias eng)
## Leitura da intenção
- **Outcome:** em `mode=tema`, buscar um tema sem as palavras exatas devolve falas relacionadas pelo sentido, cada resultado com proveniência determinística (termo exato vs tema) e excerto real; sem resultado → vazio honesto; mecanismo indisponível → resultados lexicais de hoje + aviso discreto, nunca erro nem resultado fantasma.
- **O que NÃO negociar:** o gate de leitura (`canReadSpeechCatalog`/`canReadSpeech`) não alarga nem ganha atalho; `payload.find` sempre com `user` + `overrideAccess: false`; a fala continua a unidade; busca exata continua sendo o default e não regride; sem score numérico na UI; nada de pgvector/backfill dos 997; `openai/*` intocado (OPS123).
- **O que reavaliar:** a hipótese da intenção ("áreas prováveis") está correta quanto aos arquivos. O ponto a reavaliar é a assimetria `speechListFilters` (where) × `lib/speechSearch` (espelho C180): a expansão só entra na perna textual do `where`, então o espelho precisa de um predicado por termo em vez de reescrever a semântica literal — caso contrário `matchedTextSearch` quebraria o C174.
## Abordagem recomendada
```mermaid
flowchart LR
U[assessoria digita tema] --> M{mode=tema?}
M -- não --> LEX[buildSpeechListWhere<br/>literal atual]
M -- sim --> EXP[expandSpeechSearchTheme<br/>DeepSeek generateObject]
EXP -- null/erro --> DEG[where literal +<br/>alerta discreto]
EXP -- terms --> WHERE[where OR: q + termos expandidos<br/>AND facetas + OR cortes C174]
DEG --> Q[payload.find<br/>user · overrideAccess:false]
WHERE --> Q
Q --> VM[toSpeechListItemViewModel<br/>matchKind + themeMatchTerm + excerto real]
VM --> UI[SpeechResultCard · EmptyState · Alert]
```
**Opções consideradas:** A) LLM query expansion + motor lexical existente, sem índice vetorial | B) embeddings query-time (DeepInfra) + artefato em memória | C) pgvector + embeddings + backfill.
**Recomendação:** A — um único `generateObject` determinístico (mesmo contrato de `rerankSpeechExcerpts`) expande o tema em termos lexicais; o `where` do Payload e o access control continuam no caminho, a fala segue unidade, e nada novo precisa ser indexado/backfillado. Cabe no appetite e entrega o outcome no recorte do acervo.
**Rejeitadas:** B porque exige artefato de embeddings (backfill/refresh) e cosine em memória sem ganho claro sobre a expansão lexical para 997 falas, além de introduzir dependência de infra que a intenção mandou evitar; C porque é exatamente o rabbit hole nomeado em "índice vetorial de todo o acervo / infra nova" (custo, storage, sync contínuo, backfill). Detalhe em "Decisões de engenharia".
### Componentes / mudanças
- **`SpeechSearchMode` + `mode` em `SpeechListState`** (`src/utilities/speech/speechListUrl.ts:51`): adiciona `'mode'` a `speechListParamNames:71`, parse de valor único (`tema`; desconhecido → `undefined`), serializa `mode=tema` **somente quando há `q`**. Reusa `firstValue`, `resolveListUrl`, `buildListHref`. Deep links e back/forward passam pelo canonicalizador existente; `mode` desconhecido continua redirecionando porque o nome é suportado e o serializer o omite (raw ≠ canonical).
- **`applySpeechSearchMode`** (`src/utilities/speech/speechOmnibox.ts`): ação pura que devolve `{ kind: 'url', state: { ...state, mode, page: 1 } }` (ou limpa `mode`), preservando facetas. Reusa o padrão de `withPageReset`/`mutateFacet`; não passa por `mutateFacet` (não é facet multi-valor).
- **`normalizeSpeechThemeTerms`** (`src/lib/speechThemeTerms.ts`, novo, puro/client-safe): normaliza (`normalizeForSearch`), remove `%`/`_` e tokens <3 chars, deduplica pela forma normalizada, limita a 8 termos e 60 chars. É a única barreira entre texto de LLM e o `LIKE` — reusa `normalizeForSearch`, `uniqueByNormalizedForm`.
- **`expandSpeechSearchTheme`** (`src/utilities/ai/expandSpeechSearchTheme.ts`, novo, `server-only`): `deepSeek('deepseek-flash')` + `generateObject` + zod `{ terms: z.array(z.string()) }`, `temperature 0.2`, `AbortSignal.timeout(4000)`, `maxOutputTokens` baixo; aplica `normalizeSpeechThemeTerms`. Contrato: sem `DEEPSEEK_API_KEY`, timeout, erro de provider ou saída malformada → `null`; `{ terms: [] }` é resposta legítima (modelo rodou, nada a acrescentar). Espelha `rerankSpeechExcerpts`.
- **`buildSpeechTextBranches` / `buildSpeechListWhereIncludingCutOrigins`** (`src/utilities/speech/speechListFilters.ts:52,77`): passam a aceitar `themeTerms: readonly string[]`; a perna textual vira OR de `{ searchText: like normalize(q) }`, `{ keywords: contains q }` + pares equivalentes por termo expandido + `{ id: { in: originSpeechIds } }` (C174 intocado, ainda só `state.q`); facetas seguem AND. Reusa `buildSpeechFacetWhere`, `collapseListWhereOrBranches`.
- **`speechMatchesSearchTerm` / `speechMatchesSearchQuery`** (`src/lib/speechSearch.ts:31`): extrai o predicado de um termo (espelho exato do `where` per-termo); `speechMatchesSearchQuery` passa a ser `speechMatchesSearchTerm(speech, q)` para o flag literal. Mantém o lockstep C180 e `matchedTextSearch` literal.
- **`toSpeechListItemViewModel`** (`src/utilities/speech/speechViewModels.ts:213`): recebe `themeTerms?: readonly string[]`; `matchKind` ganha `'theme'`; novo `themeMatchTerm: string | null` (primeiro termo expandido que casa em segmento/keyword via espelho); quando não há casamento literal, o excerto é `buildHighlightedExcerpt(segment.text, themeMatchTerm)` — trecho real, sem "porquê" gerado. Reusa `pickMatchingSegment` (generalizado p/ lista de termos), `buildHighlightedExcerpt`, `normalizeForSearch`.
- **`loadSpeechAcervoPageData`** (`src/utilities/speech/speechPageData.ts:160`): ganha 4º parâmetro injetável `expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme` (precedente: `SpeechVideoStartResolver` em `loadSpeechDetailPageData`). Só expande se `state.q && state.mode === 'tema' && canReadSpeechCatalog(user.role)` (fail-closed: ator negado não dispara chamada externa; o `find` continua rejeitando). Retorna `searchMode`, `themeUnavailable` e passa `themeTerms` ao view model. Seleção de `searchText` inalterada (já existe quando `state.q`).
- **UI** (`src/components/campaign/speech/` + rota): `SpeechAcervoFilters.tsx` ganha o seletor segmentado "Buscar por: Termo exato | Por tema" (`role="group"`, `aria-pressed`) que chama `applySpeechSearchMode` via `useCampaignListFilterNavigation`; `SpeechResultCard.tsx:122` ganha o selo "Tema" + bloco "Por que apareceu" com o excerto real e mantém o caminho C174/`originLabel` quando não é tema; novo `SpeechThemeFallbackNotice.tsx` com `Alert variant="pending"` + botão "Tentar por tema novamente" (`router.refresh()`); `page.tsx:29` renderiza o aviso quando `themeUnavailable` e o vazio honesto (cena 03). Reusa `CampaignListOmnibox`, `CampaignListEmptyState`, `Alert`/`AlertTitle`/`AlertDescription`, `useCampaignListFilterNavigation`, `Badge`, `Button`.
- **Migration:** **sem migration**. Nenhum campo/collection/global muda: `searchText`/`keywords` já existem e a expansão é query-time; mudar schema exigiria migration e não é necessário.
- **Access / Consent:** intocado. Nenhuma chave `Consent` nova, nenhum PII de cidadão; o único dado que sai para o DeepSeek é o texto do tema digitado pelo assessor. A leitura continua no `payload.find` com `user` + `overrideAccess: false`; `canReadSpeech`/`canReadSpeechCatalog` não são tocados nem reusados para alargar nada.
- **UI (Impeccable B):** shell existente do acervo, sem rota nova. Cenas do gate a portar: 01 (desktop, selo "Tema" + "Por que apareceu" + "Termo exato" quando ambos), 02 (mobile, toggle tocável, `min-h-11`), 03 (vazio honesto: "Reformular busca" / "Usar termo exato" / "Limpar filtros"), 04 (degradado: seletor visualmente em "Termo exato", aviso `bg-estimate-pending` preservando "Por tema foi pedido", "Tentar por tema novamente"), 05 (loading: aproveitar pending existente; skeleton fica fora do v1). Craft/critique/polish no mesmo gate B antes do PR.
### Dados → forma (N/A justificado)
N/A. O item não apresenta métrica, agregado ou score: o resultado é uma lista de falas com uma explicação qualitativa (selo + trecho). A intenção proíbe explicitamente score numérico de confiança; a forma escolhida é "selo + excerto real", sem barra/percentual.
## Fases verificáveis
1. **Tracer / server** (~1 dia): `mode` no contrato de URL + `normalizeSpeechThemeTerms` + `expandSpeechSearchTheme` + `themeTerms` no `where` + injeção em `loadSpeechAcervoPageData` + `themeMatchTerm` no view model. Testes:
- `tests/unit/speechThemeTerms.unit.spec.ts` (novo): sanitiza wildcards/curtos/duplicados/limites.
- `tests/unit/expandSpeechSearchTheme.unit.spec.ts` (novo, mockando `ai`/`@ai-sdk/deepseek` como `rerankSpeechExcerpts.unit.spec.ts`): sem chave → `null` e sem chamada; erro do provider → `null`; `{terms:[]}` → legítimo; termos válidos; prompt contém o tema e `abortSignal` é `AbortSignal`.
- `tests/unit/speechListUrl.unit.spec.ts` (estender): `mode=tema` parse/serialize; desconhecido canonicaliza; `mode=tema` sem `q` redireciona; deep-link atual (`?q=SUS`) não muda.
- `tests/unit/speechListFilters.unit.spec.ts` (estender): OR `q` + expandidos + OR de cortes, facetas AND; sem `themeTerms` idêntico ao de hoje.
- `tests/unit/speechSearch.unit.spec.ts` (estender): `speechMatchesSearchTerm` (texto normalizado + keyword case-insensitive).
- `tests/unit/speechViewModels.unit.spec.ts` (estender): `themeMatchTerm`, `matchKind='theme'`, excerto highlightando o termo expandido.
2. **UI** (~0,5–1 dia): seletor de modo em `SpeechAcervoFilters`, selo/proveniência no `SpeechResultCard`, aviso de degradação (`Alert pending` + retry), vazio honesto com saídas; `applySpeechSearchMode` em `tests/unit/speechOmnibox.unit.spec.ts`. Testes:
- Estender `tests/int/speechAcervo.int.spec.ts` com expansor injetado: (a) fala que **não** contém o `q` literal mas contém a expansão é devolvida com `themeMatchTerm` e excerto real; (b) expansor `null` → `themeUnavailable: true` e linhas literais; (c) advisor/leader não disparam o expansor (guard fail-closed).
- Estender `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (browserless, sem `DEEPSEEK_API_KEY` no app e2e): `?q=<marker>&mode=tema` → 200, seletor "Buscar por", aviso "A busca por tema está indisponível agora." e resultado exato do marker; `?q=<marker>` (default) inalterado.
3. **Gates:** `pnpm gate:fast`; PR via `pnpm push`. `src/utilities/ai` é prefixo de risco (OPS86) → o PR roda o conjunto curado de e2e; os prefixos `src/utilities/speech`/`src/components/campaign/speech`/`src/app/(campaign)/.../comunicacao`/`src/lib/speech*` já mapeiam `campaignSpeechAcervo`, então `scripts/lib/e2e-affected-manifest.mjs` **não muda**.
## Rabbit holes / Não escopo (engenharia)
- pgvector, embeddings, artefato/backfill dos 997 e sync contínuo — rejeitado nomeadamente na intenção.
- Mandar o catálogo inteiro dos 997 ao LLM para "escolher" a fala — prompt/latência/custo e caixa-preta; a seleção fica no `where` lexical.
- LLM a gerar o "por que apareceu" — alucinação; a proveniência é sempre trecho determinístico e o selo é o modo.
- Segundo passe de rerank por LLM no v1 (gatilho de revisitação: se recall/precisão do lexical+expansão não bastar, avaliar rerank sobre candidatos, como no C158).
- Curadoria de sinônimos/ontologia, busca global, Sollinha, exposição pública do acervo, analytics de busca — fora de escopo por intenção.
- Cache do resultado da expansão: v1 paga uma chamada por request (fatiar página re-expande). Gatilho: se latência/custo doer, cache in-process limitado por `normalizeForSearch(q)`.
- Skeletons da cena 05: aproveitar só o pending/dimming existente; skeleton fica para polish futuro.
## Riscos e mitigação
- **Latência de 1 chamada LLM por busca em página** (e por flip de página): timeout 4s + `generateObject`; a UI usa o pending existente (`aria-busy`); degradação honesta se estourar. Trigger de cache acima.
- **`LIKE` com curinga injetado**: `normalizeSpeechThemeTerms` remove `%`/`_` e limita tamanho/quantidade; o `where` continua parametrizado pelo Payload.
- **Divergência where ↔ espelho C180**: o predicado por termo é único (`speechMatchesSearchTerm`) e testado; qualquer mudança na perna textual tem que passar por ele no mesmo commit.
- **Regressão da busca exata**: default `termo` não emite parâmetro; `buildSpeechListWhere` sem `themeTerms` é byte-idêntico ao atual (teste dedicado).
- **Proveniência enganosa**: quando o casamento é literal, `matchedTextSearch`/`matchKind` continuam mandando; o selo "Tema" só aparece com `themeMatchTerm` real; `[]` do modelo não vira resultado.
- **Chamada externa para ator sem leitura**: guard `canReadSpeechCatalog(user.role)` antes de expandir; o `find` com access continua sendo a barreira final (fail-closed).
- **Custo/quota DeepSeek**: `deepseek-flash`, `maxOutputTokens` baixo, sem rerank; os 997 não são enviados.
- **Confusão do usuário ao degradar**: aviso preserva "Por tema foi pedido" e o seletor volta visualmente a "Termo exato", com retry explícito (cena 04) — nunca erro na cara.
- **e2e de risco**: o diff toca `src/utilities/ai`, então o PR roda o curado; `campaignSpeechAcervo` também é selecionado pelas prefixos de speech e cobre o modo degradado sem rede.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: tema sem palavras exatas devolve falas relacionadas; cada resultado explica termo exato vs tema com trecho; exata inalterada; vazio honesto; degradação com aviso, sem erro nem fantasma; facetas continuam valendo; fala é a unidade.
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` em toda leitura; gate `canReadSpeech`/`canReadSpeechCatalog` não alargado; sem pgvector/embeddings; sem `openai/*`; copy pt-BR e identificadores em inglês; sem score numérico.
- [ ] Sem migration (justificado) — nenhum schema/collection/global alterado.
- [ ] Testes de domínio: unit do sanitizador, do módulo de expansão (mock de provider), do contrato de URL, do `where`, do espelho C180 e do view model; int com expansor injetado (tema, fallback, guard de acesso); e2e browserless do modo degradado.
- [ ] `pnpm gate:fast` verde; `pnpm push` com PR ready.
- [ ] Manifesto de e2e mantém cobertura de risco sem edição (`src/utilities/ai` e prefixos de speech já mapeados).
## Decisões de engenharia
### D1 — Mecanismo da busca por tema
**Opções:** A) LLM query expansion + motor lexical existente | B) embeddings query-time (DeepInfra) + cosine em memória sobre artefato | C) pgvector + embeddings + backfill.
**Recomendação:** A — resolve o job ("sem as palavras exatas") sem infra nova nem reindexação, mantém access/`where`/paginação por fala e a proveniência é lexical e determinística.
**Rejeitadas:** C porque é o rabbit hole textual da intenção (pipeline, backfill dos 997, storage, sync); B porque exige manter um artefato de embeddings (gerar/atualizar/servir) e introduz similaridade contínua — mais superfície e sem ganho comprovado no recorte de 997 falas; D) lexical puro sem expandir não atende o outcome ("defesa do SUS" continua sem achar outras palavras); E) mandar o acervo inteiro ao LLM estoura prompt/latência/custo e vira caixa-preta.
### D2 — Contrato de falha da expansão
**Opções:** A) `null` para indisponível **e** vazio | B) `null` só para indisponível; `{terms: []}` quando o modelo roda e não acrescenta nada | C) lançar erro e tratar na página.
**Recomendação:** B — distingue "mecanismo fora" (mostra o aviso da cena 04) de "mecanismo rodou, sem termos" (cai na busca literal sem aviso de indisponibilidade); é mais honesto que colapsar os dois.
**Rejeitadas:** A porque exibir "indisponível" quando o serviço respondeu vazio mente para o usuário; C porque quebra o contrato "nunca lança" do precedente `rerankSpeechExcerpts` e arrisca erro na cara.
### D3 — Parâmetro de URL do modo
**Opções:** A) `mode=tema` (default `termo`, omitido) | B) `mode=semantic`/`mode=exact` | C) `semantic=1` booleano.
**Recomendação:** A — nome English (`mode`, consistente com `q`/`year`/`topic`/…), valor slug pt-BR (`tema`/`termo`) consistente com a convenção de VALORES já fixada nos facets (`curta`/`media`/`sem_duracao`); default omitido mantém deep-links e a URL da busca exata idênticas (zero regressão). Registrado em `speechListParamNames` para o canonicalizador redirecionar valor desconhecido.
**Rejeitadas:** B porque inventa vocabulário em inglês para valores que o usuário vê em pt-BR; C porque um booleano não escala para um terceiro modo e foge do padrão de enum de facet.
### D4 — Proveniência do resultado
**Opções:** A) selo por item + trecho real determinístico | B) "porquê" gerado pelo LLM | C) aviso único no topo.
**Recomendação:** A — `themeMatchTerm` + `buildHighlightedExcerpt` sobre o texto do segmento/keyword; trecho existe no dado, sem alucinação; o selo "Tema"/"Termo exato" marca cada fala (cena 01).
**Rejeitadas:** B por risco de alucinação e min/max imprevisível; C porque não sustenta a confiança em cada fala e some ao rolar.
### D5 — Unidade do resultado e escopo do casamento
**Opções:** A) fala, com o `where` casando texto/keywords e (C174) origem de corte | B) fala + trechos | C) trechos.
**Recomendação:** A — o job é "qual fala serve"; a expansão só entra na perna textual da fala/keyword; o casamento por corte (C174) permanece ancorado só no `q` original (não expandimos títulos de corte) para não inflar superfície/custo.
**Rejeitadas:** B/C porque recorte fino de trecho é o job do C158 no Sollinha e mudaria a unidade/paginação atual.
### D6 — Cache/segundo passe
**Opções:** A) v1 sem cache e sem rerank | B) cache in-process da expansão já no v1 | C) rerank LLM dos candidatos no v1.
**Recomendação:** A — menos superfície, cabe no appetite; o `deepseek-flash` com timeout e `maxOutputTokens` baixo mantém o custo aceitável no interno.
**Rejeitadas:** B adiada (barata de reverter; gatilho: latência/custo por flip de página); C adiada (gatilho: precisão/recall insuficientes) — espelha a decisão do C158 de rerank como segunda etapa, não no caminho crítico.
## Self-score decision-quality
1. **Decisões caras com rejeitadas:** 5/5 — mecanismo, contrato de falha, contrato de URL e proveniência têm opções/recomendação/rejeitadas registradas; custo/rerank deferidos com gatilho.
2. **Cabe no appetite:** 5/5 — query-time, sem migration, sem infra; ~1–2 dias (server+tracer num dia, UI/gates no restante).
3. **Rabbit holes nomeados:** 5/5 — embeddings/pgvector/backfill, catálogo inteiro no LLM, "porquê" gerado, rerank/cache/curadoria explicitamente cortados.
4. **Depth check (reusa shells/helpers):** 5/5 — `buildSpeechListWhere*`, canonicalizador (`resolveListUrl`/`buildListHref`), `CampaignListOmnibox`, `CampaignListEmptyState`, `useCampaignListFilterNavigation`, `Alert`, `buildHighlightedExcerpt`/`speechSearchTerms`, e o contrato de injeção de `loadSpeechDetailPageData`.
5. **Intenção permanece satisfeita:** 5/5 — outcome, anti-goals e guardrails de acesso intactos; a engenharia não reescreveu o produto.
**Total: 5/5** (gate ≥4 satisfeito).
## Triage do /simplify (C192)
Aplicados no PR: gate `speechMatchesSearchTerm` no `pickThemeMatch` (não reivindicar tema que o `where` não usou), evidência por keyword em vez de trecho alheio, copy do vazio no caminho degradado, `aria-disabled` com no-op real, `temperature: 0` na expansão, `SPEECH_OMNIBOX_ID` compartilhado, `SpeechWatchButton` extraído, frase contínua no destaque + testes diretos, prompt interpolando as constantes.
Deferidos com gatilho:
- **Cache da expansão por `q` normalizado** (custo/latência por página e coerência de paginação). Gatilho: latência/custo doer ou paginação incoerente observada. Já previsto em D6.
- **Helper único para o boilerplate DeepSeek** (`generateObject` + guard de chave + timeout + catch→null), hoje repetido em `rerankSpeechExcerpts`, `campaignDemandTitle`, `speechCutMetadata` e `expandSpeechSearchTheme`. Gatilho: tocar qualquer um deles.
- **`collectOccurrences` em `speechHighlight`** (loop duplicado entre `findHighlightRanges`/`findPhraseRanges`). Gatilho: terceira variante de busca de ranges.
- **Sanitizar `themeTerms` na fronteira do `buildSpeechListWhere`** (defense-in-depth para futuros callers; produção já segura pelo provider). Gatilho: novo caller do builder.
- **`CampaignListEmptyState` com 3 props de classe para um caller** — reavaliar se surgir um segundo consumidor (variante nomeada). Gatilho: segundo call site.

View file

@ -475,27 +475,27 @@
<div class="flex flex-col gap-3">
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
>
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/>
</svg>
Tema
</span>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
<svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/>
</svg>
Tema
</span>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 flex items-start gap-4">
<div
@ -514,22 +514,20 @@
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="space-y-2" aria-label="Excerto da fala como placeholder">
<div class="h-2.5 w-full rounded bg-muted"></div>
<div class="h-2.5 w-11/12 rounded bg-muted"></div>
<div class="h-2.5 w-3/5 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “…
atendimento público e acesso universal à saúde …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… atendimento público e
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>acesso universal à saúde</mark
>
…”
</p>
</div>
</div>
@ -560,24 +558,24 @@
</article>
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
<div
@ -594,21 +592,20 @@
</svg>
</div>
<div>
<div class="space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-4/5 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “… presença
da atenção básica nos bairros e comunidades …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… presença da
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>atenção básica</mark
>
nos bairros e comunidades …”
</p>
</div>
</div>
@ -631,24 +628,24 @@
</article>
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
<div
@ -665,21 +662,20 @@
</svg>
</div>
<div>
<div class="space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-9/12 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “…
fortalecimento da rede pública de atendimento …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… fortalecimento da
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>rede pública de atendimento</mark
>
…”
</p>
</div>
</div>
@ -702,28 +698,28 @@
</article>
<article class="rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<span
class="inline-flex h-6 items-center rounded-full border border-border bg-white px-2.5 text-xs font-medium"
>Termo exato</span
>
<span class="text-xs text-muted-foreground"
>Fala · Data da fala · Duração</span
>
<span
class="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground"
>Termo exato</span
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div class="mt-3 grid grid-cols-[128px_minmax(0,1fr)] gap-4">
<div
@ -740,21 +736,17 @@
</svg>
</div>
<div>
<div class="space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-5/6 rounded bg-muted"></div>
</div>
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2"
class="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p
class="text-[11px] font-semibold uppercase tracking-wide text-primary"
>
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “…
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
<p class="mt-1 text-sm leading-6 text-foreground/90">
“…
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>defesa do SUS</mark
>
…”
@ -916,21 +908,24 @@
</div>
<div class="space-y-3">
<article class="rounded-xl border border-border p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
><svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
><svg
class="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
>
<path
d="m12 3-1.7 4.6L6 9.3l4.3 1.7 1.7 4.7 1.7-4.7L18 9.3l-4.3-1.7L12 3Z"
/></svg
>Tema</span
><span class="text-xs text-muted-foreground">Fala · Data</span>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<div
class="mt-3 flex h-28 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground"
@ -945,17 +940,18 @@
<path d="m6 3 14 9-14 9z" />
</svg>
</div>
<div class="mt-3 space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-4/5 rounded bg-muted"></div>
</div>
<div class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2">
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5 text-foreground/80">
<span class="font-medium">Trecho exemplificativo:</span> “… atendimento público
e acesso universal à saúde …”
<p class="mt-1 text-sm leading-6 text-foreground/90">
“… atendimento público e
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>acesso universal à saúde</mark
>
…”
</p>
</div>
<a
@ -973,27 +969,27 @@
></a>
</article>
<article class="rounded-xl border border-border p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/8 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/15"
>Tema</span
><span
class="inline-flex h-6 items-center rounded-full border border-border px-2.5 text-xs font-medium"
>Termo exato</span
>
<div class="flex flex-col items-start gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
>Tema</span
><span
class="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground"
>Termo exato</span
>
</div>
<p class="text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
</div>
<p class="mt-2 text-xs text-muted-foreground">Fala · Data da fala · Duração</p>
<div class="mt-3 space-y-2">
<div class="h-2.5 rounded bg-muted"></div>
<div class="h-2.5 w-3/4 rounded bg-muted"></div>
</div>
<div class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2">
<div
class="mt-3 rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5"
>
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<p class="mt-1 text-xs leading-5">
<span class="font-medium">Trecho exemplificativo:</span> “…
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
<p class="mt-1 text-sm leading-6 text-foreground/90">
“…
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>defesa do SUS</mark
>
…”
@ -1309,12 +1305,12 @@
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm leading-6 text-foreground/85">
Trecho exemplificativo com o termo
<mark class="rounded-sm bg-estimate-pending px-0.5 text-foreground"
<p class="text-sm leading-6 text-foreground/90">
“… reafirmamos a
<mark class="rounded-sm bg-amber-200/70 px-0.5 text-foreground"
>defesa do SUS</mark
>
destacado no contexto literal.
como compromisso público …”
</p>
<div class="mt-3 flex gap-1.5">
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs">Tema da fala</span
@ -1441,11 +1437,22 @@
</li>
<li>
<strong class="font-medium text-foreground">Selo:</strong> “Tema”; quando também há
casamento literal, o card acrescenta “Termo exato”, sem score numérico.
casamento literal, o card acrescenta “Termo exato”, sem score numérico. Os selos ocupam
uma linha própria, imediatamente antes da metainformação da fala; nunca ficam ao lado de
um título.
</li>
<li>
<strong class="font-medium text-foreground">Proveniência:</strong> bloco “Por que
apareceu” + trecho curto explicitamente exemplificativo neste artefato.
apareceu” + um único excerto real da fala, com o termo que de fato casou destacado. No
modo por tema, esse bloco substitui o excerto comum: as duas áreas nunca convivem nem
repetem o mesmo texto. “Trecho exemplificativo” não é copy do produto.
</li>
<li>
<strong class="font-medium text-foreground">Precedência visual:</strong> evidência na
própria fala pelo tema vence o estado “Fala de origem”, mesmo quando não houve casamento
literal. Sem evidência de tema e sem casamento literal, permanece integralmente o branch
C174 (“Fala de origem” + nota + correspondência no corte). O card comum fica para o
resultado literal fora desses dois casos.
</li>
<li>
<strong class="font-medium text-foreground">Miniaturas e conteúdo:</strong> caixas e

View file

@ -0,0 +1,820 @@
<!doctype html>
<html lang="pt-BR" data-theme="campaign">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Design — Gravações enviadas no acervo</title>
<style type="text/tailwindcss">
@theme {
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--color-background: #ffffff;
--color-foreground: #1c1917;
--color-card: #ffffff;
--color-primary: #c51414;
--color-primary-foreground: #ffffff;
--color-secondary: #f5f5f4;
--color-secondary-foreground: #292524;
--color-muted: #f4f4f5;
--color-muted-foreground: #6b7280;
--color-border: rgb(28 25 23 / 12%);
--color-input: #d6d3d1;
--color-destructive: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input {
font: inherit;
}
.icon {
width: 1rem;
height: 1rem;
stroke-width: 2;
}
.icon-sm {
width: 0.875rem;
height: 0.875rem;
stroke-width: 2;
}
.btn {
display: inline-flex;
min-height: 2.5rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
transition: 150ms ease;
}
.btn-primary {
background: #c51414;
color: white;
border: 1px solid #c51414;
}
.btn-outline {
background: white;
color: #292524;
border: 1px solid #d6d3d1;
}
.btn-ghost {
background: transparent;
color: #292524;
border: 1px solid transparent;
}
.field {
min-height: 2.75rem;
width: 100%;
border: 1px solid #d6d3d1;
border-radius: 0.5rem;
background: white;
padding: 0.625rem 0.75rem;
color: #1c1917;
outline: none;
}
.field:focus {
border-color: #c51414;
box-shadow: 0 0 0 3px rgb(197 20 20 / 18%);
}
.scene-label {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
color: #57534e;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.scene-label::after {
content: '';
height: 1px;
flex: 1;
background: #d6d3d1;
}
.desktop-frame {
width: 1280px;
min-height: 720px;
overflow: hidden;
border: 1px solid #d6d3d1;
border-radius: 14px;
background: white;
box-shadow: 0 18px 45px rgb(28 25 23 / 10%);
}
.mobile-frame {
width: 390px;
min-height: 760px;
overflow: hidden;
border: 1px solid #d6d3d1;
border-radius: 28px;
background: white;
box-shadow: 0 18px 45px rgb(28 25 23 / 12%);
}
.status {
display: inline-flex;
min-height: 1.25rem;
align-items: center;
gap: 0.375rem;
border-radius: 9999px;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
}
.status-ready {
background: #dcfce7;
color: #166534;
}
.status-work {
background: #fef3c7;
color: #92400e;
}
.status-failed {
background: #fee2e2;
color: #991b1b;
}
.status-upload {
background: #eef0f3;
color: #46505c;
}
.seg {
display: grid;
grid-template-columns: 3.25rem 1fr;
gap: 0.75rem;
width: 100%;
border-radius: 0.5rem;
padding: 0.5rem;
text-align: left;
}
.seg:hover,
.seg-active {
background: #f4f4f5;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
}
}
</style>
</head>
<body class="bg-stone-100 text-foreground antialiased">
<svg aria-hidden="true" class="absolute size-0 overflow-hidden">
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 3v12m0-12 4 4m-4-4L8 7" />
<path d="M4 15v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4" />
</symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m6 4 14 8-14 8z" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 3v12m0 0 4-4m-4 4-4-4" />
<path d="M5 21h14" />
</symbol>
<symbol id="i-video" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="5" width="14" height="14" rx="2" />
<path d="m17 10 4-2v8l-4-2z" />
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M20 7h-5V2" />
<path d="M20 7a9 9 0 1 0 1 8" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m18 6-12 12M6 6l12 12" />
</symbol>
<symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m16 6 4 14M12 6v14M8 8v12M4 4v16" />
</symbol>
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</symbol>
</svg>
<main class="mx-auto w-max max-w-none px-10 py-10">
<header class="mb-8 max-w-3xl">
<p class="text-sm font-semibold text-primary">Teqo · acervo de comunicação</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Gravações enviadas no acervo</h1>
<p class="mt-2 text-sm leading-6 text-muted-foreground">
Referência hi-fi para enviar, acompanhar, buscar e consultar gravações próprias. A
transcrição é somente leitura.
</p>
</header>
<!-- CENA 1 -->
<section class="mb-12" aria-labelledby="scene-1">
<h2 id="scene-1" class="scene-label">Cena 1 · Desktop — acervo com alternador de fonte</h2>
<div class="desktop-frame flex">
<aside class="flex w-[220px] shrink-0 flex-col border-r border-border bg-stone-50 p-3">
<div class="flex h-12 items-center gap-2 px-2 font-semibold">
<span class="grid size-8 place-items-center rounded-lg bg-primary text-sm text-white"
>T</span
>
Campanha
</div>
<nav class="mt-3 space-y-1 text-sm">
<div class="rounded-md px-3 py-2 text-muted-foreground">Início</div>
<div class="rounded-md px-3 py-2 text-muted-foreground">Municípios</div>
<div class="rounded-md px-3 py-2 text-muted-foreground">Pessoas</div>
<div
class="rounded-md bg-white px-3 py-2 font-medium text-primary ring-1 ring-border"
>
Comunicação
</div>
<div class="ml-5 border-l border-border py-1 pl-3">
<div
class="rounded-md bg-white px-3 py-2 font-medium text-primary ring-1 ring-border"
>
Acervo
</div>
<div class="px-3 py-2 text-muted-foreground">Cortes</div>
</div>
</nav>
<div class="mt-auto border-t border-border px-2 pt-3 text-xs text-muted-foreground">
Assessor de Comunicação
</div>
</aside>
<div class="min-w-0 flex-1">
<header class="flex min-h-11 items-center gap-3 border-b border-border px-4">
<span class="text-muted-foreground">☰</span>
<div class="min-w-0">
<p class="text-sm font-semibold">Acervo</p>
<p class="text-xs text-muted-foreground">Comunicação</p>
</div>
</header>
<div class="p-6">
<div class="flex items-end justify-between gap-4">
<div>
<h3 class="text-xl font-semibold tracking-tight">Acervo de falas</h3>
<p class="mt-1 text-sm text-muted-foreground">
Busque nas falas da Câmara ou nas gravações da equipe.
</p>
</div>
<button class="btn btn-primary">
<svg class="icon"><use href="#i-upload" /></svg>Enviar gravação
</button>
</div>
<div
class="mt-5 inline-flex rounded-lg bg-secondary p-1 text-sm"
role="tablist"
aria-label="Fonte do acervo"
>
<button class="rounded-md px-3 py-2 text-muted-foreground" data-source-tab="camara">
Falas da Câmara
</button>
<button
class="rounded-md bg-white px-3 py-2 font-medium text-foreground shadow-sm ring-1 ring-border"
data-source-tab="enviadas"
>
Gravações enviadas
</button>
</div>
<label class="mt-5 block text-sm font-medium" for="search-1"
>Buscar nas gravações enviadas</label
>
<div
class="mt-1.5 flex min-h-11 items-center gap-2 rounded-lg border border-input bg-white px-3 shadow-xs"
>
<svg class="icon text-muted-foreground"><use href="#i-search" /></svg
><input
id="search-1"
class="min-w-0 flex-1 border-0 bg-transparent text-sm outline-none"
placeholder="Busque por palavra ou trecho da transcrição…"
/>
</div>
<div class="mt-5 space-y-3">
<article
class="grid grid-cols-[112px_minmax(0,1fr)_auto] gap-4 rounded-xl border border-border bg-card p-4"
>
<div
class="grid aspect-video place-items-center rounded-lg bg-stone-200 text-stone-500"
>
<svg class="h-7 w-7"><use href="#i-video" /></svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h4 class="truncate text-sm font-semibold">Título da gravação</h4>
<span class="status status-ready">Pronto</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
<p class="mt-2 line-clamp-2 text-sm leading-5 text-foreground/90">
Texto transcrito da gravação aparece aqui quando corresponde à busca.
</p>
</div>
<button class="btn btn-outline self-center">
Abrir<svg class="icon-sm"><use href="#i-chevron" /></svg>
</button>
</article>
<article
class="grid grid-cols-[112px_minmax(0,1fr)_auto] gap-4 rounded-xl border border-border bg-card p-4"
>
<div
class="grid aspect-video place-items-center rounded-lg bg-stone-200 text-stone-500"
>
<svg class="h-7 w-7"><use href="#i-video" /></svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h4 class="truncate text-sm font-semibold">Outra gravação enviada</h4>
<span class="status status-work">Processando</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
<p class="mt-2 text-sm text-muted-foreground">
Transcrevendo a gravação. Ela ainda não aparece na busca.
</p>
</div>
<button class="btn btn-outline self-center">
Abrir<svg class="icon-sm"><use href="#i-chevron" /></svg>
</button>
</article>
<article
class="grid grid-cols-[112px_minmax(0,1fr)_auto] gap-4 rounded-xl border border-border bg-card p-4"
>
<div
class="grid aspect-video place-items-center rounded-lg bg-stone-200 text-stone-500"
>
<svg class="h-7 w-7"><use href="#i-video" /></svg>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h4 class="truncate text-sm font-semibold">
Gravação com falha na transcrição
</h4>
<span class="status status-failed">Falhou</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
<p class="mt-2 text-sm text-muted-foreground">
O arquivo foi preservado. Reprocesse a transcrição no detalhe.
</p>
</div>
<button class="btn btn-outline self-center">
Abrir<svg class="icon-sm"><use href="#i-chevron" /></svg>
</button>
</article>
</div>
</div>
</div>
</div>
</section>
<!-- CENA 2 -->
<section class="mb-12" aria-labelledby="scene-2">
<h2 id="scene-2" class="scene-label">
Cena 2 · Desktop — estados de processamento na lista e no detalhe
</h2>
<div class="desktop-frame p-8">
<div class="mx-auto max-w-5xl">
<div class="flex items-end justify-between">
<div>
<h3 class="text-xl font-semibold">Estados da gravação</h3>
<p class="mt-1 text-sm text-muted-foreground">
O arquivo permanece visível desde o envio.
</p>
</div>
<span class="text-xs text-muted-foreground">Gravações enviadas</span>
</div>
<div class="mt-6 grid grid-cols-2 gap-4">
<article class="rounded-xl border border-border p-4">
<div class="flex items-start justify-between">
<div>
<span class="status status-upload">Enviando</span>
<h4 class="mt-3 text-sm font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">
Arquivo sendo enviado · ainda não pesquisável
</p>
</div>
<svg class="h-5 w-5 text-muted-foreground"><use href="#i-upload" /></svg>
</div>
<div class="mt-5 h-2 overflow-hidden rounded-full bg-muted">
<div class="h-full w-[58%] rounded-full bg-primary"></div>
</div>
<p class="mt-2 text-xs text-muted-foreground">Enviando arquivo…</p>
</article>
<article class="rounded-xl border border-border p-4">
<div class="flex items-start justify-between">
<div>
<span class="status status-work">Processando</span>
<h4 class="mt-3 text-sm font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">
Transcrevendo · ainda não pesquisável
</p>
</div>
<span
class="size-5 animate-spin rounded-full border-2 border-stone-300 border-t-primary motion-reduce:animate-none"
></span>
</div>
<div class="mt-5 rounded-lg bg-muted/70 p-3 text-xs text-muted-foreground">
A transcrição ficará disponível neste detalhe quando o processamento terminar.
</div>
</article>
<article class="rounded-xl border border-destructive/30 p-4">
<div class="flex items-start justify-between">
<div>
<span class="status status-failed">Falhou</span>
<h4 class="mt-3 text-sm font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">
A transcrição não foi concluída. O arquivo foi preservado.
</p>
</div>
<svg class="h-5 w-5 text-destructive"><use href="#i-file" /></svg>
</div>
<button class="btn btn-outline mt-5">
<svg class="icon-sm"><use href="#i-refresh" /></svg>Reprocessar transcrição
</button>
</article>
<article class="rounded-xl border border-border p-4">
<div class="flex items-start justify-between">
<div>
<span class="status status-ready">Pronto</span>
<h4 class="mt-3 text-sm font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">
Transcrição disponível e pesquisável
</p>
</div>
<span
class="grid size-6 place-items-center rounded-full bg-green-100 text-sm text-green-700"
>✓</span
>
</div>
<button class="btn btn-outline mt-5">
<svg class="icon-sm"><use href="#i-play" /></svg>Abrir gravação
</button>
</article>
</div>
</div>
</div>
</section>
<!-- CENA 3 -->
<section class="mb-12" aria-labelledby="scene-3">
<h2 id="scene-3" class="scene-label">Cena 3 · Desktop — detalhe da gravação</h2>
<div class="desktop-frame">
<header class="flex min-h-11 items-center gap-3 border-b border-border px-4">
<span class="text-muted-foreground">☰</span>
<div>
<p class="text-sm font-semibold">Gravação enviada</p>
<p class="text-xs text-muted-foreground">Acervo</p>
</div>
</header>
<div class="p-6">
<button class="btn btn-ghost -ml-2">
<svg class="icon"><use href="#i-back" /></svg>Voltar ao acervo
</button>
<div class="mt-4 flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<span class="status status-ready">Pronto</span>
</div>
<div class="mt-5 grid grid-cols-[minmax(0,1.35fr)_minmax(340px,.65fr)] gap-6">
<div>
<div
class="relative grid aspect-video place-items-center overflow-hidden rounded-lg border border-black/30 bg-stone-900"
>
<span
class="absolute inset-0 bg-[linear-gradient(145deg,#292524_0%,#0c0a09_100%)]"
></span
><button
class="relative grid size-16 place-items-center rounded-full bg-white text-stone-950 shadow-lg"
>
<svg class="h-7 w-7 translate-x-0.5"><use href="#i-play" /></svg>
</button>
<div
class="absolute inset-x-4 bottom-3 flex items-center gap-3 text-xs text-white"
>
<span>00:00</span>
<div class="h-1 flex-1 rounded-full bg-white/30">
<div class="h-full w-[18%] rounded-full bg-white"></div>
</div>
<span>—:—:—</span>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button class="btn btn-primary">
<svg class="icon"><use href="#i-download" /></svg>Baixar
</button>
</div>
</div>
<section aria-label="Transcrição">
<div class="flex items-center justify-between">
<h4 class="text-xs font-medium text-muted-foreground">TRANSCRIÇÃO</h4>
<span class="text-xs text-muted-foreground">Clique para posicionar</span>
</div>
<ol class="mt-2 space-y-0.5">
<li>
<button class="seg seg-active">
<span class="pt-0.5 text-xs tabular-nums text-primary">00:00</span
><span class="text-sm leading-relaxed"
>Texto transcrito do primeiro segmento da gravação aparece aqui.</span
>
</button>
</li>
<li>
<button class="seg">
<span class="pt-0.5 text-xs tabular-nums text-muted-foreground">00:18</span
><span class="text-sm leading-relaxed"
>O próximo segmento mantém a leitura contínua e posiciona o player quando
selecionado.</span
>
</button>
</li>
<li>
<button class="seg">
<span class="pt-0.5 text-xs tabular-nums text-muted-foreground">00:42</span
><span class="text-sm leading-relaxed"
>A transcrição é somente leitura nesta versão do acervo.</span
>
</button>
</li>
<li>
<button class="seg">
<span class="pt-0.5 text-xs tabular-nums text-muted-foreground">01:06</span
><span class="text-sm leading-relaxed"
>Mais um trecho transcrito continua disponível para busca.</span
>
</button>
</li>
</ol>
</section>
</div>
</div>
</div>
</section>
<!-- CENA 4 -->
<section class="mb-12" aria-labelledby="scene-4">
<h2 id="scene-4" class="scene-label">Cena 4 · Desktop — envio de gravação</h2>
<div class="desktop-frame relative grid place-items-center bg-stone-200/70 p-8">
<div class="absolute inset-0 bg-stone-950/25"></div>
<section
class="relative w-full max-w-lg rounded-xl border border-border bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="upload-title"
>
<div class="flex items-start justify-between">
<div>
<h3 id="upload-title" class="text-base font-semibold">Enviar gravação</h3>
<p class="mt-1 text-sm text-muted-foreground">
Adicione um arquivo local ao acervo.
</p>
</div>
<button
class="grid size-8 place-items-center rounded-md hover:bg-muted"
aria-label="Fechar"
>
<svg class="icon"><use href="#i-x" /></svg>
</button>
</div>
<div class="mt-5 space-y-4">
<div>
<label class="text-sm font-medium">Arquivo de vídeo</label
><button
class="mt-1.5 flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed border-input bg-stone-50 px-4 text-center"
>
<svg class="h-6 w-6 text-muted-foreground"><use href="#i-upload" /></svg
><span class="mt-2 text-sm font-medium">Selecionar arquivo local</span
><span class="mt-1 text-xs text-muted-foreground"
>O limite será informado antes do envio.</span
>
</button>
</div>
<div>
<label class="text-sm font-medium" for="title-upload"
>Título <span class="text-destructive">*</span></label
><input
id="title-upload"
class="field mt-1.5 text-sm"
placeholder="Título da gravação"
/>
</div>
<div>
<label class="text-sm font-medium" for="date-upload">Data da gravação</label
><input
id="date-upload"
class="field mt-1.5 text-sm text-muted-foreground"
placeholder="dd/mm/aaaa"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button class="btn btn-outline">Cancelar</button
><button class="btn btn-primary">
<svg class="icon"><use href="#i-upload" /></svg>Enviar
</button>
</div>
</section>
</div>
</section>
<!-- CENA 5 -->
<section class="mb-12" aria-labelledby="scene-5">
<h2 id="scene-5" class="scene-label">Cena 5 · Desktop — vazio</h2>
<div class="desktop-frame p-8">
<div class="mx-auto max-w-5xl">
<div class="inline-flex rounded-lg bg-secondary p-1 text-sm">
<span class="rounded-md px-3 py-2 text-muted-foreground">Falas da Câmara</span
><span class="rounded-md bg-white px-3 py-2 font-medium shadow-sm ring-1 ring-border"
>Gravações enviadas</span
>
</div>
<div
class="mt-5 flex min-h-72 flex-col items-center justify-center rounded-xl border border-border px-6 text-center"
>
<span
class="grid size-12 place-items-center rounded-lg bg-muted text-muted-foreground"
><svg class="h-6 w-6"><use href="#i-video" /></svg
></span>
<h3 class="mt-4 text-base font-semibold">Nenhuma gravação enviada ainda</h3>
<p class="mt-1 max-w-md text-sm text-muted-foreground">
Envie um arquivo de vídeo para acompanhar a transcrição e pesquisar o conteúdo no
acervo.
</p>
<button class="btn btn-primary mt-5">
<svg class="icon"><use href="#i-upload" /></svg>Enviar gravação
</button>
</div>
</div>
</div>
</section>
<!-- CENA 6 -->
<section aria-labelledby="scene-6">
<h2 id="scene-6" class="scene-label">
Cena 6 · Mobile (390 px) — lista e detalhe com transcrição
</h2>
<div class="flex items-start gap-8">
<div>
<p class="mb-2 text-xs font-medium text-muted-foreground">Lista de gravações</p>
<div class="mobile-frame relative pb-20">
<header class="flex min-h-14 items-center bg-primary px-4 text-white">
<h3 class="text-sm font-semibold">Acervo</h3>
<button class="btn ml-auto border border-white/30 bg-white/10 text-white">
<svg class="icon"><use href="#i-upload" /></svg>Enviar
</button>
</header>
<div class="border-b border-border bg-white px-4 pt-3 pb-2">
<div class="grid grid-cols-2 rounded-lg bg-secondary p-1 text-xs">
<button class="rounded-md px-2 py-2 text-muted-foreground">Falas da Câmara</button
><button
class="rounded-md bg-white px-2 py-2 font-medium shadow-sm ring-1 ring-border"
>
Gravações enviadas
</button>
</div>
<div class="mt-2 flex min-h-10 items-center gap-2 rounded-lg px-2">
<svg class="icon text-muted-foreground"><use href="#i-search" /></svg
><span class="text-sm text-muted-foreground">Buscar nas gravações…</span>
</div>
</div>
<div class="space-y-3 p-4">
<article class="rounded-xl border border-border p-4">
<div class="aspect-video rounded-lg bg-stone-200"></div>
<div class="mt-3 flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<span class="status status-ready">Pronto</span>
</div>
<p class="mt-3 text-sm leading-5">
Texto transcrito correspondente à busca aparece aqui.
</p>
<button class="btn btn-outline mt-3 w-full">
Abrir gravação<svg class="icon-sm"><use href="#i-chevron" /></svg>
</button>
</article>
<article class="rounded-xl border border-border p-4">
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">Outra gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<span class="status status-work">Processando</span>
</div>
<p class="mt-3 text-sm text-muted-foreground">
Transcrevendo. Ainda não pesquisável.
</p>
</article>
</div>
<nav
class="absolute inset-x-0 bottom-0 grid h-16 grid-cols-5 border-t border-border bg-white pt-2 text-[10px] text-muted-foreground"
>
<span class="text-center">Início</span><span class="text-center">Pessoas</span
><span class="text-center text-primary">Comunicação</span
><span class="text-center">Agenda</span><span class="text-center">Mais</span>
</nav>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted-foreground">Detalhe</p>
<div class="mobile-frame relative pb-20">
<header class="flex min-h-14 items-center gap-3 bg-primary px-4 text-white">
<svg class="icon"><use href="#i-back" /></svg>
<div>
<h3 class="text-sm font-semibold">Gravação enviada</h3>
<p class="text-xs text-white/75">Acervo</p>
</div>
</header>
<div class="p-4">
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-base font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<span class="status status-ready">Pronto</span>
</div>
<div
class="relative mt-4 grid aspect-video place-items-center rounded-lg bg-stone-900"
>
<button class="grid size-12 place-items-center rounded-full bg-white">
<svg class="h-5 w-5 translate-x-0.5"><use href="#i-play" /></svg>
</button>
</div>
<button class="btn btn-primary mt-3 w-full">
<svg class="icon"><use href="#i-download" /></svg>Baixar
</button>
<section class="mt-5">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">TRANSCRIÇÃO</h5>
<span class="text-[11px] text-muted-foreground">Toque para posicionar</span>
</div>
<ol class="mt-2 space-y-1">
<li>
<button class="seg seg-active">
<span class="text-xs text-primary">00:00</span
><span class="text-sm leading-5"
>Texto transcrito do primeiro segmento.</span
>
</button>
</li>
<li>
<button class="seg">
<span class="text-xs text-muted-foreground">00:18</span
><span class="text-sm leading-5"
>O próximo segmento continua a leitura da gravação.</span
>
</button>
</li>
<li>
<button class="seg">
<span class="text-xs text-muted-foreground">00:42</span
><span class="text-sm leading-5"
>A transcrição permanece somente leitura.</span
>
</button>
</li>
</ol>
</section>
</div>
<nav
class="absolute inset-x-0 bottom-0 grid h-16 grid-cols-5 border-t border-border bg-white pt-2 text-[10px] text-muted-foreground"
>
<span class="text-center">Início</span><span class="text-center">Pessoas</span
><span class="text-center text-primary">Comunicação</span
><span class="text-center">Agenda</span><span class="text-center">Mais</span>
</nav>
</div>
</div>
</div>
</section>
</main>
<script>
document.querySelectorAll('[data-source-tab]').forEach((tab) => {
tab.addEventListener('click', () => {
document
.querySelectorAll('[data-source-tab]')
.forEach((item) => (item.className = 'rounded-md px-3 py-2 text-muted-foreground'))
tab.className =
'rounded-md bg-white px-3 py-2 font-medium text-foreground shadow-sm ring-1 ring-border'
})
})
</script>
</body>
</html>

View file

@ -0,0 +1,116 @@
# Gravações enviadas no acervo de comunicação
Status: rascunho
Atualizado em: 2026-09-18
Issue: #1166
Priority: P2
Impeccable: C — fluxo novo na vertical `/campanha/comunicacao/acervo` (upload + nova fonte + detalhe)
Design UI: docs/plans/acervo-gravacoes-enviadas-ui-design.html
Appetite: ~3–4 dias eng; um outcome verificável — a assessoria sobe uma plenária/debate e a gravação fica pesquisável no acervo, com player e transcrição
Responsável: —
## Intenção
O acervo de falas só tem o que a Câmara publica. Mas a comunicação do mandato também grava plenárias, debates e material próprio que hoje fica em pastas e celulares — sem transcrição, sem busca e sem lugar comum. Quem precisa de um trecho continua garimpando vídeo no olho.
Este item permite que a assessoria envie um arquivo de gravação e o transforme numa fonte pesquisável dentro do acervo: transcrição com timestamps ao longo de toda a gravação, busca textual que alcança a fala de qualquer pessoa presente (não só o Solla — requisito, não efeito colateral), player que abre no trecho e download do arquivo. É o primeiro passo da linha; separar/rotular quem falou é o C200.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação (`communicator`), na mesa, lidando com arquivos longos (horas) de plenárias e debates; `coordinator`/`candidate` usam o mesmo acervo. Busca e leitura também acontecem em campo.
- **Job principal:** guardar uma gravação própria no acervo e, depois, achar nela a fala/trecho que embasa a peça — de qualquer pessoa que falou.
- **Fluxo desejado:** abre o acervo → alterna para "Gravações enviadas" → "Enviar gravação" (arquivo local, título e data) → acompanha o estado → com "Pronto", busca por assunto e lê o trecho com timestamps → abre o detalhe: player, transcrição clicável, "Baixar" → se falhou, "Reprocessar transcrição".
- **Anti-goals de produto:** não é editor de vídeo/transcrição; não expõe o acervo; não publica nada; não cria cadastro de pessoa; não é CMS de vídeo genérico.
### Esboço de fluxo (C)
```text
[acervo /campanha/comunicacao/acervo]
→ alternador de fonte: "Falas da Câmara" | "Gravações enviadas"
→ "Enviar gravação" → arquivo local + título/data → estado visível na lista
→ (Enviando → Processando → Pronto | Falhou)
→ Pronto: busca textual alcança a fala de qualquer pessoa → abre o detalhe
→ player + transcrição clicável (clique posiciona o player) + "Baixar"
→ Falhou: "Reprocessar transcrição"
[outcome: gravação própria pesquisável no acervo, sem depender de fora]
```
### Design UI (C)
- Design UI (gate): `docs/plans/acervo-gravacoes-enviadas-ui-design.html` — alternador de fonte, envio com estados, lista e detalhe (player + transcrição + download), na linguagem visual do `/campanha`. Por que C: fluxo novo (upload + nova fonte + detalhe), mesmo sem rota de topo nova.
## Objetivo e aceite
- `communicator` — e `coordinator`/`candidate` — envia um arquivo de vídeo local e acompanha o processamento até "Pronto"; `advisor`/`leader` não têm acesso (fail-closed).
- A gravação vira uma fonte do acervo em `/campanha/comunicacao/acervo`, com alternador visível "Falas da Câmara" × "Gravações enviadas"; a fonte nova tem busca própria, sem as facetas da Câmara.
- A busca textual encontra a fala de qualquer pessoa da gravação, com trecho e timestamps; a unidade do resultado é a gravação.
- Detalhe da gravação: player, transcrição clicável por segmento (clique posiciona o player, como no acervo atual), "Baixar" e "Reprocessar transcrição" no estado "Falhou".
- Estados de processamento visíveis e honestos; a falha preserva o arquivo e permite reprocessar.
- Gravação só é servida para quem tem acesso ao acervo — nunca URL pública — reusando o dono do mecanismo de mídia privada definido no C193.
- **Guardrails:** sem diarização/rotulagem de pessoa (C200), sem corte/clipe, sem publicação externa, sem segundo cadastro de pessoa, sem score/vaidade; transcrição somente leitura em v1.
## Dados (intenção)
- **Vou apresentar dados?** N/A — resultados de busca não são métrica nem agregado; não desbloqueiam decisão numérica.
- **Decisões desbloqueadas:** N/A — a escolha da persona ("qual fala/trecho usar") é qualitativa.
- **Forma:** N/A — restrição de produto: nenhum score de relevância/similaridade exposto como número.
## Dados da decisão (literais)
- **Papel:** `communicator` = "Assessor de Comunicação"; `coordinator`/`candidate` também. Mesmo gate de leitura do acervo (`speechCatalog`); `advisor`/`leader` fora.
- **Fontes (rótulos):** "Falas da Câmara" (acervo atual) e "Gravações enviadas" (novo), no mesmo `/campanha/comunicacao/acervo` via alternador; detalhe da gravação em rota própria sob o acervo, `/campanha/comunicacao/acervo/gravacoes/[id]`.
- **Gestos:** "Enviar gravação" (upload) · "Reprocessar transcrição" · "Baixar" (arquivo) · player no detalhe.
- **Estados de processamento:** "Enviando" (arquivo subindo; ainda não pesquisável) · "Processando" (transcrevendo; ainda não pesquisável) · "Pronto" (pesquisável: player, transcrição e download) · "Falhou" (transcrição não saiu; arquivo preservado e "Reprocessar transcrição" disponível). A gravação aparece na lista com o estado desde o envio.
- **Transcrição:** timestamps por segmento; clique no segmento posiciona o player — mesmo comportamento do acervo atual.
- **Entrada:** arquivo de vídeo local (mp4/mov/mkv e afins); erro claro acima do limite; gravações de horas são o caso real — o valor do limite é decisão da implementação.
- **Apagar:** com confirmação; remove a gravação (e sua transcrição/mídia) sem tocar nas falas da Câmara.
- **Dependência dura:** C193 (mídia privada/servir autenticado); C200 (separação por pessoa) depende deste.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/app/(campaign)/campanha/(app)/comunicacao/acervo/` (página atual + sub-rota nova), `src/components/campaign/speech/` (alternador, lista, filtros, player, detalhe), `src/utilities/speech/` (`speechPageData.ts`, `speechListFilters.ts`, `speechListUrl.ts`, `speechViewModels.ts`), `src/utilities/access/speeches.ts` (leitura), `src/utilities/campaignPageActor.ts` (gate `speechCatalog`), esteira de mídia (`Media`/`mediaStorage.ts` como referência, respeitando o dono do C193).
- **Precedente a olhar:** C174 (segunda fonte já entra na mesma busca via OR de ids), `speechCutJob.ts` (status + step + disparo pós-resposta + reaper preguiçoso), `speechPosterJob.ts` (single-flight), import CSV de apoiadores (enviar → processar → confirmar), route handler multipart `src/app/(campaign)/campanha/api/ai-transcribe/route.ts`, CLI da Câmara (`scripts/import-camara-speeches.mjs` + `scripts/lib/camaraFetch.mjs`) para o padrão de ASR com timestamps.
- **Risco de acoplamento:** (1) mídia privada é do C193 — não criar mecanismo gêmeo; `Media` continua leitura pública (contrato pinado em teste); (2) upload de vídeo de horas não cabe no default de server action (1 MB) — decisão do plano de implementação; (3) regra de chrome/regex do acervo pode capturar `gravacoes` como id (mesma armadilha anotada no C194); (4) nav pinada `[Acervo, Cortes]` e C184/#1106 mexem nos mesmos arquivos — nada de item de nav novo; (5) contrato de URL da lista atual (`q|year|topic|scope|phase|municipality|duration|page`) não pode quebrar deep-links.
## Dependências
- **C193 (dura):** mecanismo de mídia privada e servir autenticado. O C199 pode possuir o upload de arquivo grande; o _servir privado_ é do C193 — sem ele fechado, a fatia privada não entra.
- **C153/C154 (entregues):** base do acervo (dados, busca, rota, gate).
- **C184/#1106 (soft):** conflito de nav/sub-itens — serializar.
- **C200 (futuro):** depende deste; não bloqueia a fatia.
## Fora de escopo
- Diarização/rotulagem por pessoa (C200).
- Link/YouTube/download automático por URL.
- Cortes/clipes (C167/C174 são da Câmara).
- Busca semântica (C192).
- Edição de transcrição.
- Publicação externa/espelhamento em rede social.
- Dossiês/Sollinha.
- Comissões/outras fontes e classificação por tema/alcance.
## Rabbit holes de produto
- **Virar editor.** Se alguém "só completar": cortar, corrigir, legendar, remixar. **Corte neste item:** transcrição somente leitura; a única "edição" é reprocessar a transcrição inteira.
- **Diarização no v1.** Se alguém "só completar": rotular quem falou em horas de áudio. **Corte neste item:** C200; a busca textual geral já resolve o job.
- **Espelhar/publicar.** Se alguém "só completar": derivar para redes ou galeria. **Corte neste item:** arquivo interno; nada sai do acervo.
- **Segundo cadastro de pessoa.** Se alguém "só completar": transformar falante em `Contact`. **Corte neste item:** sem rótulo de pessoa em v1.
- **Merge unificado das fontes.** Se alguém "só completar": lista única Câmara+gravações e entrada no `home-search`. **Corte neste item:** alternador dentro do acervo; a busca global não entra.
- **Fila/infra de processamento.** Se alguém "só completar": pipeline genérico, tempo real, chunking. **Corte neste item:** processamento inteiro com estado visível.
## Questões em aberto (produto)
- **Forma da segunda fonte?** **Opções:** A) duas fontes no mesmo acervo, com alternador e busca própria | B) merge das duas numa lista única | C) rota separada fora do acervo. **Recomendação:** A — as facetas da Câmara não se aplicam à gravação e o merge multi-coleção é caro; o alternador mantém "acervo pesquisável" sem fingir homogeneidade. _(assumido — validar no gate)_
- **Transcrição editável em v1?** **Opções:** A) somente leitura | B) correção manual inline. **Recomendação:** A — correção vira curadoria; se doer, item próprio. _(assumido — validar no gate)_
- **Quem apaga uma gravação?** **Opções:** A) qualquer um dos três papéis com acesso, com confirmação | B) só `coordinator`/`candidate`. **Recomendação:** A — a assessoria é dona do acervo no dia a dia; apagar não toca nas falas da Câmara e é confirmado. _(assumido — validar no gate)_
- **Gravação que falhou fica visível?** **Opções:** A) sim, com "Falhou" e "Reprocessar transcrição" | B) só aparece quando "Pronto". **Recomendação:** A — falha silenciosa faz o arquivo sumir sem explicação. _(assumido — validar no gate)_
- **O que a assessoria preenche ao enviar?** **Opções:** A) título obrigatório + data da gravação | B) só o arquivo. **Recomendação:** A — sem isso a lista vira pilha de `IMG_…`; uploader e duração são automáticos. _(assumido — validar no gate)_
## Referências
- GitHub Issue #1166
- Design UI (gate): `docs/plans/acervo-gravacoes-enviadas-ui-design.html` (+ assets em `docs/plans/acervo-gravacoes-enviadas-ui-design-assets/`)
- Planos irmãos: `docs/plans/catalogo-falas-solla.md` (C153) · `docs/plans/acervo-videos-comunicacao.md` (C154) · `docs/plans/acervo-busca-semantica.md` (C192) · `docs/plans/reels-reel-privado.md` (C193) · `docs/plans/reels-biblioteca.md` (C194)
- Arquivos-pista: `src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx` · `src/components/campaign/speech/` · `src/utilities/speech/speechPageData.ts` · `src/utilities/speech/speechListFilters.ts` · `src/utilities/speech/speechListUrl.ts` · `src/lib/speechSearch.ts` · `src/collections/SpeechSegment.ts` · `src/utilities/speech/speechCutJob.ts` · `scripts/import-camara-speeches.mjs`
- `AGENTS.md` — RBAC de `/campanha`, acervo interno e leader lockdown

View file

@ -0,0 +1,701 @@
<!doctype html>
<html lang="pt-BR" data-theme="campaign">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Design — Separar falas por pessoa</title>
<style type="text/tailwindcss">
@theme {
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--color-background: #ffffff;
--color-foreground: #1c1917;
--color-card: #ffffff;
--color-primary: #c51414;
--color-primary-foreground: #ffffff;
--color-secondary: #f5f5f4;
--color-secondary-foreground: #292524;
--color-muted: #f4f4f5;
--color-muted-foreground: #6b7280;
--color-border: rgb(28 25 23 / 12%);
--color-input: #d6d3d1;
--color-destructive: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input {
font: inherit;
}
.icon {
width: 1rem;
height: 1rem;
stroke-width: 2;
}
.icon-sm {
width: 0.875rem;
height: 0.875rem;
stroke-width: 2;
}
.btn {
display: inline-flex;
min-height: 2.5rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
}
.btn-primary {
background: #c51414;
color: white;
border: 1px solid #c51414;
}
.btn-outline {
background: white;
color: #292524;
border: 1px solid #d6d3d1;
}
.btn-ghost {
background: transparent;
color: #292524;
border: 1px solid transparent;
}
.field {
min-height: 2.75rem;
width: 100%;
border: 1px solid #d6d3d1;
border-radius: 0.5rem;
background: white;
padding: 0.625rem 0.75rem;
outline: none;
}
.field:focus {
border-color: #c51414;
box-shadow: 0 0 0 3px rgb(197 20 20 / 18%);
}
.scene-label {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
color: #57534e;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.scene-label::after {
content: '';
height: 1px;
flex: 1;
background: #d6d3d1;
}
.desktop-frame {
width: 1280px;
min-height: 720px;
overflow: hidden;
border: 1px solid #d6d3d1;
border-radius: 14px;
background: white;
box-shadow: 0 18px 45px rgb(28 25 23 / 10%);
}
.mobile-frame {
width: 390px;
min-height: 790px;
overflow: hidden;
border: 1px solid #d6d3d1;
border-radius: 28px;
background: white;
box-shadow: 0 18px 45px rgb(28 25 23 / 12%);
}
.speaker-block {
border: 1px solid rgb(28 25 23 / 12%);
border-radius: 0.75rem;
overflow: hidden;
background: white;
}
.speaker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid rgb(28 25 23 / 12%);
background: #fafaf9;
padding: 0.625rem 0.75rem;
}
.seg {
display: grid;
grid-template-columns: 3.25rem 1fr;
gap: 0.75rem;
width: 100%;
padding: 0.55rem 0.75rem;
text-align: left;
}
.seg:hover,
.seg-active {
background: #f4f4f5;
}
.chip {
display: inline-flex;
min-height: 1.75rem;
align-items: center;
gap: 0.375rem;
border-radius: 0.375rem;
background: #f4f4f5;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
}
}
</style>
</head>
<body class="bg-stone-100 text-foreground antialiased">
<svg aria-hidden="true" class="absolute size-0 overflow-hidden">
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m6 4 14 8-14 8z" />
</symbol>
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
</symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8h.01" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m18 6-12 12M6 6l12 12" />
</symbol>
<symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 3v12m0 0 4-4m-4 4-4-4" />
<path d="M5 21h14" />
</symbol>
<symbol id="i-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M4 6h16M7 12h10M10 18h4" />
</symbol>
</svg>
<main class="mx-auto w-max max-w-none px-10 py-10">
<header class="mb-8 max-w-3xl">
<p class="text-sm font-semibold text-primary">Teqo · acervo de comunicação</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Separar falas por pessoa</h1>
<p class="mt-2 text-sm leading-6 text-muted-foreground">
Referência hi-fi para agrupamentos acústicos e identificação humana. O produto nunca
sugere nem infere quem falou.
</p>
</header>
<!-- CENA 1 -->
<section class="mb-12" aria-labelledby="scene-1">
<h2 id="scene-1" class="scene-label">
Cena 1 · Desktop — detalhe com transcrição separada por falante
</h2>
<div class="desktop-frame">
<header class="flex min-h-11 items-center gap-3 border-b border-border px-4">
<span class="text-muted-foreground">☰</span>
<div>
<p class="text-sm font-semibold">Gravação enviada</p>
<p class="text-xs text-muted-foreground">Acervo</p>
</div>
</header>
<div class="p-6">
<button class="btn btn-ghost -ml-2">
<svg class="icon"><use href="#i-back" /></svg>Voltar ao acervo
</button>
<div class="mt-4 flex items-start justify-between">
<div>
<h3 class="text-lg font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<span class="rounded-full bg-green-100 px-2 py-1 text-xs font-semibold text-green-700"
>Pronto</span
>
</div>
<div class="mt-5 grid grid-cols-[minmax(0,1.05fr)_minmax(430px,.95fr)] gap-6">
<div>
<div class="relative grid aspect-video place-items-center rounded-lg bg-stone-900">
<button
class="grid size-16 place-items-center rounded-full bg-white text-stone-950 shadow-lg"
>
<svg class="h-7 w-7 translate-x-0.5"><use href="#i-play" /></svg>
</button>
<div
class="absolute inset-x-4 bottom-3 flex items-center gap-3 text-xs text-white"
>
<span>00:00</span>
<div class="h-1 flex-1 rounded-full bg-white/30">
<div class="h-full w-[22%] rounded-full bg-white"></div>
</div>
<span>—:—:—</span>
</div>
</div>
<button class="btn btn-primary mt-3">
<svg class="icon"><use href="#i-download" /></svg>Baixar
</button>
<div class="mt-4 flex gap-2 rounded-lg border border-border bg-muted/50 p-3">
<svg class="icon mt-0.5 shrink-0 text-muted-foreground">
<use href="#i-info" />
</svg>
<p class="text-xs leading-5 text-muted-foreground">
Os falantes são agrupados automaticamente pelo áudio. A identificação de cada
pessoa é feita pela equipe.
</p>
</div>
</div>
<section aria-label="Transcrição por falante">
<div class="flex items-center justify-between">
<h4 class="text-xs font-medium text-muted-foreground">TRANSCRIÇÃO POR FALANTE</h4>
<span class="text-xs text-muted-foreground">Clique para posicionar</span>
</div>
<div class="mt-2 space-y-3">
<article class="speaker-block">
<header class="speaker-head">
<div class="flex items-center gap-2">
<span class="grid size-7 place-items-center rounded-full bg-muted"
><svg class="icon-sm"><use href="#i-user" /></svg
></span>
<div>
<h5 class="text-sm font-semibold">Falante 1</h5>
<p class="text-[11px] text-muted-foreground">
Agrupamento sem identificação
</p>
</div>
</div>
<button class="btn btn-outline">
<svg class="icon-sm"><use href="#i-edit" /></svg>Identificar falante
</button>
</header>
<button class="seg seg-active">
<span class="text-xs tabular-nums text-primary">00:00</span
><span class="text-sm leading-5"
>Texto transcrito deste agrupamento aparece neste trecho.</span
></button
><button class="seg">
<span class="text-xs tabular-nums text-muted-foreground">00:38</span
><span class="text-sm leading-5"
>Outra fala da mesma voz permanece no mesmo agrupamento.</span
>
</button>
</article>
<article class="speaker-block">
<header class="speaker-head">
<div class="flex items-center gap-2">
<span
class="grid size-7 place-items-center rounded-full bg-primary/10 text-primary"
><svg class="icon-sm"><use href="#i-user" /></svg
></span>
<div>
<h5 class="text-sm font-semibold">Nome do falante</h5>
<p class="text-[11px] text-muted-foreground">
Identificação feita pela equipe
</p>
</div>
</div>
<button class="btn btn-outline">
<svg class="icon-sm"><use href="#i-edit" /></svg>Editar identificação
</button>
</header>
<button class="seg">
<span class="text-xs tabular-nums text-muted-foreground">00:18</span
><span class="text-sm leading-5"
>O nome aplicado identifica todas as falas deste agrupamento.</span
>
</button>
</article>
<article class="speaker-block">
<header class="speaker-head">
<div class="flex items-center gap-2">
<span class="grid size-7 place-items-center rounded-full bg-muted"
><svg class="icon-sm"><use href="#i-user" /></svg
></span>
<div>
<h5 class="text-sm font-semibold">Falante 3</h5>
<p class="text-[11px] text-muted-foreground">
Agrupamento sem identificação
</p>
</div>
</div>
<button class="btn btn-outline">
<svg class="icon-sm"><use href="#i-edit" /></svg>Identificar falante
</button>
</header>
<button class="seg">
<span class="text-xs tabular-nums text-muted-foreground">01:04</span
><span class="text-sm leading-5"
>Texto transcrito do terceiro agrupamento.</span
>
</button>
</article>
</div>
</section>
</div>
</div>
</div>
</section>
<!-- CENA 2 -->
<section class="mb-12" aria-labelledby="scene-2">
<h2 id="scene-2" class="scene-label">Cena 2 · Desktop — diálogo para rotular o falante</h2>
<div class="desktop-frame relative grid place-items-center bg-stone-200/70 p-8">
<div class="absolute inset-0 bg-stone-950/25"></div>
<section
class="relative w-full max-w-md rounded-xl border border-border bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="identify-title"
>
<div class="flex items-start justify-between">
<div>
<h3 id="identify-title" class="text-base font-semibold">Identificar falante</h3>
<p class="mt-1 text-sm text-muted-foreground">
Dê um nome ao agrupamento “Falante 1”.
</p>
</div>
<button
class="grid size-8 place-items-center rounded-md hover:bg-muted"
aria-label="Fechar"
>
<svg class="icon"><use href="#i-x" /></svg>
</button>
</div>
<div class="mt-5 rounded-lg border border-border bg-muted/50 p-3">
<div class="flex gap-2">
<svg class="icon mt-0.5 shrink-0 text-muted-foreground"><use href="#i-info" /></svg>
<p class="text-xs leading-5 text-muted-foreground">
Ouça um trecho e informe quem é a pessoa. O acervo não sugere nem identifica
pessoas automaticamente.
</p>
</div>
</div>
<div class="mt-5">
<label for="speaker-name" class="text-sm font-medium">Nome do falante</label
><input
id="speaker-name"
class="field mt-1.5 text-sm"
placeholder="Digite o nome do falante"
/>
<p class="mt-2 text-xs text-muted-foreground">
O nome será aplicado a todas as falas deste agrupamento nesta gravação.
</p>
</div>
<div class="mt-6 flex justify-end gap-2">
<button class="btn btn-outline">Cancelar</button
><button class="btn btn-primary">Salvar identificação</button>
</div>
</section>
</div>
</section>
<!-- CENA 3 -->
<section class="mb-12" aria-labelledby="scene-3">
<h2 id="scene-3" class="scene-label">
Cena 3 · Desktop — lista com faceta “Pessoa” aplicada
</h2>
<div class="desktop-frame flex">
<aside class="flex w-[220px] shrink-0 flex-col border-r border-border bg-stone-50 p-3">
<div class="flex h-12 items-center gap-2 px-2 font-semibold">
<span class="grid size-8 place-items-center rounded-lg bg-primary text-sm text-white"
>T</span
>
Campanha
</div>
<nav class="mt-3 space-y-1 text-sm">
<div class="rounded-md px-3 py-2 text-muted-foreground">Início</div>
<div class="rounded-md px-3 py-2 text-muted-foreground">Municípios</div>
<div class="rounded-md px-3 py-2 text-muted-foreground">Pessoas</div>
<div
class="rounded-md bg-white px-3 py-2 font-medium text-primary ring-1 ring-border"
>
Comunicação
</div>
<div class="ml-5 border-l border-border py-1 pl-3">
<div
class="rounded-md bg-white px-3 py-2 font-medium text-primary ring-1 ring-border"
>
Acervo
</div>
<div class="px-3 py-2 text-muted-foreground">Cortes</div>
</div>
</nav>
</aside>
<div class="min-w-0 flex-1">
<header class="flex min-h-11 items-center gap-3 border-b border-border px-4">
<span class="text-muted-foreground">☰</span>
<div>
<p class="text-sm font-semibold">Acervo</p>
<p class="text-xs text-muted-foreground">Comunicação</p>
</div>
</header>
<div class="p-6">
<div>
<h3 class="text-xl font-semibold">Acervo de falas</h3>
<p class="mt-1 text-sm text-muted-foreground">Gravações enviadas</p>
</div>
<div class="mt-5 inline-flex rounded-lg bg-secondary p-1 text-sm">
<span class="rounded-md px-3 py-2 text-muted-foreground">Falas da Câmara</span
><span
class="rounded-md bg-white px-3 py-2 font-medium shadow-sm ring-1 ring-border"
>Gravações enviadas</span
>
</div>
<label class="mt-5 block text-sm font-medium">Buscar nas gravações enviadas</label>
<div
class="mt-1.5 flex min-h-11 items-center gap-2 rounded-lg border border-input px-3"
>
<svg class="icon text-muted-foreground"><use href="#i-search" /></svg
><span class="text-sm text-muted-foreground"
>Busque por palavra ou trecho da transcrição…</span
>
</div>
<div class="mt-2 flex gap-2">
<button class="btn btn-outline h-8 min-h-8 px-3">
<svg class="icon-sm"><use href="#i-filter" /></svg>Pessoa: Nome do falante
<svg class="icon-sm"><use href="#i-x" /></svg>
</button>
</div>
<div class="mt-5 space-y-3">
<article class="rounded-xl border border-border p-4">
<div class="grid grid-cols-[112px_minmax(0,1fr)_auto] gap-4">
<div class="aspect-video rounded-lg bg-stone-200"></div>
<div>
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold">Título da gravação</h4>
<span
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700"
>Pronto</span
>
</div>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
<p class="mt-2 text-sm leading-5">
Trecho da transcrição encontrado pela busca geral.
</p>
<div class="mt-2 flex items-center gap-2 text-xs">
<span class="chip"
><svg class="icon-sm"><use href="#i-user" /></svg>Nome do falante</span
><span class="text-muted-foreground">aparece nesta gravação</span>
</div>
</div>
<button class="btn btn-outline self-center">
Abrir<svg class="icon-sm"><use href="#i-chevron" /></svg>
</button>
</div>
</article>
<article class="rounded-xl border border-border p-4">
<div class="grid grid-cols-[112px_minmax(0,1fr)_auto] gap-4">
<div class="aspect-video rounded-lg bg-stone-200"></div>
<div>
<h4 class="text-sm font-semibold">Outra gravação enviada</h4>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
<p class="mt-2 text-sm leading-5">
Outro trecho continua alcançável pela busca de texto.
</p>
<div class="mt-2 flex items-center gap-2 text-xs">
<span class="chip"
><svg class="icon-sm"><use href="#i-user" /></svg>Nome do falante</span
><span class="text-muted-foreground">aparece nesta gravação</span>
</div>
</div>
<button class="btn btn-outline self-center">
Abrir<svg class="icon-sm"><use href="#i-chevron" /></svg>
</button>
</div>
</article>
</div>
</div>
</div>
</div>
</section>
<!-- CENA 4 -->
<section class="mb-12" aria-labelledby="scene-4">
<h2 id="scene-4" class="scene-label">
Cena 4 · Desktop — agrupamento aproximado / qualidade ruim
</h2>
<div class="desktop-frame p-8">
<div class="mx-auto max-w-4xl">
<div>
<h3 class="text-lg font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">Transcrição por falante</p>
</div>
<div class="mt-5 flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
<svg class="icon mt-0.5 shrink-0 text-amber-800"><use href="#i-info" /></svg>
<div>
<h4 class="text-sm font-semibold text-amber-950">
A separação por falante pode estar imprecisa
</h4>
<p class="mt-1 text-sm leading-5 text-amber-900">
O agrupamento é automático e aproximado. Ruído e falas sobrepostas podem colocar
trechos no agrupamento errado. A equipe continua responsável por identificar cada
falante.
</p>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<article class="speaker-block">
<header class="speaker-head">
<div>
<h5 class="text-sm font-semibold">Falante 1</h5>
<p class="text-[11px] text-muted-foreground">Agrupamento sem identificação</p>
</div>
<button class="btn btn-outline">
<svg class="icon-sm"><use href="#i-edit" /></svg>Identificar falante
</button>
</header>
<button class="seg">
<span class="text-xs text-muted-foreground">00:00</span
><span class="text-sm leading-5"
>Trecho agrupado automaticamente pelo áudio.</span
>
</button>
</article>
<article class="speaker-block">
<header class="speaker-head">
<div>
<h5 class="text-sm font-semibold">Falante 2</h5>
<p class="text-[11px] text-muted-foreground">Agrupamento sem identificação</p>
</div>
<button class="btn btn-outline">
<svg class="icon-sm"><use href="#i-edit" /></svg>Identificar falante
</button>
</header>
<button class="seg">
<span class="text-xs text-muted-foreground">00:19</span
><span class="text-sm leading-5"
>Outro trecho pode conter sobreposição de vozes.</span
>
</button>
</article>
</div>
</div>
</div>
</section>
<!-- CENA 5 -->
<section aria-labelledby="scene-5">
<h2 id="scene-5" class="scene-label">
Cena 5 · Mobile (390 px) — detalhe com agrupamentos e identificação
</h2>
<div class="mobile-frame relative pb-20">
<header class="flex min-h-14 items-center gap-3 bg-primary px-4 text-white">
<svg class="icon"><use href="#i-back" /></svg>
<div>
<h3 class="text-sm font-semibold">Gravação enviada</h3>
<p class="text-xs text-white/75">Acervo</p>
</div>
</header>
<div class="p-4">
<div>
<h4 class="text-base font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<div class="relative mt-4 grid aspect-video place-items-center rounded-lg bg-stone-900">
<button class="grid size-12 place-items-center rounded-full bg-white">
<svg class="h-5 w-5 translate-x-0.5"><use href="#i-play" /></svg>
</button>
</div>
<div class="mt-3 flex gap-2 rounded-lg border border-border bg-muted/50 p-3">
<svg class="icon mt-0.5 shrink-0 text-muted-foreground"><use href="#i-info" /></svg>
<p class="text-xs leading-5 text-muted-foreground">
Agrupamento automático e aproximado. A identificação é feita pela equipe.
</p>
</div>
<section class="mt-5">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">TRANSCRIÇÃO POR FALANTE</h5>
<span class="text-[11px] text-muted-foreground">Toque para posicionar</span>
</div>
<div class="mt-2 space-y-3">
<article class="speaker-block">
<header class="border-b border-border bg-stone-50 p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="grid size-7 place-items-center rounded-full bg-muted"
><svg class="icon-sm"><use href="#i-user" /></svg
></span>
<h6 class="text-sm font-semibold">Falante 1</h6>
</div>
<button class="btn btn-outline h-9 min-h-9 px-2">
<svg class="icon-sm"><use href="#i-edit" /></svg>Identificar
</button>
</div>
</header>
<button class="seg seg-active">
<span class="text-xs text-primary">00:00</span
><span class="text-sm leading-5"
>Texto transcrito deste agrupamento.</span
></button
><button class="seg">
<span class="text-xs text-muted-foreground">00:38</span
><span class="text-sm leading-5">Outra fala da mesma voz.</span>
</button>
</article>
<article class="speaker-block">
<header class="border-b border-border bg-stone-50 p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span
class="grid size-7 place-items-center rounded-full bg-primary/10 text-primary"
><svg class="icon-sm"><use href="#i-user" /></svg
></span>
<div>
<h6 class="text-sm font-semibold">Nome do falante</h6>
<p class="text-[10px] text-muted-foreground">Identificado pela equipe</p>
</div>
</div>
<button
class="grid size-9 place-items-center rounded-lg border border-input bg-white"
aria-label="Editar identificação"
>
<svg class="icon-sm"><use href="#i-edit" /></svg>
</button>
</div>
</header>
<button class="seg">
<span class="text-xs text-muted-foreground">00:18</span
><span class="text-sm leading-5"
>Todas as falas deste agrupamento recebem o mesmo nome.</span
>
</button>
</article>
</div>
</section>
</div>
<nav
class="absolute inset-x-0 bottom-0 grid h-16 grid-cols-5 border-t border-border bg-white pt-2 text-[10px] text-muted-foreground"
>
<span class="text-center">Início</span><span class="text-center">Pessoas</span
><span class="text-center text-primary">Comunicação</span
><span class="text-center">Agenda</span><span class="text-center">Mais</span>
</nav>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,115 @@
# C200 — Separar falas por pessoa nas gravações enviadas
Status: rascunho
Atualizado em: 2026-09-18
Issue: #1167
Priority: P3
Impeccable: C — fluxo novo no acervo (rotulagem de falantes no detalhe + faceta "Pessoa" na fonte de gravações enviadas)
Design UI: docs/plans/acervo-separacao-por-pessoa-ui-design.html
Appetite: ~2–3 dias eng; um outcome verificável — a assessoria identifica quem fala numa gravação enviada e filtra o acervo pelas falas de uma pessoa
Responsável: —
## Intenção
As gravações enviadas ao acervo (C199) não têm só fala do Solla: plenárias e debates têm várias vozes, e a transcrição sai como um bloco único. A busca geral já cobre esse texto inteiro, mas não responde à pergunta que a assessoria faz o tempo todo: "o que **fulano** disse nesta gravação?". Este item é a camada de quem-falou-o-quê: a transcrição passa a vir dividida por agrupamento de falante, a assessoria identifica quem é cada voz e o acervo ganha um recorte por pessoa.
O guardrail é duro e vem do próprio pedido: separar por áudio é **agrupamento acústico** ("Falante 1", "Falante 2", …), e a identificação é **humana** — a assessoria ouve e rotula. Nada de voiceprint, nada de casar voz com pessoa conhecida, nada de identificar terceiros por biometria. Rótulo não preenchido continua "Falante N"; nunca se inventa identidade.
P3: melhora real o garimpo, mas depende do C199 e não bloqueia operação — o acervo segue útil com a busca geral enquanto isso.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação (`communicator`) — e também `coordinator`/`candidate` — na mesa, montando peça com prazo curto, garimpando em plenárias/debates enviados pela equipe; não é editor de transcrição nem curador de cadastro.
- **Job principal:** achar as falas de uma pessoa específica dentro de uma gravação e conseguir recortar as gravações em que ela fala.
- **Fluxo desejado:**
1. Abre o acervo na fonte "Gravações enviadas" e abre uma gravação.
2. A transcrição aparece dividida em "Falante 1", "Falante 2", … — sem nome inventado, sem sugestão automática de identidade.
3. Ouve o trecho e usa "Identificar falante" para rotular o agrupamento (ex.: "Falante 2" → "Dep. Jorge Solla"); o rótulo vale para todas as falas daquele agrupamento naquela gravação.
4. Volta à lista e usa a faceta "Pessoa" para ver só as gravações com falas daquela pessoa; a busca geral continua cobrindo a transcrição inteira.
5. Abre o trecho e assiste/baixa como já faz hoje.
- **Anti-goals de produto:** não vira biometria/identificação automática de voz; não cria cadastro de pessoas paralelo ao `Contact`; não troca nem esvazia a busca geral; não edita transcrição; não reprocessa o acervo antigo sozinho.
### Esboço de fluxo (C)
```text
[acervo: fonte "Gravações enviadas"] → abre a gravação
→ transcrição dividida em "Falante 1", "Falante 2", … (sem nome inventado)
→ ouve + "Identificar falante" → rótulo vale para todas as falas daquele agrupamento
→ volta à lista → faceta "Pessoa" recorta as gravações com falas daquela pessoa
→ abre o trecho, assiste/baixa → [outcome: a fala da pessoa certa na mão]
```
### Design UI (C)
- Design UI (gate): `docs/plans/acervo-separacao-por-pessoa-ui-design.html` (+ assets em `docs/plans/acervo-separacao-por-pessoa-ui-design-assets/`) — a produzir pelo designer.
- Por que C: há affordance nova no detalhe (rotular agrupamento) e estado novo na lista (faceta "Pessoa" só na fonte de gravações enviadas, com rótulos por gravação), dentro da tela existente. Design hi-fi obrigatório no gate e fonte de verdade do port.
## Objetivo e aceite
- Gravações enviadas (C199) têm a transcrição dividida por agrupamento de falante; o rótulo default é "Falante 1", "Falante 2", … (sempre com número).
- A assessoria rotula um agrupamento com "Identificar falante" no detalhe da gravação; o rótulo passa a valer para todas as falas daquele agrupamento naquela gravação.
- A faceta "Pessoa" existe na fonte "Gravações enviadas" e recorta as gravações em que aquele rótulo aparece; a busca geral continua alcançando a transcrição inteira, como hoje.
- Rótulo não preenchido permanece "Falante N"; o produto nunca sugere, infere ou completa identidade.
- Reprocessar a transcrição de uma gravação não perde os rótulos já feitos (ou exige re-vinculação clara e avisada) — requisito de produto, não detalhe técnico.
- Escopo de aplicação: gravações novas a partir deste item; reprocessar/diarizar as antigas é ação explícita, nunca automática.
- **Guardrails:** sem biometria de voz; identificação é humana; sem segundo cadastro de pessoa (rótulo é texto, não `Contact`); gate fail-closed do acervo (`communicator` + `coordinator`/`candidate`; demais negados); acervo segue interno; sem score de confiança de "quem falou" na UI.
## Dados (intenção)
- **Vou apresentar dados?** N/A — o filtro por pessoa é recorte de conteúdo/conteúdo editorial, não métrica nem agregado.
- **Decisões desbloqueadas:** N/A — a escolha da persona ("qual fala usar") é qualitativa.
- **Forma:** N/A — sem superfície de dados; restrição de produto: agrupamento é acústico e aproximado, então nada de confiança numérica por falante (número cru engana e sugere precisão que não existe).
## Dados da decisão (literais)
- **Rótulo default dos agrupamentos:** "Falante 1", "Falante 2", … (sempre com número; nunca nome inventado).
- **Ação de curadoria:** "Identificar falante" (rotular), no detalhe da gravação.
- **Faceta:** "Pessoa", na fonte "Gravações enviadas" do acervo.
- **Fonte:** "Gravações enviadas" (a fonte que o C199 popula).
- **Papéis:** `communicator` ("Assessor de Comunicação") + `coordinator`/`candidate`; demais negados (fail-closed) — mesmo gate do acervo.
- **Guardrail literal:** sem biometria de voz; identificação humana; sem segundo cadastro de pessoa.
- **Dependência dura:** C199.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/app/(campaign)/campanha/(app)/comunicacao/acervo/…` (lista + detalhe); `src/components/campaign/speech/SpeechAcervoFilters.tsx` e `src/components/campaign/shared/CampaignHeaderFilterPopover.tsx` (faceta multi-seleção reutilizável); `src/utilities/speech/speechListUrl.ts` (`SpeechFilterOptions` e params/URLs da lista), `speechListFilters.ts`, `speechOmnibox.ts`, `speechPageData.ts`; `src/utilities/ai/deepInfraTranscribe.ts` e o import (`src/utilities/speech/speechImport.ts`); `src/utilities/access/speeches.ts` (gate).
- **Precedente a olhar:** C154 (filtros/facetas do acervo), C153 (menções como texto; anti-goal de normalizar pessoa), C192 (recorte adicional na mesma tela), `src/collections/SpeechSegment.ts` (segmento hoje sem locutor, admin-hidden, substituído em bloco no import).
- **Risco de acoplamento:** o reprocessamento do import substitui os segmentos em bloco — um rótulo por trecho só sobrevive se o reprocessamento carregar/re-aplicar; o contrato de URL da lista sustenta deep-links e a faceta nova não pode quebrar as existentes; busca geral, cortes e gate de leitura intocados.
## Dependências
- **C199** (dura, bloqueante) — sem as gravações transcritas não há o que separar.
- Soft: C192 (busca semântica) compartilha a tela e a organização de filtros; não bloqueia nem é pré-requisito.
## Fora de escopo
- Biometria/voiceprint e qualquer identificação automática de pessoa.
- `Contact`/segundo cadastro de pessoa.
- Edição/correção de transcrição e correção de erros de ASR.
- Cortes/clipes e biblioteca de cortes.
- Busca semântica (C192) — recorte ortogonal sobre a mesma tela.
- Publicação externa, compartilhamento e Sollinha/dossiês.
- Backfill automático de diarização nas gravações antigas.
## Rabbit holes de produto
- **Virar reconhecimento biométrico.** Se alguém "só completar": voiceprint, match automático voz↔pessoa, identificação de terceiros. **Corte neste item:** agrupamento acústico + rótulo humano; nada automático.
- **Cadastro de pessoas/vozes.** Se alguém "só completar": diretório de falantes, entidade nova, merge entre gravações. **Corte:** rótulo é texto por gravação; sem unificação automática.
- **Transcrição perfeita por falante.** Se alguém "só completar": correção de sobreposição, refino de palavras, edição de texto. **Corte:** agrupamento é aproximado; fala sobreposta pode cair no agrupamento errado e a assessoria re-rotula.
- **Identificar automaticamente autoridades.** Se alguém "só completar": inferir "quem presidia" ou "o deputado" por heurística. **Corte:** se quiserem marcar presidência, é rótulo humano como qualquer outro.
## Questões em aberto (produto)
- **Qual provedor/etapa faz a diarização?** **Opções:** A) provedor com diarização nativa no pipeline de transcrição | B) etapa de diarização sobre o áudio já transcrito | C) diarização local no homeserver. **Recomendação:** decisão do plano de implementação, com custo por minuto medido e fallback honesto — o ASR atual (Whisper large-v3) **não** entrega diarização; sem a etapa, a transcrição volta a bloco único (nunca fingir separação). _(assumido — validar no gate)_
- **Reprocessar as gravações antigas?** **Opções:** A) backfill automático de todo o acervo | B) ação explícita por gravação | C) não oferecer. **Recomendação:** B — o custo por minuto não justifica varrer tudo agora, e reprocessar pode mexer em rótulos já feitos; quando rodar, é avisado e não perde rótulo. _(assumido — validar no gate)_
- **Mesma pessoa em gravações diferentes?** **Opções:** A) unificação automática por voz (biometria — proibida) | B) texto livre por gravação, sem unificação; o filtro casa pelo texto do rótulo | C) diretório sugerido + merge manual (item futuro). **Recomendação:** B — sem segunda fonte de identidade nem matching biométrico; se as variações de nome ("Solla" vs "Dep. Jorge Solla") fragmentarem o filtro, é curadoria, outro item se doer. _(assumido — validar no gate)_
- **O que mostrar quando o agrupamento é ruim (sobreposição/ruído)?** **Opções:** A) esconder a separação quando a qualidade for baixa | B) mostrar mesmo aproximada, com aviso discreto de que o agrupamento é automático | C) permitir marcar "não sei quem falou". **Recomendação:** B (+ C se couber no design) — transparência sobre o limite; esconder quebra a confiança no que apareceu. _(assumido — validar no gate)_
## Referências
- GitHub Issue #1167
- Design UI (gate): `docs/plans/acervo-separacao-por-pessoa-ui-design.html` (+ assets em `docs/plans/acervo-separacao-por-pessoa-ui-design-assets/`)
- Dependência: C199 (upload de gravações no acervo — a definir planos irmãos)
- Planos irmãos: `docs/plans/acervo-videos-comunicacao.md` (C154), `docs/plans/catalogo-falas-solla.md` (C153), `docs/plans/acervo-busca-semantica.md` (C192)
- Arquivos-pista: `src/components/campaign/speech/SpeechAcervoFilters.tsx`, `src/components/campaign/shared/CampaignHeaderFilterPopover.tsx`, `src/utilities/speech/speechListUrl.ts`, `src/utilities/speech/speechImport.ts`, `src/collections/SpeechSegment.ts`, `src/utilities/ai/deepInfraTranscribe.ts`
- `AGENTS.md` — RBAC de `/campanha`, acervo interno, LGPD/consentimento fail-closed

View file

@ -0,0 +1,838 @@
<!doctype html>
<html lang="pt-BR" data-theme="campaign-site">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Design — S15 · Card Time de você</title>
<style type="text/tailwindcss">
:root {
--background: #ffffff;
--foreground: #000000;
--pt-red: #a21c1c;
--pt-red-dark: #47130e;
--pt-yellow: #ffe607;
--campaign-cream: #fff8f2;
--campaign-ink: #000000;
--campaign-muted: #6c615e;
--campaign-surface: #f9faff;
--campaign-band: #ebe9e9;
--campaign-line: rgb(0 0 0 / 12%);
--field-border: rgb(0 0 0 / 18%);
--team-red: #e50e2f;
--team-yellow: #ffec01;
--team-blue: #0061a5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
}
button,
input {
font: inherit;
}
button:focus-visible,
input:focus-visible,
a:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--pt-red);
outline-offset: 2px;
}
.checker {
background-color: #f4f1ed;
background-image:
linear-gradient(45deg, #e6e1dc 25%, transparent 25%),
linear-gradient(-45deg, #e6e1dc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e6e1dc 75%),
linear-gradient(-45deg, transparent 75%, #e6e1dc 75%);
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-size: 16px 16px;
}
.range-track {
appearance: none;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, var(--pt-red) 0 46%, #d6d3d1 46% 100%);
}
.range-track::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border: 3px solid white;
border-radius: 999px;
background: var(--pt-red);
box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
}
</style>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-[1440px] px-4 py-10 sm:px-6 lg:px-10">
<header class="max-w-4xl">
<p class="m-0 text-xs font-black tracking-[0.12em] text-red-400 uppercase">
Gate visual · S15
</p>
<h1 class="mt-2 text-2xl font-black tracking-[-0.02em] sm:text-3xl">Card “Time de você”</h1>
<p class="mt-3 max-w-3xl text-sm leading-6 text-zinc-400">
Visitante, principalmente no celular, escolhe o quarto modelo, informa nome e foto de
busto, acompanha o recorte local, confere o enquadramento e baixa o card. As cenas mantêm
o compositor vigente: dialog no desktop, drawer no mobile, um único CTA primário e arte
oficial sem redesenho.
</p>
<div class="mt-5 flex flex-wrap gap-2 text-[11px] font-bold text-zinc-300">
<span class="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2"
>1280px · desktop</span
>
<span class="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2"
>390px · mobile</span
>
<span class="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2"
>alvos ≥ 44px</span
>
<span class="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2"
>contraste ≥ 4,5:1</span
>
</div>
</header>
<!-- Cena 1: galeria desktop -->
<section class="mt-14">
<div class="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 1 · Home / #cards · desktop 1280
</h2>
<p class="mt-1 text-xs text-zinc-500">
Quatro tiles em uma linha: a arte continua dominante e a seção não ganha CTA
concorrente.
</p>
</div>
<p class="text-xs font-bold text-zinc-400">
Decisão: <span class="text-zinc-200">grid 4 × 1 em lg</span>
</p>
</div>
<div
class="mt-4 w-full max-w-[1280px] overflow-hidden rounded-xl border border-zinc-700 bg-[var(--campaign-cream)] text-[var(--campaign-ink)] shadow-2xl"
>
<section
id="cards"
aria-labelledby="cards-title-desktop"
class="border-b border-[var(--campaign-line)] px-10 py-14"
>
<div class="mx-auto max-w-2xl text-center">
<p class="m-0 text-xs font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Faça parte
</p>
<h3 id="cards-title-desktop" class="mt-1 text-2xl font-black tracking-[-0.02em]">
Mostre que você está com Solla
</h3>
<p class="mt-2 text-base leading-6 text-[var(--campaign-muted)]">
Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com
sua gente e fortaleça nossa caminhada.
</p>
</div>
<ul aria-label="Modelos de card" class="mt-8 grid list-none grid-cols-4 gap-5 p-0">
<li>
<button
aria-label="Card com seu nome"
class="group flex h-full min-h-11 w-full flex-col rounded-xl text-left"
>
<span
class="relative block aspect-[3/4] w-full overflow-hidden rounded-xl border border-[var(--campaign-line)] bg-[var(--campaign-band)] transition group-hover:-translate-y-0.5 group-hover:shadow-lg"
>
<img
src="../../public/cards/name-card-base.jpg"
alt=""
class="absolute inset-0 size-full object-cover"
/>
<span
class="absolute top-[30%] left-[19.7%] max-w-[66%] text-left text-[clamp(12px,1.55vw,22px)] leading-none font-black text-[#ffec01]"
>SEU NOME</span
>
</span>
<span class="mt-3 block w-full text-center text-sm font-bold"
>Card com seu nome</span
>
</button>
</li>
<li>
<button
aria-label="Moldura quadrada"
class="group flex h-full min-h-11 w-full flex-col rounded-xl text-left"
>
<span
class="checker relative block aspect-square w-full overflow-hidden rounded-xl border border-[var(--campaign-line)] transition group-hover:-translate-y-0.5 group-hover:shadow-lg"
>
<img
src="../../public/cards/photo-square-frame.png"
alt=""
class="absolute inset-0 size-full object-cover"
/>
</span>
<span class="mt-3 block w-full text-center text-sm font-bold"
>Moldura quadrada</span
>
</button>
</li>
<li>
<button
aria-label="Moldura vertical"
class="group flex h-full min-h-11 w-full flex-col rounded-xl text-left"
>
<span
class="checker relative block aspect-[25/36] w-full overflow-hidden rounded-xl border border-[var(--campaign-line)] transition group-hover:-translate-y-0.5 group-hover:shadow-lg"
>
<img
src="../../public/cards/photo-portrait-frame.png"
alt=""
class="absolute inset-0 size-full object-cover"
/>
</span>
<span class="mt-3 block w-full text-center text-sm font-bold"
>Moldura vertical</span
>
</button>
</li>
<li>
<button
aria-label="Time de você"
class="group flex h-full min-h-11 w-full flex-col rounded-xl text-left"
>
<span
class="relative block aspect-[3/4] w-full overflow-hidden rounded-xl border border-[var(--campaign-line)] bg-[var(--campaign-band)] transition group-hover:-translate-y-0.5 group-hover:shadow-lg"
>
<img
src="../../public/cards/team-card-example.jpg"
alt=""
class="absolute inset-0 size-full object-cover"
/>
<span
class="absolute top-3 right-3 rounded-full bg-white/95 px-2.5 py-1 text-[10px] font-black text-[var(--team-blue)] shadow"
>NOVO</span
>
</span>
<span class="mt-3 block w-full text-center text-sm font-bold">Time de você</span>
</button>
</li>
</ul>
</section>
</div>
</section>
<!-- Cena 2: galeria mobile -->
<section class="mt-14">
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 2 · /cards · mobile 390
</h2>
<p class="mt-1 text-xs text-zinc-500">
A intro e a dica dizem “quatro”; o primeiro tile ocupa 78% e o segundo aparece como pista
de rolagem.
</p>
<div
class="mt-4 w-[390px] max-w-full overflow-hidden rounded-[22px] border border-zinc-700 bg-white text-black shadow-2xl"
>
<div class="px-5 pt-5">
<a
href="#"
class="inline-flex min-h-11 items-center text-sm font-bold text-[var(--pt-red)] underline-offset-4"
>← Voltar para a campanha</a
>
</div>
<section aria-labelledby="cards-page-title-mobile" class="px-5 pt-6 pb-10">
<div class="text-center">
<p class="text-[10px] font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Faça parte
</p>
<h3
id="cards-page-title-mobile"
class="mt-1 text-[22px] leading-tight font-black tracking-[-0.02em]"
>
Crie seu card de apoio
</h3>
<p class="mt-2 text-sm leading-5 text-[var(--campaign-muted)]">
Escolha um dos quatro modelos, personalize com seu nome ou sua foto e baixe para
compartilhar.
</p>
<p class="mt-2 text-xs leading-5 text-[var(--campaign-muted)]">
Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para
nós.
</p>
</div>
<div class="mt-7 flex w-[710px] snap-x gap-4">
<button
aria-label="Card com seu nome"
class="flex w-[273px] shrink-0 flex-col rounded-xl"
>
<span
class="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-[var(--campaign-line)] bg-[var(--campaign-band)]"
>
<img
src="../../public/cards/name-card-base.jpg"
alt=""
class="absolute inset-0 size-full object-cover"
/>
<span
class="absolute top-[30%] left-[19.7%] text-[18px] leading-none font-black text-[#ffec01]"
>SEU NOME</span
>
</span>
<span class="mt-3 text-center text-sm font-bold">Card com seu nome</span>
</button>
<button
aria-label="Moldura quadrada"
class="flex w-[273px] shrink-0 flex-col rounded-xl"
>
<span
class="checker relative aspect-square w-full overflow-hidden rounded-xl border border-[var(--campaign-line)]"
>
<img
src="../../public/cards/photo-square-frame.png"
alt=""
class="absolute inset-0 size-full object-cover"
/>
</span>
<span class="mt-3 text-center text-sm font-bold">Moldura quadrada</span>
</button>
</div>
<p class="mt-2 text-center text-xs font-semibold text-[var(--campaign-muted)]">
Deslize para ver os quatro modelos
</p>
<div class="mt-5 flex justify-center gap-2" aria-hidden="true">
<span class="h-1.5 w-6 rounded-full bg-[var(--pt-red)]"></span
><span class="size-1.5 rounded-full bg-stone-300"></span
><span class="size-1.5 rounded-full bg-stone-300"></span
><span class="size-1.5 rounded-full bg-stone-300"></span>
</div>
</section>
</div>
</section>
<!-- Cena 3: inicial desktop -->
<section class="mt-14">
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 3 · Compositor inicial · dialog desktop
</h2>
<p class="mt-1 text-xs text-zinc-500">
Nome e foto são pedidos juntos; instrução de qualidade sem prometer recorte perfeito. O
CTA final segue desabilitado.
</p>
<div
class="relative mt-4 flex min-h-[760px] w-full max-w-[1280px] items-center justify-center overflow-hidden rounded-xl border border-zinc-700 bg-[var(--campaign-cream)] p-8 text-black shadow-2xl"
>
<div
class="absolute inset-0 bg-[linear-gradient(135deg,#fff8f2,#ebe9e9)] opacity-80"
></div>
<div class="absolute inset-0 bg-black/45"></div>
<article
role="dialog"
aria-modal="true"
aria-labelledby="initial-title"
class="relative z-10 flex max-h-[92dvh] w-[512px] max-w-full flex-col overflow-hidden rounded-xl border border-black/10 bg-white shadow-2xl"
>
<header class="flex items-start gap-3 px-5 pt-5">
<div class="min-w-0">
<p class="text-xs font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Time de você
</p>
<h3 id="initial-title" class="mt-1 text-xl font-black tracking-[-0.01em]">
Entre para o time
</h3>
</div>
<button
aria-label="Fechar"
class="ml-auto inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-2xl text-[var(--campaign-muted)] hover:bg-[var(--campaign-band)]"
>
×
</button>
</header>
<div class="overflow-y-auto px-5 pt-4 pb-5">
<div
class="mx-auto w-[172px] overflow-hidden rounded-lg border border-[var(--campaign-line)] shadow-sm"
>
<img
src="../../public/cards/team-card-example.jpg"
alt="Exemplo do card Time de você"
class="block aspect-[3/4] w-full object-cover"
/>
</div>
<div class="mt-5">
<label for="desktop-name" class="block text-sm font-bold text-[var(--campaign-ink)]"
>Seu nome</label
>
<input
id="desktop-name"
value=""
placeholder="Como você é chamado"
class="mt-2 h-11 w-full rounded-lg border border-[var(--field-border)] bg-white px-3 text-base placeholder:text-stone-500"
/>
<p class="mt-2 text-xs leading-5 text-[var(--campaign-muted)]">
Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido.
</p>
</div>
<div
class="mt-5 rounded-lg border border-[var(--campaign-line)] bg-[var(--campaign-surface)] p-4"
>
<p class="text-sm font-bold">Sua foto de busto</p>
<p class="mt-1 text-sm leading-5 text-[var(--campaign-muted)]">
Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples.
</p>
<button
class="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white px-5 text-sm font-extrabold text-[var(--pt-red)] hover:bg-[var(--campaign-band)]"
>
Escolher foto
</button>
</div>
<p class="mt-4 text-xs leading-5 text-[var(--campaign-muted)]">
Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para
nós.
</p>
</div>
<footer class="flex gap-3 border-t border-[var(--campaign-line)] px-5 py-4">
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white px-5 text-sm font-extrabold text-[var(--pt-red)]"
>
Cancelar
</button>
<button
disabled
class="inline-flex min-h-11 flex-1 cursor-not-allowed items-center justify-center rounded-lg bg-[var(--pt-red)] px-5 text-sm font-extrabold text-white opacity-50"
>
Criar meu card
</button>
</footer>
</article>
</div>
</section>
<!-- Cena 4: processamento mobile -->
<section class="mt-14">
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 4 · Recorte em processamento · drawer mobile 390
</h2>
<p class="mt-1 text-xs text-zinc-500">
A primeira execução explicita download do modelo e processamento local; não promete
duração.
</p>
<div
class="relative mt-4 h-[780px] w-[390px] max-w-full overflow-hidden rounded-[22px] border border-zinc-700 bg-[var(--campaign-cream)] text-black shadow-2xl"
>
<div class="px-5 py-10 opacity-50">
<p class="text-xs font-black text-[var(--pt-red)] uppercase">Faça parte</p>
<h3 class="mt-1 text-2xl font-black">Mostre que você está com Solla</h3>
<div class="mt-5 h-72 rounded-xl bg-stone-200"></div>
</div>
<div class="absolute inset-0 bg-black/50"></div>
<article
role="dialog"
aria-modal="true"
aria-labelledby="processing-title"
class="absolute inset-x-0 bottom-0 overflow-hidden rounded-t-2xl border-t border-black/10 bg-white"
>
<div class="mx-auto mt-2 h-1.5 w-10 rounded-full bg-stone-300"></div>
<header class="flex items-start gap-3 px-5 pt-4">
<div>
<p class="text-[10px] font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Time de você
</p>
<h3 id="processing-title" class="mt-1 text-lg font-black tracking-[-0.01em]">
Preparando sua foto
</h3>
</div>
<button
aria-label="Fechar"
class="ml-auto inline-flex size-11 items-center justify-center rounded-lg text-2xl text-[var(--campaign-muted)]"
>
×
</button>
</header>
<div class="px-5 pt-4 pb-5">
<div
class="mx-auto w-[144px] overflow-hidden rounded-lg border border-[var(--campaign-line)] shadow-sm"
>
<img
src="../../public/cards/team-card-base.png"
alt="Prévia do modelo enquanto a foto é preparada"
class="block aspect-[3/4] w-full object-cover"
/>
</div>
<div class="mt-5" role="status" aria-live="polite">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-bold">Baixando o modelo de recorte…</p>
<p class="text-sm font-black text-[var(--pt-red)]">36%</p>
</div>
<div
class="mt-2 h-2 overflow-hidden rounded-full bg-stone-200"
aria-label="Progresso do recorte: 36%"
>
<div class="h-full w-[36%] rounded-full bg-[var(--pt-red)]"></div>
</div>
<p class="mt-3 text-sm leading-5 text-[var(--campaign-muted)]">
Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da
foto no seu aparelho. Isso pode levar um pouco.
</p>
</div>
<div class="mt-4 flex items-start gap-3 rounded-lg bg-[var(--campaign-cream)] p-3">
<span
aria-hidden="true"
class="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--team-blue)] text-xs font-black text-white"
>✓</span
>
<p class="text-xs leading-5 text-[var(--campaign-muted)]">
Sua foto fica neste aparelho e não é enviada para nós.
</p>
</div>
</div>
<footer class="flex gap-3 border-t border-[var(--campaign-line)] px-5 py-4">
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white text-sm font-extrabold text-[var(--pt-red)]"
>
Cancelar
</button>
<button
disabled
class="inline-flex min-h-11 flex-1 cursor-wait items-center justify-center rounded-lg bg-[var(--pt-red)] text-sm font-extrabold text-white opacity-50"
>
Processando…
</button>
</footer>
</article>
</div>
</section>
<!-- Cena 5: pronto para criar -->
<section class="mt-14">
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 5 · Recorte pronto + enquadramento · dialog desktop
</h2>
<p class="mt-1 text-xs text-zinc-500">
A prévia real confirma “TIME DE MARIA”; arrastar e controles de zoom/ajuste fino são
escapes opcionais.
</p>
<div
class="relative mt-4 flex min-h-[850px] w-full max-w-[1280px] items-center justify-center overflow-hidden rounded-xl border border-zinc-700 bg-[var(--campaign-band)] p-8 text-black shadow-2xl"
>
<div class="absolute inset-0 bg-black/45"></div>
<article
role="dialog"
aria-modal="true"
aria-labelledby="ready-title"
class="relative z-10 flex max-h-[92dvh] w-[512px] max-w-full flex-col overflow-hidden rounded-xl border border-black/10 bg-white shadow-2xl"
>
<header class="flex items-start gap-3 px-5 pt-5">
<div>
<p class="text-xs font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Time de você
</p>
<h3 id="ready-title" class="mt-1 text-xl font-black">Confira seu card</h3>
<p class="mt-1 text-sm leading-5 text-[var(--campaign-muted)]">
O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.
</p>
</div>
<button
aria-label="Fechar"
class="ml-auto inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-2xl text-[var(--campaign-muted)]"
>
×
</button>
</header>
<div class="overflow-y-auto px-5 pt-4 pb-5">
<div
role="group"
aria-label="Ajuste da foto"
tabindex="0"
class="relative mx-auto w-[240px] overflow-hidden rounded-lg border border-[var(--campaign-line)] shadow-md"
>
<img
src="../../public/cards/team-card-example.jpg"
alt="Prévia do card TIME DE MARIA"
class="block aspect-[3/4] w-full object-cover"
/>
<div
class="pointer-events-none absolute top-[15.5%] left-1/2 flex h-[12.2%] w-[47%] -translate-x-1/2 -rotate-[4.1deg] items-center justify-center bg-[var(--team-blue)] px-1 text-center text-[27px] leading-none font-black tracking-tight text-white shadow-sm"
>
MARIA
</div>
<span
class="absolute right-2 bottom-2 rounded bg-black/70 px-2 py-1 text-[10px] font-bold text-white"
>Arraste para ajustar</span
>
</div>
<div class="mt-5 flex items-center gap-3">
<label for="desktop-zoom" class="text-sm font-bold">Zoom</label>
<button
aria-label="Diminuir zoom"
class="inline-flex size-11 shrink-0 items-center justify-center rounded-lg border border-[var(--campaign-line)] bg-white text-xl font-bold"
>
−
</button>
<input
id="desktop-zoom"
aria-label="Nível de zoom"
type="range"
value="46"
class="range-track h-11 min-w-0 flex-1"
/>
<button
aria-label="Aumentar zoom"
class="inline-flex size-11 shrink-0 items-center justify-center rounded-lg border border-[var(--campaign-line)] bg-white text-xl font-bold"
>
+
</button>
</div>
<div class="mt-4">
<p class="text-sm font-bold">Ajuste fino</p>
<div class="mt-2 grid grid-cols-4 gap-2">
<button
aria-label="Mover foto para a esquerda"
class="inline-flex min-h-11 items-center justify-center rounded-lg border border-[var(--campaign-line)] bg-white text-lg"
>
←
</button>
<button
aria-label="Mover foto para cima"
class="inline-flex min-h-11 items-center justify-center rounded-lg border border-[var(--campaign-line)] bg-white text-lg"
>
↑
</button>
<button
aria-label="Mover foto para baixo"
class="inline-flex min-h-11 items-center justify-center rounded-lg border border-[var(--campaign-line)] bg-white text-lg"
>
↓
</button>
<button
aria-label="Mover foto para a direita"
class="inline-flex min-h-11 items-center justify-center rounded-lg border border-[var(--campaign-line)] bg-white text-lg"
>
→
</button>
</div>
</div>
<button
class="mt-4 inline-flex min-h-11 w-full items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white px-5 text-sm font-extrabold text-[var(--pt-red)]"
>
Trocar foto
</button>
<p class="mt-3 text-xs leading-5 text-[var(--campaign-muted)]">
Sua foto fica neste aparelho e não é enviada para nós.
</p>
</div>
<footer class="flex gap-3 border-t border-[var(--campaign-line)] px-5 py-4">
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white text-sm font-extrabold text-[var(--pt-red)]"
>
Cancelar
</button>
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[var(--pt-red)] px-5 text-sm font-extrabold text-white shadow-sm"
>
Criar meu card
</button>
</footer>
</article>
</div>
</section>
<!-- Cena 6: resultado mobile -->
<section class="mt-14">
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 6 · Resultado · drawer mobile 390
</h2>
<p class="mt-1 text-xs text-zinc-500">
Uma ação primária: baixar. “Criar outro” permanece secundária e devolve ao catálogo.
</p>
<div
class="relative mt-4 h-[780px] w-[390px] max-w-full overflow-hidden rounded-[22px] border border-zinc-700 bg-[var(--campaign-cream)] text-black shadow-2xl"
>
<div class="absolute inset-0 bg-black/50"></div>
<article
role="dialog"
aria-modal="true"
aria-labelledby="result-title"
class="absolute inset-x-0 bottom-0 overflow-hidden rounded-t-2xl bg-white"
>
<div class="mx-auto mt-2 h-1.5 w-10 rounded-full bg-stone-300"></div>
<header class="flex items-start gap-3 px-5 pt-4">
<div>
<p class="text-[10px] font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Tudo certo
</p>
<h3 id="result-title" class="mt-1 text-lg leading-6 font-black">
Seu card está pronto para compartilhar.
</h3>
</div>
<button
aria-label="Fechar"
class="ml-auto inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-2xl text-[var(--campaign-muted)]"
>
×
</button>
</header>
<div class="px-5 pt-4 pb-5">
<div
class="mx-auto w-[190px] overflow-hidden rounded-lg border border-[var(--campaign-line)] shadow-md"
>
<img
src="../../public/cards/team-card-example.jpg"
alt="Seu card Time de Maria pronto"
class="block aspect-[3/4] w-full object-cover"
/>
<div class="pointer-events-none absolute"></div>
</div>
<div class="mt-4 flex items-start gap-3 rounded-lg bg-[var(--campaign-cream)] p-3">
<span
aria-hidden="true"
class="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-green-700 text-xs font-black text-white"
>✓</span
>
<p class="text-xs leading-5 text-[var(--campaign-muted)]">
Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para
nós.
</p>
</div>
</div>
<footer class="flex gap-3 border-t border-[var(--campaign-line)] px-5 py-4">
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white px-3 text-sm font-extrabold text-[var(--pt-red)]"
>
Criar outro card
</button>
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[var(--pt-red)] px-3 text-sm font-extrabold text-white shadow-sm"
>
Baixar meu card
</button>
</footer>
</article>
</div>
</section>
<!-- Cena 7: erros -->
<section class="mt-14 pb-10">
<h2 class="text-xs font-black tracking-[0.12em] text-zinc-500 uppercase">
Cena 7 · Erros recuperáveis · conteúdo do compositor
</h2>
<p class="mt-1 text-xs text-zinc-500">
Os dois erros preservam o que a pessoa já informou e oferecem recuperação direta, sem beco
sem saída.
</p>
<div class="mt-4 grid max-w-[1048px] gap-6 lg:grid-cols-2">
<article
class="overflow-hidden rounded-xl border border-zinc-700 bg-white text-black shadow-xl"
>
<header class="flex items-start gap-3 px-5 pt-5">
<div>
<p class="text-xs font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Falha no recorte
</p>
<h3 class="mt-1 text-xl font-black">Vamos tentar outra vez</h3>
</div>
<button
aria-label="Fechar"
class="ml-auto inline-flex size-11 items-center justify-center rounded-lg text-2xl text-[var(--campaign-muted)]"
>
×
</button>
</header>
<div class="px-5 pt-4 pb-5">
<div role="alert" class="rounded-lg border border-[var(--pt-red)] bg-red-50 p-4">
<p class="text-sm font-black text-[var(--pt-red)]">
Não foi possível remover o fundo desta foto.
</p>
<p class="mt-1 text-sm leading-5 text-[var(--campaign-ink)]">
Tente de novo ou escolha outra foto.
</p>
</div>
<p class="mt-4 text-sm leading-5 text-[var(--campaign-muted)]">
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
</p>
<p class="mt-3 text-xs leading-5 text-[var(--campaign-muted)]">
Sua foto fica neste aparelho e não é enviada para nós.
</p>
<div class="mt-5 flex gap-3">
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white px-3 text-sm font-extrabold text-[var(--pt-red)]"
>
Escolher outra foto
</button>
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[var(--pt-red)] px-3 text-sm font-extrabold text-white"
>
Tentar de novo
</button>
</div>
</div>
</article>
<article
class="overflow-hidden rounded-xl border border-zinc-700 bg-white text-black shadow-xl"
>
<header class="flex items-start gap-3 px-5 pt-5">
<div>
<p class="text-xs font-black tracking-[0.1em] text-[var(--pt-red)] uppercase">
Nome muito longo
</p>
<h3 class="mt-1 text-xl font-black">Encurte o nome</h3>
</div>
<button
aria-label="Fechar"
class="ml-auto inline-flex size-11 items-center justify-center rounded-lg text-2xl text-[var(--campaign-muted)]"
>
×
</button>
</header>
<div class="px-5 pt-4 pb-5">
<label for="error-name" class="block text-sm font-bold">Seu nome</label>
<input
id="error-name"
aria-invalid="true"
value="MARIA DA CONCEIÇÃO OLIVEIRA"
class="mt-2 h-11 w-full rounded-lg border-2 border-[var(--pt-red)] bg-white px-3 text-base"
/>
<p role="alert" class="mt-2 text-sm leading-5 font-semibold text-[var(--pt-red)]">
Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é
chamado.
</p>
<p class="mt-4 text-xs leading-5 text-[var(--campaign-muted)]">
A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você corrige
o nome.
</p>
</div>
<footer class="flex gap-3 border-t border-[var(--campaign-line)] px-5 py-4">
<button
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-[var(--campaign-line)] bg-white text-sm font-extrabold text-[var(--pt-red)]"
>
Cancelar
</button>
<button
disabled
class="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[var(--pt-red)] text-sm font-extrabold text-white opacity-50"
>
Criar meu card
</button>
</footer>
</article>
</div>
</section>
<aside class="border-t border-zinc-800 pt-8 text-xs leading-5 text-zinc-500">
<p>
<strong class="text-zinc-300">Arte e composição preservadas:</strong> base → foto
recortada → overlay → textos; banners #E50E2F / #0061A5, texto #FFEC01 / #FFFFFF. A prévia
usa o exemplo oficial; nenhuma arte nova foi inventada.
</p>
<p class="mt-2"><strong class="text-zinc-300">NEEDS ASSET:</strong> nenhum.</p>
</aside>
</main>
</body>
</html>

View file

@ -0,0 +1,113 @@
# Card "Time de você": quarto modelo auto-serviço no funil público
Status: registrado (aguardando plano em main)
Atualizado em: 2026-09-18
Issue: #1161
Priority: P1
Impeccable: C — modelo novo num funil público existente (não é superfície nova)
Design UI: docs/plans/cards-time-de-voce-ui-design.html
Appetite: ~2–3 dias eng; um quarto modelo verificável no funil já entregue, sem fluxo novo
Responsável: —
## Intenção
O funil de cards do site (seção `#cards` da home + `/cards`, S13/#941, ajustado em S14/#959) tem três modelos — um de nome e dois de foto. Falta o card que mais circula na militância: a pessoa entra no "time" com a própria cara. O visitante digita o nome, envia uma foto de busto, o fundo é removido no próprio aparelho (nada é enviado) e o card `TIME DE <NOME>` fica pronto para baixar, com os candidatos ao fundo, a foto recortada no centro e os banners no topo.
É a continuação natural do funil — S13 excluía "remoção de fundo" e "novos modelos" do escopo. Não é superfície nova: quarto item do catálogo, mesmo editor, mesma promessa de privacidade.
## Persona e fluxo
- **Persona / contexto:** visitante/militante no celular, quer mostrar que entrou no time do Solla; tem uma foto de busto razoável na galeria.
- **Job principal:** virar `TIME DE <NOME>` em um card oficial sem instalar nada, sem criar conta e sem enviar a foto para ninguém.
- **Fluxo desejado:** vê o quarto modelo na home ou em `/cards` → escolhe `Time de você` → digita o nome → escolhe uma foto de busto → vê o recorte acontecer com progresso no aparelho → confere a prévia (auto-enquadrada; arrastar/zoom como escape) → baixa o PNG.
- **Anti-goals de produto:** editor gráfico/máscara manual, cadastro, galeria pública, analytics de PII, promessa de recorte perfeito, segundo editor.
### Esboço de fluxo (C)
```text
[home/#cards ou /cards] → [modelo "Time de você"] → [nome + foto de busto]
→ [remoção de fundo local: progresso | erro + retry] → [prévia auto-enquadrada + ajuste opcional]
→ [card pronto] → [baixar PNG]
```
### Design UI (C)
- Design UI (gate): `docs/plans/cards-time-de-voce-ui-design.html`
- Cenas: tile do quarto modelo na galeria, estados de processamento/erro do recorte, prévia com a geometria final e resultado, em desktop e mobile.
## Objetivo e aceite
- O funil ganha o quarto modelo, `Time de você`, ao lado dos três atuais; home e `/cards` continuam um único editor e um único fluxo.
- O visitante digita o nome e escolhe uma foto de busto; o card final estampa `TIME DE <NOME>` nos banners do topo (nome em caixa alta) e a foto recortada no centro, sobre o fundo oficial com céu e candidatos.
- A remoção de fundo acontece no aparelho: nenhum byte da foto é enviado; a nota de privacidade do funil segue visível no novo modelo.
- Durante o recorte a pessoa vê progresso; se falhar, vê um erro claro com opção de tentar de novo e não fica presa.
- A foto entra auto-enquadrada (topo do recorte encostado no topo do espaço previsto, centrada), com os controles de arrastar/zoom já existentes como ajuste opcional.
- Nome em uma linha: se não couber no menor tamanho legível, a pessoa é pedida a usar um nome mais curto (mesma mensagem do modelo de nome) — nunca corte silencioso.
- Fidelidade ao exemplo de referência: posições, rotações, cores e corpo do nome seguem o literal medido; arte-mestre intocada (compor por cima, nunca redesenhar).
- `/cards` e o link com modelo pré-selecionado (`?model=time-de-voce`) continuam funcionando.
- Escolha de foto, retry do recorte e download funcionam por toque e teclado.
- A expectativa é honesta: o pedido é foto de busto com fundo simples — sem prometer recorte perfeito.
- Sem conta, cadastro ou analytics de PII; o nome e a imagem final nunca saem do aparelho.
## Dados (intenção)
- **Vou apresentar dados?** Não.
- **Decisões desbloqueadas:** N/A — nenhuma métrica é apresentada; não há decisão operacional desbloqueada por dado.
- **Forma:** _adiada ao plano de implementação_ — nome e foto são insumos locais da imagem; nenhum conteúdo pessoal entra em analytics.
## Dados da decisão (literais)
- ID `S15`; tipo `feature`; Priority `P1`; Model `composer-2.5`; Impeccable `C — modelo novo num funil público existente (não é superfície nova)`; Design UI: `docs/plans/cards-time-de-voce-ui-design.html`.
- Slug do plano: `cards-time-de-voce` (arquivo `docs/plans/cards-time-de-voce.md`).
- Model id `time-de-voce`; label de galeria `Time de você`; texto do card `TIME DE <NOME>` (nome normalizado em CAIXA ALTA pelo pipeline existente `normalizeCardName`).
- Assets commitados com o plano em `public/cards/`: `team-card-base.png` (fundo céu+candidatos, 1080×1440, ~1,5 MB), `team-card-front.png` (overlay marca/faixa, 1080×1440 RGBA, ~86 KB), `team-card-example.jpg` (exemplo final p/ tile da galeria, 1080×1440, ~268 KB).
- Ordem de composição: base → foto do usuário (fundo removido) → overlay frontal → textos do topo.
- Slot da foto (silhueta-placeholder do exemplo): x=286, y=439, largura=592, altura=577; auto-enquadramento cobrindo o slot, ancorado no topo e centrado (bbox alfa do recorte).
- Banner "TIME DE" (vermelho): centro ≈ (545, 162), ≈ 693×118, rotação ≈ −3,5°, fill `#E50E2F`; texto "TIME DE" centralizado, Brexter 700, cap ≈ 93 px, fill `#FFEC01`.
- Banner do nome (azul): centro ≈ (545, 313), ≈ 509×176, rotação ≈ −4,1°, fill `#0061A5`; nome centralizado, Brexter 700, cap ≈ 130 px, tinta máxima ≈ 470 px, 1 linha, cap mínimo ≈ 56 px, fill `#FFFFFF`.
- Cores literais: vermelho `#E50E2F`, amarelo `#FFEC01`, azul `#0061A5`, branco `#FFFFFF`.
- Copy: a página `/cards` e a dica da galeria hoje dizem "três modelos" → viram "quatro modelos" (o título/eyebrow da home não mudam). A nota de privacidade do funil continua valendo (`CARD_PRIVACY_NOTE`/`CARD_PHOTO_PRIVACY_NOTE`) e a remoção de fundo é 100% local.
- Motor de remoção de fundo: roda no navegador, sem upload da foto; recomendação `@imgly/background-removal` (mesma família do `@imgly/background-removal-node` já usado em `scripts/resize-images.mjs --remove-bg`), modelo pequeno (`isnet_quint8`, ~40 MB na 1ª vez, cacheado), carregado sob demanda SÓ quando o modelo time é escolhido, com estados de progresso/erro/retry. Alternativa mais leve (menor qualidade de borda): MediaPipe selfie segmenter (~250 KB, Apache-2.0). A escolha final do motor fica para o plano de implementação, mas o aceite exige: nenhum byte da foto sai do aparelho.
- Foto: auto-enquadrada (sem exigir ajuste), mas recomenda-se manter os controles de arrastar/zoom dos modelos de foto existentes como escape. Nome: 1 linha; se não couber no cap mínimo, erro pedindo nome mais curto (mesma mensagem do modelo de nome).
## Direção no codebase (hipótese)
- **Áreas prováveis:** catálogo `src/lib/cardModels.ts`, roteiro/render `src/lib/cardRender.ts`, fit `src/lib/cardNameFit.ts`, transform `src/lib/cardPhotoTransform.ts`, componentes `src/components/cards/`, página `src/app/(frontend)/(home)/cards/page.tsx`, seção `CampaignCardsSection.tsx`, assets `public/cards/`.
- **Precedente a olhar:** S13/S14 (`docs/plans/cards-personalizados-campanha-impl.md`, `docs/plans/cards-personalizados-ajustes.md`); `scripts/resize-images.mjs --remove-bg` para o motor; c167 (ffmpeg.wasm rejeitado por peso + COOP/COEP global que quebraria embed do YouTube).
- **Risco de acoplamento:** o composer carrega UMA imagem base e ramifica por `kind`; o roteiro não tem save/rotate/translate; os testes pinam os 3 ids (`tests/unit/cardModels.unit.spec.ts`) e o e2e (~:1694–1821) falha em qualquer console error e não cobre WASM; o motor deve funcionar SEM COOP/COEP (single-thread).
## Dependências
- S13 (#941) e S14 (#959) entregues; assets do quarto modelo já em `public/cards/`, commitados com o plano.
## Fora de escopo
- Modelos adicionais, CMS de modelos, métricas de uso.
- Compartilhamento direto em redes sociais.
- Recorte perfeito: cabelo fino, fundo complexo, várias pessoas, máscara manual (pincel/borracha).
- Conta, cadastro, persistência de nome/foto/arte gerada.
- Qualquer envio (mesmo parcial) da foto, ou processamento no servidor.
- Alterar as artes-mestre ou redesenhar home/`/cards`.
## Rabbit holes de produto
- **Motor "melhor" a qualquer custo.** Se alguém "só completar": entra uma dependência pesada ou headers globais que quebram o embed do YouTube (precedente c167). **Corte neste item:** motor sob demanda, sem COOP/COEP, carregado só se o modelo time for escolhido.
- **Prometer recorte perfeito.** Se alguém "só completar": vira editor de máscara com pincel e correção manual. **Corte neste item:** foto de busto com fundo simples, auto-recorte + retry.
- **Auto-enquadramento virar editor.** Se alguém "só completar": nasce um passo obrigatório de posicionamento. **Corte neste item:** auto + controles existentes como escape.
- **Segundo editor/segundo fluxo.** Se alguém "só completar": a home ganha compositor próprio e os comportamentos divergem. **Corte neste item:** o mesmo compositor de `/cards`.
- **Medir fidelidade "no olho".** Se alguém "só completar": aproximação visual vira critério. **Corte neste item:** geometria literal medida no exemplo (seção Dados da decisão).
## Questões em aberto (produto)
- **Qual motor de remoção de fundo?** **Opções:** `@imgly/background-removal` no navegador (borda melhor; ~40 MB na 1ª vez, cacheado; licença AGPL — self-host vs CDN a decidir na implementação) | MediaPipe selfie segmenter (~250 KB, Apache-2.0, borda mais fraca). **Recomendação:** `@imgly/background-removal`, com a ressalva AGPL a resolver na implementação. _(assumido — validar com produto)_
- **Auto-enquadramento puro ou com ajuste manual?** **Opções:** auto puro | auto + controles existentes. **Recomendação:** auto + controles existentes como escape. _(assumido — validar com produto)_
- **Qual rótulo do modelo na galeria?** **Opções:** `Time de você` | `Entre para o time`. **Recomendação:** `Time de você`. _(assumido — validar com produto)_
## Referências
- GitHub Issue #1161
- Design UI (gate): `docs/plans/cards-time-de-voce-ui-design.html` (+ assets em `cards-time-de-voce-ui-design-assets/`)
- `public/cards/team-card-example.jpg` — referência visual final do card
- `docs/plans/cards-personalizados-campanha.md` e `cards-personalizados-campanha-impl.md`; `docs/plans/cards-personalizados-ajustes.md`
- `src/lib/cardModels.ts`, `src/components/cards/CardComposer.tsx`, `src/app/(frontend)/(home)/cards/page.tsx`
- `AGENTS.md` e `AGENTS-public.md`

View file

@ -0,0 +1,154 @@
# Impl: Gráficos de dados para Instagram (skill de comunicação)
Status: em execução
Atualizado em: 2026-09-18
Issue: #1145
Intenção: docs/plans/graficos-dados-instagram.md
Appetite restante: herdado (~2–3 dias eng) — sem corte
Design tier: openai/gpt-5.6-sol (crítica de fechamento certificada; sem bloqueantes)
## Leitura da intenção
- **Outcome:** numa invocação, transformar dados colados/em arquivo (`xlsx/csv/md/txt/texto`) num **gráfico PNG** legível, na paleta Solla, com título-manchete e um destaque de cor, no tamanho do Instagram (`1080×1350` default, `1080×1080`, `1080×1920`). O tipo segue a relação dos dados (barra, coluna, linha, número-âncora); dado ambíguo/faltando → pergunta, nunca completa; render 100% local; artefato gitignored.
- **O que NÃO negociar:** bars/colunas partem do zero; ≤ ~7 pontos; sem pizza grande, 3D ou eixo truncado; rótulos ≥30px e headline ≥48px; contraste ≥4.5:1 (texto) / ≥3:1 (marcas); cor nunca é a única pista; sem persistir dado/schema/migration; **sem segundo pipeline irmão do renderer do dossiê**; dado de campanha nunca sai da máquina; PNG/intermediários nunca commitados (repo público).
- **O que reavaliar (hipóteses do "Direção no codebase"):** (a) que os primitivos de `dossieRender.mjs` são reaproveitáveis como estão — são `const` privados, A4/mm/pt e com `CHART_COLORS` de domínio dossiê; o reuso exige **extração parametrizada**, não `import` direto. (b) que `buildPdf.mjs` serve a saída imagem — ele só tem caminho `page.pdf()`; falta um caminho `page.screenshot()`. (c) que `xlsx`/`csv-parse` exigem dependência nova — já são devDeps. (d) que o subagente é necessário — a etapa semântica é pequena (1 dataset, 1 escolha) e não isola contexto pesado como o C186.
## Abordagem recomendada
```mermaid
flowchart LR
U["Comunicação: /graficos-dados &lt;dados&gt;"] --> S["skill graficos-dados (orquestra)"]
S -->|arquivo ou texto colado| B["scripts/build-chart-from-data.mjs"]
B --> D["scripts/lib/chartData.mjs — parse + classifica relação + valida guardrails"]
D -->|ambíguo ou faltando| Q["skill pergunta — nunca inventa"]
Q --> B
D -->|chart-spec ok| R["scripts/lib/graficosInstagramRender.mjs — canvas 1080, headline, plot, rodapé"]
R --> G["scripts/lib/chartPrimitives.mjs — barra/coluna/linha/âncora (emissão SVG)"]
G -. mesmo dono .-> P["dossieRender.mjs (A4, saída inalterada)"]
R --> H["scripts/lib/buildPdf.mjs — screenshotHtmlPng"]
H --> O["PNG gitignored: docs/research/graficos-instagram/"]
O --> U
```
**Opções consideradas:** A (extrair o dono dos primitivos + caminho de screenshot no `buildPdf`; superfície só skill+command) | B (renderer Instagram novo e isolado, sem tocar `dossieRender`; subagente classificador) | C (dependência de chart library, ex. vega/Chart.js, e/ou MCP de gráfico).
**Recomendação:** A — é o que a intenção manda ("estender/extrair o dono"), cabe no appetite e mantém o núcleo de parsing/classificação puro e testável.
**Rejeitadas:** B porque gemina o desenho de gráfico (anti-goal explícito) e um subagente sem contexto pesado a isolar só acrescenta handoff/pin; C porque traz bundler/CDN/dep para um caminho que precisa ser local, determinístico e sem rede.
### Decisões de engenharia
**(i) Reuso/extração dos primitivos de gráfico sem geminar renderer nem quebrar o A4**
- **Opções:** A) Extrair a **emissão SVG** dos tipos (barra, coluna, linha, âncora) para `scripts/lib/chartPrimitives.mjs`, parametrizada por `{ width, height, colors, classNames, format, highlight }`, e rewiring de `dossieRender.mjs` para consumi-la com os defaults A4 atuais (saída byte-idêntica); o renderer Instagram chama os mesmos emissores com canvas 1080 e paleta Solla. | B) Renderer Instagram copia os primitivos (twin). | C) Instagram renderer importa `dossieRender.mjs` e reescreve CSS por cima.
- **Recomendação:** A — `barChart/valueList/columnChart/stackedColumnChart` e `CHART_COLORS` (L898–993) são usados **só dentro** de `dossieRender.mjs` (8 call sites em L1791–1840), então a extração é contida: o dono passa a ser um módulo deep de desenho de gráfico; cada consumidor mantém a **composição de página** (dossiê A4 × canvas Instagram). Linha e número-âncora não existem hoje e nascem nesse mesmo dono (não num irmão). A extração é provada sem mudança por um **golden spec** que compara a string SVG emitida antes/depois para fixtures fixas.
- **Alternativas rejeitadas:** B porque o anti-goal é explícito e correção de palette/guardrail passaria a exigir dois edits (drift); C porque os primitivos não são exportados e arrastam CSS A4/`CHART_COLORS` de domínio para dentro do caminho Instagram (o médium é genuinamente outro: px, paleta Solla, headline/rodapé).
**(ii) Artefato intermediário (HTML→PNG) e execução do Chromium/screenshot**
- **Opções:** A) Adicionar `screenshotHtmlPng(browser, { html, width, height, outPath })` ao dono `scripts/lib/buildPdf.mjs` (reusa `launchPdfBrowser`; viewport exato; `page.screenshot({ type:'png', clip:[0,0,w,h] })`; `document.fonts.ready`; guarda de tamanho ≤8MB) e o renderer devolve HTML com CSS **inline**. | B) Rasterizar o PDF A4 existente. | C) Renderizar com `sharp`/`resvg` ou serviço externo.
- **Recomendação:** A — o `buildPdf.mjs` é o dono do launch/emit do Chromium; o novo caminho é **aditivo** e não toca constantes A4, `emulateMedia('print')` nem a lógica de fit (o PNG usa mídia `screen` e viewport `1080×(1350|1080|1920)`; o `.canvas` tem exatamente o tamanho de saída, então `clip` é o canvas). CSS inline porque o pipeline não tem bundler — o hi-fi usa Tailwind por CDN, que **não** pode ser dependência de rede no render. PNG do Chromium já é sRGB; a guarda de 8MB falha fechado (`die`).
- **Alternativas rejeitadas:** B porque exige o caminho A4 (páginas mm/pt, `preferCSSPageSize`) e ainda um rasterizador extra; C porque adiciona dependência/pipeline novo e (no serviço externo) viola "dado nunca sai da máquina".
**(iii) Superfície pública: skill + command + (sub)agente**
- **Opções:** A) **skill** `.agents/skills/graficos-dados/SKILL.md` (canônica) + **command** `.opencode/commands/graficos-dados.md` (homônimo, carrega a skill pelo nome e passa `$ARGUMENTS`); **sem** subagente. | B) skill + command + subagente classificador (`redator/classificador`) que devolve um recibo. | C) skill + command + subagente renderizador.
- **Recomendação:** A — a etapa semântica é pequena e cabe no agente principal (parse do formato + leitura da tabela + escolha do tipo + copy), e o builder determinístico faz a validação; o par skill+command é exigido pelo pin `opencodeCommands.unit.spec.ts` (nome exato, sem `model:`). Não há contexto pesado (pesquisa web multi-era) a isolar como no C186. Gatilho de revisitação: quando a invocação passar a aceitar **lote** (vários gráficos por comando) ou pesquisa web, reavalie um subagente por lote.
- **Alternativas rejeitadas:** B porque duplicaria os guardrails da skill num recibo/handoff sem ganho de contexto; C porque renderização é etapa determinística de script — nunca de LLM.
**(iv) Parsing de xlsx/csv/md/txt/texto com o mínimo de dependência**
- **Opções:** A) `xlsx` (já devDep L194) para `xlsx/xls`; `csv-parse/sync` (já devDep L171) para `csv`; sniffer de delimitador local para `txt` (`,`/`;`/`\t`); parser local de tabela GFM para `md`; fallback de pares `rótulo<sep>valor` para texto solto; `node:fs` para leitura. | B) Adicionar `papaparse` e/ou `mammoth` (docx). | C) Shell-out para `libreoffice`/`ssconvert`.
- **Recomendação:** A — zero dependência nova; tudo isolado em `scripts/lib/chartData.mjs` (puro, sem Chromium), com detecção de header e inferência de tipo numérico, e falha explícita quando a matriz não tem 2 colunas mínimas (`label` + `value`). `doc/docx` fica **fora do v1** (a intenção já decidiu) com gatilho de revisitação se houver demanda real.
- **Alternativas rejeitadas:** B porque `papaparse` é redundante com `csv-parse` e `mammoth` reabre o `docx` já cortado; C porque depende de binário externo e torna o determinismo ambiental.
**(v) Onde ficam tipos/classificação de relação e o que é unit-testável**
- **Opções:** A) Núcleo puro em `scripts/lib/chartData.mjs` — `parseInput`, `classifyRelation`, `validateSpec` e o shape `ChartSpec`; o entry `scripts/build-chart-from-data.mjs` é orquestrador fino; testes unit cobrem o núcleo. | B) Lógica dentro do entry `.mjs`. | C) Lógica em `src/lib/*.ts` compartilhada com o app.
- **Recomendação:** A — Dependency Rule: o núcleo (parse/classificação/guardrails) não conhece Chromium nem CSS e roda em `test:unit` sem DB/rede; o renderer/emissor é a camada externa. Unit-testável e pinado: parsing de cada formato (fixtures inline, sem PII), classificação (`tempo→linha/coluna`, `ranking/comparação→barra`, `1 medida→âncora`, ambíguo→`needsQuestion`), guardrails (base zero, ≤7 pontos, `pie` recusada no v1), mapeamento de tamanho→viewport, **golden** dos primitivos (`chartPrimitives`) e estrutura do HTML Instagram (canvas exato, paleta hex, headline ≥48px, rótulos ≥30px, rodapé/fonte).
- **Alternativas rejeitadas:** B porque não é importável e foge do pin de CI; C porque não é superfície de app (é ferramenta local) e travaria o núcleo ao build do Next.
### Componentes / mudanças
- **`.agents/skills/graficos-dados/SKILL.md`** (novo): frontmatter só `name`+`description` (aspas simples). Corpo: quando usar; fluxo (parse → propõe tipo → gera → confere/ajusta); **contrato do `chart-spec.json`**; uso do CLI (plain `node`, sem `tsx` — o builder é `.mjs` puro); guardrails; troubleshooting; links. Dado colado vira arquivo em `data/graficos-instagram/` antes do `--in`.
- **`.opencode/commands/graficos-dados.md`** (novo): frontmatter só `description:` (sem `model:`); corpo carrega a skill `graficos-dados` pelo nome exato, aponta `.agents/skills/graficos-dados/SKILL.md` e passa `$ARGUMENTS`. Satisfaz `tests/unit/opencodeCommands.unit.spec.ts`.
- **Sem `.opencode/agent/graficos-dados.md` no v1** (decisão iii).
- **`scripts/build-chart-from-data.mjs`** (novo entry, auto-entry em `knip.json` `scripts/*.mjs`): flags `--in=<path>|-` / `--text-file=`, `--spec=<json>` (replay sem reparse), `--inspect` (imprime a matriz parseada em JSON para o agente), `--type=`, `--headline=`, `--source=`, `--size=feed|square|story`, `--out=`. Lê → `parseInput` → (`classifyRelation` se `--type` ausente) → `validateSpec` (fail-closed) → `renderChartHtml` → `screenshotHtmlPng` → grava PNG + `chart-spec.json` intermediário. `die` via `dieWithLabel`/`parseEqualsFlags` de `scripts/lib/cli.mjs`.
- **`scripts/lib/chartData.mjs`** (novo): `parseInput`, `classifyRelation`, `validateSpec`, `SIZES` (1080×1350 / 1080×1080 / 1080×1920 + safe zones de stories 250px) e `CHART_TYPES` (`bar|column|line|anchor`). JSDoc para knip `types: error`. Sem Chromium/DOM.
- **`scripts/lib/chartPrimitives.mjs`** (novo): dono da emissão SVG dos tipos, extraído de `dossieRender.mjs` L895–993, parametrizado (`width/height/colors/classNames/format/highlight`); inclui o `lineChart` (novo) e o `proportionalPercent` (escala honesta compartilhada). A composição de barra/coluna/âncora do Instagram é CSS do template aprovado, com a proporção vinda do dono. Exportado e consumido pelos dois renderers.
- **`scripts/lib/graficosInstagramRender.mjs`** (novo): compõe canvas 1080 + regra de topo + kicker/contexto + headline + subtítulo + plot (via primitivos) + rodapé (fonte/nota + lockup tipográfico de marca) para os 3 tamanhos; CSS **inline**; paleta Solla verbatim; sem `asset-flag`/anotações do gate.
- **`scripts/lib/buildPdf.mjs`** (editar o dono): adicionar `screenshotHtmlPng` (aditivo; constantes A4 e `emitHtmlPairPdf` intactos).
- **`scripts/lib/dossieRender.mjs`** (editar o dono): remover os 5 `const` e importar de `chartPrimitives.mjs` com os defaults A4 atuais (saída inalterada).
- **Testes (novos):** `tests/unit/chartData.unit.spec.ts`, `tests/unit/chartPrimitives.unit.spec.ts` (golden), `tests/unit/graficosInstagramRender.unit.spec.ts`, `tests/unit/graficosDadosSkill.unit.spec.ts` (prosa da skill por strings literais).
- **`tests/unit/opencodeCommands.unit.spec.ts`** (editar): incluir `'graficos-dados'` no array `commands`.
- **`scripts/lib/test-affected-core.mjs`** (editar): adicionar `scripts/lib/chartData.mjs`, `scripts/lib/chartPrimitives.mjs`, `scripts/lib/graficosInstagramRender.mjs` a `SCRIPTS_SPEC_PINNED` (L31–90) — obrigatório porque os specs novos os importam; o invariante em `ciSkipInvariants.unit.spec.ts:192-216` recalcula o closure e exige igualdade exata.
- **`.gitignore`** (editar, após L93): bloco `# C191 — ...` com `/data/graficos-instagram/` e `/docs/research/graficos-instagram/`.
- **`docs/changelog/2026-09-18-c191-graficos-instagram.md`** (novo).
- **`package.json`:** sem dep nova e **sem script novo** (o builder é chamado direto, como os irmãos); nada de migration/schema/DB.
- **Access / Consent:** não se aplica (ferramenta local; sem collection, sem PII, sem escrita de banco).
### Dados → forma (pergunta 3 de data-presentation)
- **Forma escolhida:** **barras horizontais** para ranking/comparação (rótulo direto + valor na ponta, ordenado por valor, barras do zero); **colunas** para poucos períodos (base zero, `≤~7`); **linha** para série temporal contínua (ponto final destacado); **número-âncora** para uma única medida. Um único destaque `#c51414` (~≤10% da peça) sobre o resto neutro; fonte/ressalva dentro da imagem.
- **Rejeitadas:** pizza (v1 **não** gera; pedido `pie` é recusado com sugestão de barras — cena 05 do gate); pizza >5 fatias (recusa fail-closed); 3D/perspectiva/eixo truncado (nunca implementados); dashboard/segundo renderer/interatividade (fora de escopo).
## Fases verificáveis
1. **Tracer — extração sem mudança de comportamento** (quota: ~0,5 dia). Mover os primitivos para `chartPrimitives.mjs`, rewiring de `dossieRender.mjs`, golden spec comparando a string SVG. Prova: `pnpm test:unit` verde e `git diff` em `dossieRender.mjs` apenas de import/remoção.
2. **Tracer — screenshot PNG** (~0,5 dia). `screenshotHtmlPng` em `buildPdf.mjs` + smoke com um canvas dummy em cada tamanho; verificar dimensões e `<8MB`.
3. **Núcleo de dados** (~0,5 dia). `chartData.mjs` + `chart-spec` + unit specs de parse/classificação/guardrails.
4. **Renderer Instagram** (~0,5 dia). `graficosInstagramRender.mjs` para os 4 tipos × 3 tamanhos, paleta/contraste/tipografia mínimas, rodapé/lockup.
5. **Superfície + pinos** (~0,5 dia). skill + command; `opencodeCommands.unit.spec.ts`; `SCRIPTS_SPEC_PINNED`; `.gitignore`; changelog. Rodar a skill de ponta a ponta com `xlsx`, `csv`, `md` e texto.
6. **Gates** — `pnpm gate:fast` (lint + typecheck + unit); conferir `knip` (exports consumidos) e `check:cycles`; `pnpm push` (o PR CI roda a cascata).
## Rabbit holes / Não escopo (engenharia)
- **Segundo renderer de gráfico:** proibido; o dono é `chartPrimitives.mjs` (decisão i).
- **Parametrizar o dossiê A4 além do necessário:** não mudar layout, classes ou CSS do dossiê; a extração preserva a saída.
- **`docx`/`mammoth`, MCP de gráfico, serviço remoto:** fora do v1 (render local).
- **Editor gráfico:** sem cores/fontes/eixos configuráveis além de tipo/rótulo/tamanho/destaque.
- **Fontes self-hosted / logo oficial:** v1 usa stack de sistema e o lockup tipográfico do hi-fi; o asset oficial (NEEDS ASSET) é gatilho futuro.
- **DRY especulativo:** não criar abstrações para tipos não pedidos (pizza, stacked, área).
- **Migration/schema/DB e persistência de dado:** fora; artefato gitignored.
## Adiado com gatilho (triage pós-simplify)
- **Módulo-folha de constantes** (`SIZES`/`RELATION_LABEL` sem `xlsx`/`csv-parse`)
— hoje o renderer importa de `chartData.mjs`, arrastando os parsers. Gatilho:
um consumidor de `SIZES`/`RELATION_LABEL` sem parsers (renderer em bundle/browser).
- **Decompor `main()` do entry** em `buildSpec`/`emitPng`. Gatilho: modo lote/2ª
saída ou entry > ~200 linhas.
Cobertura de runtime (parse ambíguo pt-BR, teste do entry, smoke real do PNG):
registrada como `C191-runtime` (`depends: [1145]`), plano
`docs/plans/graficos-dados-instagram-runtime-followups.md`.
## Riscos e mitigação
- **Regressão no dossiê por tocar `dossieRender.mjs` (high-risk/spec-pinned):** extração mínima, golden spec dos primitivos, defaults idênticos; rodar os builders de dossiê num fixture e comparar HTML intermediário (amostra).
- **Fonte não determinística no PNG (Inter ausente / fallback):** v1 usa stack de sistema com `document.fonts.ready`; medir nos dois ambientes; se houver drift, avaliar embed de woff2 (novo item, não neste PR).
- **`page.screenshot` capturar tamanho errado:** `.canvas` com dimensões exatas + `clip` e viewport iguais; teste de dimensão do PNG.
- **PNG >8MB:** guarda falha fechado; arte é vetorial/flat (esperado ≪8MB).
- **Dados ambíguos/enganosos:** `validateSpec` fail-closed + fluxo de pergunta da skill; nunca completa nem infere.
- **`xlsx@0.18.5` tem CVEs conhecidas (devDep):** uso local com insumo da própria equipe; preferir CSV quando possível; documentar.
- **Pin de CI esquecido:** adicionar os 3 libs a `SCRIPTS_SPEC_PINNED` no mesmo commit; o invariante falha se divergir.
- **knip `exports: error`:** todo export novo precisa de consumidor real (entry/renderer/spec) — já coberto pelo desenho.
## Aceite de engenharia
- [x] Rodar a skill/entry com `xlsx`, `csv`, `md` e texto gera PNG `1080×1350` legível (default) e `1080×1080`/`1080×1920` sob `--size`, todos sRGB e <8MB.
- [x] Tipos essenciais (barra, coluna, linha, número-âncora) escolhidos pela relação dos dados; `pie` recusada; base zero; ≤7 pontos (5 em stories); um destaque `#c51414`; headline ≥48px e rótulos ≥30px; contraste atendido.
- [x] Dado ambíguo/faltando → pergunta; nada é inventado nem completado.
- [x] Nenhum dado bruto/PNG commitado: `.gitignore` cobre `data/graficos-instagram/` e `docs/research/graficos-instagram/`.
- [x] Saída do dossiê A4 inalterada após a extração dos primitivos (golden + specs de dossiê verdes).
- [x] Invariantes AGENTS/engineering-standards: sem schema/migration/DB; sem PII; identificadores em inglês, copy pt-BR; "edite o dono".
- [x] Testes de domínio previstos: `chartData` (parse/classificação/guardrails), `chartPrimitives` (golden), `graficosInstagramRender` (estrutura/medidas), prosa da skill, e `opencodeCommands` atualizado; `SCRIPTS_SPEC_PINNED` sincronizado.
- [x] `pnpm gate:fast` verde; knip sem export órfão; push via `pnpm push`.
## Self-score decision-quality
1. **Decisões caras têm rejeitadas? — 5/5.** As cinco decisões exigidas (extração dos primitivos, HTML→PNG/screenshot, superfície pública, parsing, núcleo de tipos) trazem Opções/Recomendação/Rejeitadas nomeadas.
2. **Abordagem cabe no appetite? — 4/5.** Extração contida + 3 módulos + 1 entry cabem em ~2–3 dias; o único risco de appetite é o rewrite de `dossieRender.mjs`, mitigado por extração mínima e golden.
3. **Rabbit holes nomeados? — 5/5.** Segundo renderer, editor, `docx`, MCP, fontes/logo, DRY especulativo e DB estão explicitamente cortados.
4. **Depth check (reusa shells/helpers)? — 5/5.** Reusa `buildPdf.launchPdfBrowser`/emit como dono do Chromium, `cli.mjs` (`dieWithLabel`/`parseEqualsFlags`), os primitivos extraídos do próprio dono e o padrão skill+command dos irmãos; nenhum caminho paralelo.
5. **Intenção (aceite de produto) permanece satisfeita? — 5/5.** O outcome, os guardrails e os anti-goals seguem intactos; a engenharia não reescreveu o produto.
**Média: 4,8 — passa o gate ≥4.** (Critério mais fraco: appetite, 4.)

View file

@ -0,0 +1,50 @@
# Runtime follow-ups C191 — cobertura do entry/PNG e parse ambíguo
Status: rascunho
Atualizado em: 2026-09-18
Issue: a registrar (`C191-runtime`, `depends: [1145]`)
Pai: #1145 / `docs/plans/graficos-dados-instagram-impl.md`
Appetite: ~0,5 dia eng (fill-in)
Impeccable: A (só backend/ferramenta; sem superfície de UI)
## Outcome
Fechar a cobertura de runtime da skill `graficos-dados` e tornar explícito o
único parsing hoje silencioso, sem mudar o comportamento entregue no C191.
## Fases (ordenadas por ROI)
1. **Parse ambíguo pt-BR (score 3).** `parseNumber('1.234')` hoje decide
milhar × decimal em silêncio. Documentar e cobrir com unit a regra do
separador único; se a decisão for por perguntar, expor no `issues` do
`parseInput` (fail-closed), nunca adivinhar.
2. **Teste do entry (score 3).** Cobrir `scripts/build-chart-from-data.mjs`:
`--inspect`, montagem do spec, `--spec` replay, `needsQuestion`, `--source`
obrigatório e a linha de log por tamanho. Sem Chromium (injetar o emit ou
mockar o browser).
3. **Smoke real do PNG (score 3).** Exercitar `screenshotHtmlPng` com Chromium
real por tamanho (feed/quadrado/story), verificando dimensões exatas e o
guard de 8 MB — hoje só há guard com browser fake.
## Já resolvido no simplify/critique (não reabrir)
- Perda silenciosa de linha com valor faltando (header heurístico) — corrigido
e pinado (`chartData.unit.spec.ts`).
- `--size` inválido e `--highlight` inexistente agora falham fechado.
- `escapeHtml` gêmeo removido (usa `reportText.htmlEscape`).
- Padding de stories derivado de `SIZES.story.safe*`; cap de 5 pontos em stories.
- `proportionalPercent` com zero honesto (0 → 0%); typo de copy.
## Explicitamente fora (descartes e defers deste triage)
- `SIZES.*.label` sem consumidor no código — dobra no item de fronteira abaixo
se `SIZES` for movido; não vira Issue.
- `SOLLA_PALETTE.ptRed` sem consumidor — contrato verbatim da intenção,
intencional.
- Contexto extra em logs do entry — ENOENT/`--spec` inválido já embrulhados;
valor marginal.
- **Adiado com gatilho:** mover `SIZES`/`RELATION_LABEL` para um módulo-folha
sem `xlsx`/`csv-parse` (fronteira do renderer). Gatilho: um consumidor de
`SIZES`/`RELATION_LABEL` sem parsers (renderer em bundle/browser).
- **Adiado com gatilho:** decompor `main()` do entry em `buildSpec`/`emitPng`.
Gatilho: o entry ganhar modo lote/2ª saída ou passar de ~200 linhas.

View file

@ -0,0 +1,54 @@
# Hardening do serving privado de reels (ramo S3)
Status: rascunho
Atualizado em: 2026-09-18
Issue: #1162
Priority: P2
Impeccable: A — N/A sem UI
Design UI: N/A — sem UI
Appetite: ~½ dia eng; cobertura do ramo de produção sem tocar o contrato
Responsável: —
## Intenção
O C193 (#1152) entregou o serving privado dos artefatos de reel com dois ramos no mesmo utilitário: disco local (dev/test) e S3/Garage (produção). A suíte só exercita o ramo de disco — o caminho que roda em produção (`HeadObjectCommand` → `GetObjectCommand` com `Range`, 206/416, 404 de objeto ausente, content-type/disposition) depende de inspeção, e uma regressão passaria o CI verde. Este item fecha essa lacuna com um teste que injeta o cliente S3 (sem depender de infraestrutura real) e pina o contrato de resposta do ramo de produção.
## Contexto
- Utilitário: `src/utilities/reels/reelMediaResponse.ts` (`buildReelMediaResponse`, `openS3Object`, `openLocalObject`).
- Regras puras (já cobertas por unit): `src/lib/reelMedia.ts` (`reelMediaContentType`, `reelMediaContentDisposition`, `reelMediaHeaders`).
- Cobertura atual: `tests/int/reel.int.spec.ts` exercita a rota e o disco local; `tests/unit/reel.unit.spec.ts` cobre o puro.
- Achados que motivaram o item (triage do C193): `isMissingObject` já checava um campo inexistente (`httpStatusCode`) antes do fix da sessão; o ramo S3 ficou sem prova.
## Objetivos
- Um teste (unit ou int, sem S3 real) cobre o ramo S3 de `buildReelMediaResponse`:
- objeto existente → 200 com `Content-Length`/`Content-Type`/`Cache-Control: private, no-store`;
- `Range` válido → 206 com `Content-Range` e slice correto;
- `Range` insatisfazível → 416 com `Content-Range: bytes */<size>`;
- `NoSuchKey`/404 → 404 silencioso (sem vazar erro do SDK);
- `ContentLength` ausente → erro interno (nunca 200 enganoso).
- O teste não sobe Garage/S3: injeta um cliente falso no ponto de resolução de storage (extrair uma costura testável se necessário, sem alterar o contrato público).
- Guardrails: nenhuma mudança de comportamento observável na rota; sem migration; sem Consent; `Media.read` intocado.
## Decisões em aberto (implementação)
- **Costura de injeção:** expor um override interno (`__setReelMediaStorageForTests`?) é feio; preferir extrair `openReelMediaObject({ storage, filename, rangeHeader })` como função de módulo testável com um storage fake, mantendo `reelMediaStorage()` privado. Decidir no impl plan.
- **Camada:** unit com fake do `S3Client` (rápido, sem DB) vs int com `vi.mock('@aws-sdk/client-s3')`. Preferir unit — o ramo não toca Payload/DB.
## Fases verificáveis
1. **Costura + unit do ramo S3** — extrair o ponto de injeção e cobrir a matriz acima.
2. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo
- Subir MinIO/Garage em CI para testar integração real (custo de infra desproporcional; o contrato do SDK é estável).
- Testar o ramo de disco de novo (já pinado no `reel.int.spec.ts`).
- Mudar o desenho do streaming (roteamento por presign, proxy self-fetch) — decisão travada no impl do C193.
- C194 (#1153) e C195 (#1154) — consumidores do contrato, não deste hardening.
## Aceite de engenharia
- [ ] Ramo S3 coberto com fake, sem infra externa e sem afrouxar asserções.
- [ ] Nenhuma mudança de comportamento no serving; `pnpm gate:fast` verde.

View file

@ -0,0 +1,151 @@
# Impl: Reel privado no CMS
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1152
Intenção: docs/plans/reels-reel-privado.md
Appetite restante: herdado (~1 dia eng; um reel registrável de ponta a ponta com arquivos privados e kill switch)
## Leitura da intenção
- **Outcome:** uma collection `reel` na vertical de Comunicação registra título, funcionalidade-alvo (`cards`) e os artefatos do reel (vídeo sem áudio primário, variante com áudio, mp3 da narração, `.srt`, transcrição e capa), com `status` `draft | published | unpublished` funcionando como kill switch. Os arquivos são privados: só abrem com login de campanha; `unpublished` deixa de servi-los. O site público e os cards não mudam. C194 (biblioteca) consome o modelo e o caminho autenticado de mídia; C195 (ingestão Local API) escreve no mesmo modelo.
- **O que NÃO negociar:**
- Privacidade do arquivo: anônimo e navegador sem sessão recebem não; `Media.read` público intocado (`src/collections/Media.ts:16-21`, pino em `tests/int/collectionAccessLockdown.int.spec.ts:224-249`).
- Site público e `#cards` idênticos; nenhuma URL pública nova.
- Sem publicação automática no Instagram, sem segundo cadastro de pessoa, sem clone de voz, sem `Consent` novo (mídia interna de staff, sem PII).
- Papéis da comunicação (`communicator`/`coordinator`/`candidate`); `advisor`/`leader` negados fail-closed.
- Arquivos aceitos como enviados: sem transcode, compressão, thumb derivada ou versionamento.
- **O que reavaliar:**
- `revalidateDocumentById` no `afterChange`/`afterDelete` do `reel`: não há cache público nem `unstable_cache` do reel. Dispersável; documentado abaixo (mantido apenas se houver leitura cacheada — não há no C193).
- e2e: sem UI no C193; a cobertura de fluxo fica para o C194. Registrado como decisão, não como lacuna.
- A questão de produto "`unpublished` esconde só da listagem ou invalida o arquivo?" conflita com a recomendação do C194. O C193 **adota B (invalidar o arquivo)** e marca como decisão assumida a confirmar no gate.
## Abordagem recomendada
```mermaid
flowchart TD
A[Admin / C195 Local API] -->|create/update| R[(collection reel)]
R --> V[video → reelMedia]
R --> VA[videoWithAudio → reelMedia]
R --> N[narrationAudio → reelMedia]
R --> S[captions → reelMedia]
R --> C[cover → reelMedia]
B[Browser logado] -->|GET /campanha/comunicacao/reels/:id/media/:kind| G{getCampaignUser + canReadCommunicationCatalog}
G -->|nega| N404[404]
G -->|ok| L[Local API find reel<br/>overrideAccess:false, user]
L -->|status != published| N404
L -->|campo vazio| N404
L --> H{S3 enabled?}
H -->|sim| S3[S3Client GetObjectCommand + Range]
H -->|não| D[disco: path.resolve staticDir ou 'reelMedia']
S3 --> RESP[206/200 · Content-Type · Content-Length · Accept-Ranges<br/>Content-Disposition · Cache-Control private,no-store]
D --> RESP
```
**Opções consideradas:** A | B | C
- **Serving privado — A (recomendada):** rota Next `GET /campanha/comunicacao/reels/[id]/media/[kind]/route.ts`; gate por `getCampaignUser()` + `canReadCommunicationCatalog`; carrega o `reel` com Local API `overrideAccess: false, user`; streama o objeto direto (S3 `GetObjectCommand` com `Range`, ou disco local). Reusa `parseRangeHeader`/`getRangeRequestInfo` de `payload/internal` (os mesmos helpers do handler local/S3, `node_modules/payload/dist/uploads/endpoints/getFile.js:19,51-53`). O cookie `campaign-token` tem `path='/campanha'` (`src/utilities/campaignAuth.ts:12-13`), e a rota vive sob `/campanha` — o cookie chega; `<video>`/`<img>` não mandam header, mas a rota é gated por sessão, não por `Authorization`.
- **B — URL assinada S3 (rejeitada):** `signedDownloads` aponta para o endpoint interno (`host.docker.internal:3900`), inalcançável pelo browser.
- **C — self-fetch para `/api/reelMedia/file/...` com `Authorization: JWT` (rejeitada, mantida como revisitação):** reusaria o streaming do plugin, mas exige request HTTP do processo a si mesmo pela URL pública (túnel Cloudflare), com risco de latência/loop. Preterida.
- **Modelo — A (recomendada):** collection `reel` (group `'Comunicação'`) + collection de upload privada `reelMedia`, separada de `media`. Justificativa: `media` é o contrato de leitura pública (`Media.ts:16-21`), pino de lockdown; colocá-la atrás de login quebraria o site. Rejeitadas: **reusar `media`** (quebra leitura anônima) e **um único campo upload com arquivos mistos** (perde discriminação de artefato, tipo e downloads por `kind` que o C194 consome).
- **RBAC — A (recomendada):** renomear `canReadSpeechCatalog` → `canReadCommunicationCatalog` em `src/lib/campaignRoles.ts:25` (o predicado é da vertical de comunicação inteira; reels usam o mesmo conjunto) e atualizar ~23 referências em 7 arquivos (`actions/speech.ts`, `campaignPageActor.ts`, `findSpeechExcerpts.ts`, `poster/route.ts`, `nav.ts`, `access/speeches.ts`, `schemas/speechVod.ts`) + barrel. Rejeitada: criar um twin `canReadReelCatalog` (duplicaria o predicado — fere "edit the owner, don't twin").
- **Feature — A (recomendada):** lista fechada em `src/lib/reel.ts`, começando por `cards`; rejeitada texto livre (variação de grafia, sem agrupamento).
- **Aprovação (`draft → published`) — A (recomendada, assumida):** qualquer um dos três papéis da comunicação; rejeitada restringir a coordinator/candidate (a assessoria produz e aprova; coordinator/candidate mantêm o kill switch).
**Recomendação:** modelo `reel` + `reelMedia`, access em `src/utilities/access/reels.ts`, rename do predicado, serving por rota autenticada sob `/campanha` streamando direto do S3/disco. Sem UI.
### Componentes / mudanças
- **`Reel`** (`src/collections/Reel.ts`, novo): slug `reel`; labels singular `Reel`, plural `Reels`; `admin.group: 'Comunicação'`; `useAsTitle: 'title'`; `defaultColumns: ['title', 'feature', 'status', 'publishedAt']`; `admin.description` explicando que os arquivos são privados e que despublicar tira do ar. Campos: `title` (text, required, `maxLength` de `src/lib/reel.ts`); `feature` (select, required, options de `REEL_FEATURES`); `status` (select, required, default `draft`, index, options de `REEL_STATUSES`, `admin.description` do kill switch); `video` (upload → `reelMedia`, required — primário sem áudio); `videoWithAudio` (upload → `reelMedia`, opcional); `narrationAudio` (upload → `reelMedia`, opcional); `captions` (upload → `reelMedia`, opcional); `transcript` (textarea, opcional — roteiro/transcrição); `cover` (upload → `reelMedia`, required); `publishedAt` (date, readOnly); `systemStampedActorField({ setAccess: canSetCampaignSystemField })`. Access: `create`/`update`/`read` = `canReadReel`, `delete` = `canDeleteReel` (predicado próprio, forma de `canDeleteSpeechCut` em `src/utilities/access/speeches.ts:39-44`). Hooks: `beforeChange: [stampCampaignCreatedBy]`. **Dispensa de `revalidateDocumentById`:** o `reel` não é lido por `getCachedDocumentById`/`unstable_cache` nem renderizado em RSC público; não há tag a bustar. Reavaliar se o C194 introduzir leitura cacheada.
- **`ReelMedia`** (`src/collections/ReelMedia.ts`, novo): slug `reelMedia`; labels singular `Mídia de reel`, plural `Mídias de reel`; `admin.group: 'Comunicação'`; `upload: true`; campo `alt` (text, required); access `read`/`create`/`update` = `canReadReel`, `delete` = `canDeleteReel`. **Não** entra em nenhuma superfície pública (sem rota `/api/reelMedia/file` consumida pelo site). Justificativa da separação versus `media`: o bucket é privado via proxy, mas o access de `media` é `read: () => true`; reusá-la entregaria os arquivos a qualquer um que descubra o filename.
- **`src/lib/reel.ts`** (novo): constantes puras e client-safe — `REEL_STATUSES` (`draft|published|unpublished`), `REEL_FEATURES` (`['cards']`), `REEL_TITLE_MAX_LENGTH`, `REEL_MEDIA_SLUG`, labels pt-BR (`reelStatusLabels`, `reelFeatureLabels`) e o mapa `kind → campo` (`REEL_MEDIA_KINDS`/`reelMediaFieldByKind`). **`src/lib/reelMedia.ts`** (novo): regras puras da resposta HTTP (content type allowlisted, content disposition, headers de range/cache). Fonte única consumida pela collection, pela rota e pelos unit tests.
- **`src/lib/campaignRoles.ts`** (editar): renomear `canReadSpeechCatalog` → `canReadCommunicationCatalog` e atualizar o comentário; nenhuma mudança de comportamento.
- **`src/utilities/access/reels.ts`** (novo): `canReadReel` e `canDeleteReel`, forma de `speeches.ts` — `isPayloadAdmin(req.user)` → `true`; `getFreshCampaignUser(req)` → `canReadCommunicationCatalog(role)`; senão `false`. `canDeleteReel` é predicado próprio (nunca alias de `canReadReel`), para que ampliar leitura não conceda delete. Reexport no barrel `src/utilities/campaignAccess.ts` (bloco após `speeches`, linhas ~172-177).
- **`src/utilities/reels/reelMediaResponse.ts`** (novo, subpasta de domínio — o top-level de `src/utilities/` é pinado por `tests/unit/codebaseConventions.unit.spec.ts:405-533`): responsabilidade única de montar a resposta de streaming, separando o puro do I/O — parser dos headers (`Range`, `?download=1` → `Content-Disposition: attachment`, `Cache-Control: private, no-store`, `Content-Type`, `Content-Length`, `Accept-Ranges`) testável sem rede, e a resolução do objeto (`resolveS3StorageEnv().enabled` → `S3Client` + `GetObjectCommand`; senão `path.resolve(staticDir || 'reelMedia', filename)`), com `parseRangeHeader`/`getRangeRequestInfo` para 206/416. `@aws-sdk/client-s3` já é dependência direta (`package.json`), usada em `scripts/recover-media.mjs`.
- **`route.ts`** (`src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/media/[kind]/route.ts`, novo): `export const dynamic = 'force-dynamic'`; `getCampaignUser()` + `canReadCommunicationCatalog` (senão 404); valida `id` inteiro e `kind ∈ REEL_MEDIA_KINDS`; Local API `find` do `reel` com `overrideAccess: false, user`, `depth: 0`; **kill switch** → só serve quando `status === 'published'` (`draft`/`unpublished` → 404); resolve o upload do campo do `kind` (vazio → 404); delega ao `reelMediaResponse`. Espelha o formato do precedente `.../acervo/[id]/poster/route.ts:53-89` (rota interna, 404 silencioso para não vazar existência).
- **`payload.config.ts`** (editar): importar `Reel`/`ReelMedia`, registrar em `collections` (junto de `Media`, linhas 100-133) e adicionar `reelMedia: true` ao `s3Storage({ collections: { media: true, reelMedia: true } })` (linhas 160-178). Sem isso, `reelMedia` cai em disco local mesmo com S3 ligado — inaceitável em prod. O plugin é condicionado a `mediaStorage.enabled` (`resolveS3StorageEnv`, fail-closed).
- **Migration:** `pnpm migrate:create add_reel` — cria `reel`, `reel_media`, enums (`enum_reel_status`, `enum_reel_feature`), colunas de FK para `reel_media`/`campaign_user`, `payload_locked_documents_rels`, índices (`status`). Referência de forma: `src/migrations/20260915_122356_add_speech_cut.ts`. Registrar `.ts`+`.json`+`index.ts`. **Nunca editar migrações antigas.**
- **Access / Consent:** `src/utilities/access/reels.ts` (novo) reexportado pelo barrel; leituras sempre `overrideAccess: false` + `user`; `Media.read` intocado. **Sem `Consent`** — mídia interna de staff, sem PII; nenhum fluxo de opt-in nasce aqui.
- **UI:** Impeccable **A — N/A sem UI**. Nenhum componente, shell ou tela neste item. A rota de mídia não é tela; o C194 monta a lista/detalhe e o painel de downloads consumindo o contrato.
### Dados → forma (se aplicável)
O C194 consome o contrato; o C193 fixa a forma. Cada artefato é um `upload` 1:1 com `reelMedia`; o `kind` da URL mapeia para o campo e para o content type:
| `kind` | Campo | Obrigatório | Content-Type típico |
| ------------- | ---------------- | ----------- | -------------------------- |
| `video` | `video` | sim | `video/mp4` |
| `video-audio` | `videoWithAudio` | não | `video/mp4` |
| `narration` | `narrationAudio` | não | `audio/mpeg` |
| `captions` | `captions` | não | `application/x-subrip` |
| `cover` | `cover` | sim | `image/png` / `image/jpeg` |
- Leitura de lista/detalhe para o C194: `reel` filtrado por `status: published` (a biblioteca) e o detalhe com os `kind`s disponíveis; nenhuma projeção nova no C193 além do que o access/admin já expõe.
- Sem agregação, sem métrica, sem gráfico. Nada a apresentar.
## Fases verificáveis
1. **Puro + RBAC (unit).** Criar `src/lib/reel.ts` (constantes, labels, mapa `kind → campo`, content types) e renomear `canReadSpeechCatalog` → `canReadCommunicationCatalog` com todas as referências e o barrel. Unit: boundary das listas, mapa `kind → campo`, parser puro de headers (`Range`/`download`/`Cache-Control`), comportamento do predicado renomeado.
2. **Collections + access + storage.** Criar `Reel`/`ReelMedia`, `src/utilities/access/reels.ts` + reexport, registrar em `payload.config.ts` e no `s3Storage.collections`. Editar `campaignAccess.ts`. `pnpm generate:types`.
3. **Migration (HARD-STOP de aprovação humana).** `pnpm migrate:create add_reel` → revisar SQL (tabelas, enums, FKs, rels, índices) → aprovação registrada → `pnpm migrate` local. Nunca editar migrações antigas.
4. **Serving privado.** Criar `src/utilities/reels/reelMediaResponse.ts` e a rota `.../reels/[id]/media/[kind]/route.ts`. Unit do parser e do mapa; int de integração da rota (matriz de papéis + kill switch).
5. **Testes int + e2e (decisão).** `tests/int/reel.int.spec.ts` e `tests/int/reelMedia.int.spec.ts` seguindo `tests/int/speechCut.int.spec.ts` (mock de `next/cache` conforme :22-26). Registrar que e2e de fluxo fica no C194 (sem UI); a mudança em `src/utilities/access` e em `src/lib/campaignRoles` já acorda os specs curados de RBAC/comunicação pelo manifest (`scripts/lib/e2e-affected-manifest.mjs:254,310-313`).
6. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Hard-stops (aprovação humana explícita)
1. **Migração de schema** (Fase 3): aprovada na sessão `--auto` (2026-09-18) para criar/aplicar `add_reel` **só no banco local** do worktree; nunca editar migrações antigas, nunca tocar banco remoto/prod.
2. **`unpublished` invalida o acesso ao arquivo** (recomendação B do C193): **decidido B** (2026-09-18) — `draft`/`unpublished` → 404 no serving. Se o C194 quiser link direto para despublicado, reabrir produto.
3. **Feature como lista fechada** (`cards`) e **aprovação por qualquer um dos três papéis**: **confirmadas** as recomendações A (2026-09-18).
## Rabbit holes / Não escopo (engenharia)
- Transcode, compressão, extração de thumb/frame, normalização de áudio, mux de vídeo+áudio.
- Versionamento/revisão de artefatos: um artefato por formato; trocar = substituir.
- STT/geração de `.srt`/legenda automática ou clone de voz.
- Galeria pública, embed no site, publicação/agendamento no Instagram, métricas sociais.
- CRUD de catálogo de funcionalidades (o vocabulário começa fixo).
- UI de biblioteca, player 9:16, painel de downloads e rotas autenticadas de página — C194.
- Upload/ingestão automática de pacote e watcher do homeserver — C195.
- Renomear `Media` ou mudar seu access; rota pública nova; `Consent` novo.
## Riscos e mitigação
- **Vazamento de arquivo privado por `media`/rota pública.** Mitigação: `reelMedia` separada, sem superfície pública; `Media.read` intocado e pinado no lockdown; rota sempre gated + Local API `overrideAccess: false`.
- **S3 ausente/parcial.** Mitigação: `resolveS3StorageEnv` fail-closed; dev/test caem em disco local. Risco residual: esquecer `reelMedia: true` no plugin — coberto no aceite e por inspeção de config.
- **Cookie não chega à rota.** Mitigação: rota sob `/campanha` (path do cookie) e gate por `getCampaignUser()`; sem depender de header em `<video>`.
- **Streaming incorreto (Range/206/416/Content-Length).** Mitigação: reusar `parseRangeHeader`/`getRangeRequestInfo` do `payload/internal` (mesma semântica do plugin) e isolar o cálculo puro em unit com `Range` inválido/aberto/fechado.
- **Kill switch com cache.** Mitigação: `Cache-Control: private, no-store` em toda resposta; sem `unstable_cache` do reel. Se o C194 introduzir cache, o hook de revalidação volta ao dono.
- **Rename do predicado tocar superfície ampla.** Mitigação: rename mecânico com unit do predicado e o manifest acordando `campaignSpeechAcervo`/`campaignSpeechCut`.
- **e2e não cobre o fluxo novo.** Mitigação: decisão registrada (C194); a superfície de risco (`src/utilities/access`, `src/lib/campaignRoles`, `${CAMPAIGN_APP}/comunicacao`) continua mapeada e roda o conjunto curado.
## Já resolvido no simplify/critique (não reabrir)
- `REEL_MEDIA_INLINE_MIME_TYPES` exportado sem consumidor (quebrava `pnpm knip`) → não exportado.
- `mimeType as string` redundante em `reelMediaContentType` → `isInlineSafe` virou type predicate.
- Spread `...(required ? { required: true } : {})` no campo de upload → `required` direto.
- Literal `'reelMedia'` duplicado (slug/staticDir/rota) → `REEL_MEDIA_SLUG` em `src/lib/reel.ts`.
- 404 da rota e do utilitário com `Cache-Control` divergentes → `REEL_MEDIA_CACHE_CONTROL` único.
- `isMissingObject` checava `httpStatusCode` (inexistente no AWS SDK v3) → `$metadata.httpStatusCode`.
- Objeto S3 de 0 byte virava 500 enquanto o disco servia 200 → alinhado (só `ContentLength` nulo falha).
- Comentário desatualizado em `nav.ts`; variável de fixture `narration` guardando `.srt`; drift de nomes no impl plan.
## Explicitamente fora (skips, descartes e defers deste triage)
- **Registrado:** ramo S3 sem teste → C198 (#1162), `docs/plans/reels-privado-hardening.md` (score 4, expensive_lock; `depends: [C193]`).
- **Defer (contrato `src/lib/reel.ts`):** `reelMediaPath` e `isReelStatus` sem call site de produção. Gatilho: primeiro consumidor no C194 (#1153)/C195 (#1154) — se sobrar sem uso no merge do C194, apagar.
- **Defer (mapa kind→campo):** `ReelMediaField` reescreve os nomes dos uploads em vez de derivar `keyof Reel` de `@/payload-types`. Gatilho: rename de campo do `Reel` ou o C194 passando a ler `reelMediaFieldByKind` — então tipar o mapa contra `keyof Reel`.
- **Descarte:** gate key `'speechCatalog'` em `campaignPageActor.ts` (rename de pureza; se o C194 tocar o gate, renomeia de carona).
- **Descarte:** corpos idênticos de `canReadReel`/`canDeleteReel` (duplicação deliberada, precedente `canDeleteSpeechCut` — nunca alias de leitura).
- **Descarte:** fallback defensivo de `staticDir` na rota (guard exigido pelo tipo do Payload; fallback sem custo).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: registro com título/feature/status; artefatos anexáveis com vídeo sem áudio primário; nenhum arquivo abre sem login; aprovar não dispara ação externa; `unpublished` some da biblioteca e deixa de ser servido; só papéis da comunicação; site público e cards idênticos.
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` em toda leitura; sem `Consent` novo; `Media.read` intocado; identificadores em inglês, copy/labels em pt-BR; "edit the owner, don't twin" (rename do predicado, sem twin); módulo novo em subpasta de domínio (`src/utilities/reels/`), sem top-level novo; migração nova sem editar antigas; `pnpm push` como caminho de entrega.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: unit do predicado/mapa `kind → campo`/parser de Range; int de access (communicator/coordinator/candidate passam; advisor/leader e anônimo negados), kill switch (`draft`/`unpublished` → 404) e stamping de ator/transação.
## Self-score de decision-quality
**4.5/5.** As três decisões estruturais (collection privada separada, serving por rota autenticada streamando direto, rename do predicado) têm alternativas honestas registradas e rejeições com motivo ancorado em file:line (endpoint S3 interno, `Media.read` público, top-level pin). Perde 0,5 porque a decisão de produto sobre `unpublished` fica em conflito conhecido com o C194 e só se fecha no gate — o plano adota B e explicita o custo de reverter, mas a qualidade final depende dessa confirmação humana.

View file

@ -242,6 +242,12 @@ export default defineConfig({
// Keep e2e artifacts outside `.next`: a concurrent development server
// owns that entire directory and may clear nested production bundles.
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',
/*
* C192 — blank the DeepSeek key so the acervo's theme expansion always
* degrades to the literal search (never the network) and the degraded
* state stays deterministic, same principle as the Google test key.
*/
DEEPSEEK_API_KEY: '',
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
// Prod mode serves a production build where `getCampaignInviteBaseURL`
// fails closed unless NEXT_PUBLIC_SITE_URL is an HTTPS public DNS name

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -0,0 +1,156 @@
/**
* Build an Instagram-ready chart PNG from pasted/attached data (C191).
*
* The skill `graficos-dados` orchestrates: it parses the input, decides the
* chart relation and writes the headline/source, then calls this entry. The
* entry is deterministic and fail-closed: guardrail violations and missing
* data exit non-zero instead of drawing something misleading. Rendered local,
* never committed (data/ and docs/research/ are gitignored).
*
* Usage:
* node scripts/build-chart-from-data.mjs --in=data/graficos-instagram/dados.csv \
* --headline="Um território concentra o maior resultado" --source="TSE 2022" \
* [--subtitle=...] [--note=...] [--type=bar] [--size=feed|square|story] \
* [--highlight="Território A"] [--out=docs/research/graficos-instagram/x.png]
* node scripts/build-chart-from-data.mjs --in=dados.csv --inspect
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, extname, join } from 'node:path'
import { launchPdfBrowser, screenshotHtmlPng } from './lib/buildPdf.mjs'
import {
MAX_POINTS,
MAX_POINTS_STORY,
SIZES,
classifyRelation,
parseInput,
validateSpec,
} from './lib/chartData.mjs'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
import { renderChartHtml } from './lib/graficosInstagramRender.mjs'
const die = dieWithLabel('graficos-dados')
const repoRoot = process.cwd()
const FORMAT_BY_EXT = {
'.xlsx': 'xlsx',
'.xls': 'xls',
'.csv': 'csv',
'.tsv': 'tsv',
'.md': 'md',
'.markdown': 'md',
'.txt': 'txt',
}
const slugify = (value) =>
String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
const today = () => new Date().toISOString().slice(0, 10)
const readInput = async (flags) => {
const path = flags.in === '-' || flags.in === true ? null : String(flags.in ?? '')
if (!path) {
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
return { text: Buffer.concat(chunks).toString('utf8'), format: 'txt' }
}
const format = FORMAT_BY_EXT[extname(path).toLowerCase()] ?? 'txt'
const buffer = await readFile(path)
if (format === 'xlsx' || format === 'xls') return { buffer, format }
return { text: buffer.toString('utf8'), format }
}
const main = async () => {
const { flags } = parseEqualsFlags(process.argv.slice(2))
let spec
if (flags.spec) {
try {
spec = JSON.parse(await readFile(String(flags.spec), 'utf8'))
} catch (error) {
die(`falha ao ler --spec=${flags.spec}: ${error?.message ?? error}`)
}
} else {
if (!flags.in) die('informe --in=<arquivo|-> com os dados (ou --spec=<json> para replay).')
let input
try {
input = await readInput(flags)
} catch (error) {
die(`falha ao ler --in=${flags.in}: ${error?.message ?? error}`)
}
const dataset = parseInput(input)
if (flags.inspect) {
process.stdout.write(`${JSON.stringify(dataset, null, 2)}\n`)
return
}
if (dataset.rows.length === 0 || dataset.issues.length > 0) {
process.stdout.write(
`${JSON.stringify({ needsQuestion: true, issues: dataset.issues, rows: dataset.rows }, null, 2)}\n`,
)
die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).')
}
const chartType = classifyRelation(dataset.rows, flags.type ? String(flags.type) : null)
spec = {
chartType,
size: flags.size ? String(flags.size) : 'feed',
headline: flags.headline ? String(flags.headline) : '',
subtitle: flags.subtitle ? String(flags.subtitle) : '',
source: flags.source ? String(flags.source) : '',
note: flags.note ? String(flags.note) : '',
highlight: flags.highlight ? String(flags.highlight) : null,
rows: dataset.rows,
}
}
if (!spec.source) die('informe --source (a fonte viaja dentro da imagem).')
validateSpec(spec)
const slug = slugify(spec.headline)
const specPath = join(repoRoot, 'data/graficos-instagram', `${slug || 'grafico'}.chart-spec.json`)
const outPath = flags.out
? String(flags.out)
: join(
repoRoot,
'docs/research/graficos-instagram',
`${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.png`,
)
const html = renderChartHtml(spec)
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
const canvas = SIZES[sizeKey]
const pointCap =
sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column')
? MAX_POINTS_STORY
: MAX_POINTS
const browser = await launchPdfBrowser()
try {
await mkdir(dirname(outPath), { recursive: true })
await mkdir(dirname(specPath), { recursive: true })
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`)
const { size } = await screenshotHtmlPng(browser, {
html,
width: canvas.width,
height: canvas.height,
outPath,
})
console.log(
`[graficos-dados] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${spec.rows.length} ponto(s) ≤ ${pointCap}`,
)
} finally {
await browser.close()
}
}
main().catch((error) => {
die(error?.message ?? String(error))
})

View file

@ -11,6 +11,7 @@
*/
import { chromium } from '@playwright/test'
import { stat } from 'node:fs/promises'
const MM_TO_PX = 96 / 25.4
export const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
@ -29,6 +30,38 @@ const MAX_BULLETIN_FIT_ATTEMPTS = 24
export const launchPdfBrowser = () => chromium.launch()
/**
* Exact-size PNG of a screen-media document (C191 Instagram canvas). The
* `.canvas` element is authored at the output size, so the viewport and the
* clip are the same rectangle; no A4 constant is touched. Chromium PNGs are
* already sRGB. Fails closed when the file exceeds `maxBytes` (default 8 MB).
*/
export const screenshotHtmlPng = async (
browser,
{ html, width, height, outPath, maxBytes = 8 * 1024 * 1024 },
) => {
const page = await browser.newPage()
try {
await page.setViewportSize({ width, height })
await page.setContent(html, { waitUntil: 'load' })
await page.evaluate(() => document.fonts.ready)
await page.screenshot({
path: outPath,
type: 'png',
clip: { x: 0, y: 0, width, height },
})
} finally {
await page.close()
}
const { size } = await stat(outPath)
if (size > maxBytes) {
throw new Error(
`PNG ${outPath} tem ${size} bytes (> ${maxBytes}); simplifique o gráfico ou reduza pontos.`,
)
}
return { size }
}
/** Every `[data-page]` taller than the budget, in document order. */
const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) =>
page.evaluate(

334
scripts/lib/chartData.mjs Normal file
View file

@ -0,0 +1,334 @@
/**
* Chart data core (C191): parse the numbers the communication team pasted or
* dropped (xlsx/csv/md/txt/free text), pick the chart relation and enforce the
* correctness guardrails — all pure, no Chromium and no DOM, so the unit suite
* covers it without a database or a browser.
*
* The renderer (`graficosInstagramRender.mjs`) owns the look; this module never
* invents a value: anything missing or ambiguous becomes an explicit question.
*/
import { parse as parseCsv } from 'csv-parse/sync'
import XLSX from 'xlsx'
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor']
/** Max data points per chart (honest density; above this the person must summarize). */
export const MAX_POINTS = 7
/**
* Stories recompose the vertical rhythm with one fewer ranking point (approved
* design: "reduz um ponto do ranking"), so a six/seven-point ranking is refused
* instead of crowded.
*/
export const MAX_POINTS_STORY = 5
/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */
export const SIZES = {
feed: { width: 1080, height: 1350, label: '1080×1350', safeTop: 0, safeBottom: 0 },
square: { width: 1080, height: 1080, label: '1080×1080', safeTop: 0, safeBottom: 0 },
story: { width: 1080, height: 1920, label: '1080×1920', safeTop: 250, safeBottom: 250 },
}
/** Kicker of each relation, matching the approved hi-fi template. */
export const RELATION_LABEL = {
bar: 'Comparação',
column: 'Poucos períodos',
line: 'Série de tempo',
anchor: 'Número-âncora',
}
const TEMPORAL_RE =
/^(\d{4}|\d{2}|[A-Za-zÀ-ÿ]{3,9}[./-]?\d{2,4}|\d{1,2}[./-]\d{2,4}|[TQ]\d|P\d+|\d{4}[./-]\d{1,2})$/u
/** True when the label reads as a period (year, month, quarter, P1…), not a category. */
export const isTemporalLabel = (label) => TEMPORAL_RE.test(String(label).trim())
/**
* pt-BR/en number: strips currency/spaces/percent, treats the last separator as
* the decimal when both `.` and `,` appear, and comma-as-decimal otherwise.
* Returns null (never 0) for anything non-numeric, so "missing" stays honest.
*/
export const parseNumber = (raw) => {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
let value = String(raw ?? '')
.replace(/[R$\s%]/gi, '')
.trim()
if (value === '') return null
const hasDot = value.includes('.')
const hasComma = value.includes(',')
if (hasDot && hasComma) {
value =
value.lastIndexOf(',') > value.lastIndexOf('.')
? value.replaceAll('.', '').replace(',', '.')
: value.replaceAll(',', '')
} else if (hasComma) {
value = value.replace(',', '.')
}
if (!/^-?\d+(\.\d+)?$/.test(value)) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const lastCell = (row) => row[row.length - 1]
const normalizeWord = (value) =>
String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
/**
* A header row is only recognized by an explicit header word in some cell —
* never by "the first value is not numeric", which would drop a real data row
* with a missing value ("Ilhéus n/d") in silence. Unknown headers survive as a
* data row and become an explicit question instead.
*/
const HEADER_HINTS = new Set([
'valor',
'value',
'total',
'quantidade',
'qtd',
'numero',
'montante',
'percentual',
'percent',
'%',
'taxa',
'media',
'indice',
'votos',
'count',
'amount',
'resultado',
'unidade',
'ano',
'mes',
'periodo',
'data',
'indicador',
'metrica',
'r$',
'populacao',
'eleitores',
'eleitorado',
'cidade',
'municipio',
'categoria',
'grupo',
'regiao',
'estado',
'partido',
'candidato',
])
const looksLikeHeaderRow = (row) =>
row.some((cell) =>
normalizeWord(cell)
.split(/[^a-z0-9%$]+/)
.some((word) => HEADER_HINTS.has(word)),
)
const cleanMatrix = (matrix) =>
matrix
.map((row) => row.map((cell) => String(cell ?? '').trim()))
.filter((row) => row.some(Boolean))
const matrixToDataset = (matrix, format) => {
const cleaned = cleanMatrix(matrix)
const issues = []
if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] }
let start = 0
if (
cleaned.length > 1 &&
parseNumber(lastCell(cleaned[0])) === null &&
looksLikeHeaderRow(cleaned[0])
) {
start = 1
}
const rows = []
for (const row of cleaned.slice(start)) {
const label = row[0]
const rawValue = lastCell(row)
const value = parseNumber(rawValue)
if (!label && value === null) {
issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`)
continue
}
if (value === null) {
issues.push(`valor não numérico em "${label}": ${JSON.stringify(rawValue)}`)
continue
}
rows.push({ label, value })
}
const seen = new Set()
for (const row of rows) {
if (seen.has(row.label)) issues.push(`rótulo repetido: "${row.label}"`)
seen.add(row.label)
}
return { rows, format, issues }
}
const sniffDelimiter = (line) => {
let best = null
let bestCount = 0
for (const delimiter of ['\t', ';', ',']) {
const count = line.split(delimiter).length - 1
if (count > bestCount) {
bestCount = count
best = delimiter
}
}
return bestCount > 0 ? best : null
}
const isMarkdownTable = (lines) =>
lines.length > 1 && lines[0].includes('|') && /^\s*\|?[\s:-]*-{3,}[\s:|-]*\|?\s*$/.test(lines[1])
const parseMarkdownTable = (lines) =>
lines
.filter((line) => line.includes('|'))
.filter((_line, index) => index !== 1)
.map((line) =>
line
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((cell) => cell.trim()),
)
/** Last separator wins, so labels may contain spaces: "Votos em Ilhéus: 84". */
const splitPair = (line) => {
const separators = [':', '=', '\t']
for (const separator of separators) {
const at = line.lastIndexOf(separator)
if (at > 0) return [line.slice(0, at), line.slice(at + 1)]
}
const match = line.match(/^(.*\S)\s+([^\s]+)$/s)
if (match) return [match[1], match[2]]
// A bare number is an unlabeled single measure (the anchor relation).
if (parseNumber(line) !== null) return ['', line]
return [line, '']
}
const parseFreeText = (text) => {
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
if (lines.length === 0) return { rows: [], format: 'txt', issues: ['nenhum dado encontrado'] }
if (isMarkdownTable(lines)) return matrixToDataset(parseMarkdownTable(lines), 'md')
const delimiter = sniffDelimiter(lines[0])
if (delimiter) {
const matrix = lines.map((line) => line.split(delimiter))
return matrixToDataset(matrix, delimiter === '\t' ? 'tsv' : 'csv')
}
const matrix = lines.map(splitPair)
return matrixToDataset(matrix, 'txt')
}
/**
* Parse the raw input into `{ rows, format, issues }`. `buffer` is required for
* xlsx/xls; `text` for csv/md/txt. `format` overrides the extension inference.
*
* @param {{ text?: string | null, buffer?: Buffer | null, format?: string | null }} [options]
*/
export const parseInput = ({ text = null, buffer = null, format = null } = {}) => {
if (format === 'xlsx' || format === 'xls') {
const workbook = XLSX.read(buffer, { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false })
return matrixToDataset(matrix, 'xlsx')
}
const body = String(text ?? '')
if (format === 'csv' || format === 'tsv') {
const delimiter =
format === 'tsv' ? '\t' : (sniffDelimiter(body.split(/\r?\n/)[0] ?? '') ?? ',')
const matrix = parseCsv(body, {
delimiter,
skip_empty_lines: true,
relax_column_count: true,
})
return matrixToDataset(matrix, format)
}
return parseFreeText(body)
}
/**
* Relation of the data → chart type. One measure is an anchor; every-temporal
* labels are a series (few periods as columns, many as a line); anything else
* is a ranking/comparison in horizontal bars.
*
* @param {{ label: string, value: number }[]} rows
* @param {string | null} [forcedType]
*/
export const classifyRelation = (rows, forcedType = null) => {
if (forcedType) {
// `pie` passes through so `validateSpec` answers with the actionable
// "use barras horizontais" message instead of a generic unknown type.
if (forcedType !== 'pie' && !CHART_TYPES.includes(forcedType)) {
throw new Error(
`tipo de gráfico desconhecido: ${JSON.stringify(forcedType)} (use ${CHART_TYPES.join(', ')})`,
)
}
return forcedType
}
if (rows.length === 1) return 'anchor'
if (rows.length > 1 && rows.every((row) => isTemporalLabel(row.label))) {
return rows.length <= 4 ? 'column' : 'line'
}
return 'bar'
}
/**
* Fail-closed guardrails of the approved template. Throws with the actionable
* reason instead of drawing a misleading chart.
*/
export const validateSpec = (spec) => {
const { chartType, rows, headline } = spec
if (spec.size && !SIZES[spec.size]) {
throw new Error(
`tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`,
)
}
if (!CHART_TYPES.includes(chartType)) {
if (chartType === 'pie') {
throw new Error(
'pizza não é gerada no v1 (a comparação fica escondida); use barras horizontais.',
)
}
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
}
if (!Array.isArray(rows) || rows.length === 0)
throw new Error('sem pontos de dado para desenhar.')
if (rows.length > MAX_POINTS) {
throw new Error(
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
)
}
if (
spec.size === 'story' &&
(chartType === 'bar' || chartType === 'column') &&
rows.length > MAX_POINTS_STORY
) {
throw new Error(
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
)
}
if (chartType === 'anchor' && rows.length !== 1) {
throw new Error('número-âncora exige exatamente um valor.')
}
for (const row of rows) {
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
if (row.value < 0) throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
}
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
}
if (typeof headline !== 'string' || headline.trim() === '') {
throw new Error('título-manchete ausente — a manchete é obrigatória.')
}
return spec
}

View file

@ -0,0 +1,185 @@
/**
* Chart primitives (owner of the drawing): inline-SVG emitters shared by the
* A4 dossier renderer (C186/C187) and the Instagram chart renderer (C191).
*
* Extracted from `dossieRender.mjs` when the Instagram generator became the
* second consumer, so a fix to the honest-scale/guardrail drawing lives once.
* The dossier keeps its mm/pt page composition; each consumer composes its own
* page around these primitives — never a second copy of the drawing.
*/
import { htmlEscape } from './reportText.mjs'
/**
* Domain palette of the dossier charts. Instagram passes its own Solla palette
* explicitly; this map stays the dossier default so its output is unchanged.
*/
export const CHART_COLORS = {
municipio: '#315c75',
regiao: '#9d8b64',
polo: '#8f819c',
instituicao: '#315c75',
setor: '#9d8b64',
rede: '#8f819c',
empenhado: '#6b4918',
liquidado: '#4a6b7c',
pago: '#285338',
autorizado: '#8a5a18',
restos: '#5b6470',
nao_informado: '#8b96a2',
default: '#315c75',
}
/**
* Honest-scale proportion of a value against the maximum, as a percentage
* (0..100). Zero stays zero (no bar is drawn); a present small value keeps a
* visible minimum so it is not mistaken for absent. Used for the ranking width
* and the column height of the Instagram renderer.
*/
export const proportionalPercent = (value, max) =>
value <= 0 ? 0 : Math.min(100, Math.max(2, Math.round((value / Math.max(1, max)) * 100)))
export const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => {
const max = Math.max(1, ...rows.map((row) => row.count))
const barMax = width - labelWidth - 26
const height = rows.length * rowHeight + 4
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img">
${rows
.map((row, index) => {
const y = index * rowHeight + 3
const barWidth = Math.max(2, Math.round((row.count / max) * barMax))
return `<text x="0" y="${y + 11}" class="chart-label">${htmlEscape(row.label)}</text>
<rect x="${labelWidth}" y="${y + 2}" width="${barWidth}" height="${rowHeight - 7}" rx="1" fill="${row.color ?? CHART_COLORS.default}" />
<text x="${labelWidth + barWidth + 4}" y="${y + 11}" class="chart-value">${row.count}</text>`
})
.join('')}
</svg>`
}
/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */
export const valueList = ({ rows, format }) => {
const max = Math.max(1, ...rows.map((row) => row.value))
return `<ul class="value-list">
${rows
.map(
(row) => `<li>
<span>${htmlEscape(row.label)}</span>
<span class="value-list-bar"><span style="width:${Math.max(2, Math.round((row.value / max) * 100))}%"></span></span>
<span class="value-list-value">${htmlEscape(format(row))}</span>
</li>`,
)
.join('')}
</ul>`
}
/**
* Vertical columns with the value label above and the year label below.
* `fullYearLabel` prints the whole year (theme axis); the institution keeps the
* 2-digit label.
*/
export const columnChart = ({
rows,
width = 330,
height = 132,
format = (row) => String(row.count),
fullYearLabel = false,
}) => {
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
const baseY = height - 18
const plotHeight = baseY - 16
const columnWidth = (width - 8) / Math.max(1, rows.length)
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
${rows
.map((row, index) => {
const value = row.value ?? row.count
const barHeight = Math.max(2, Math.round((value / max) * plotHeight))
const x = 4 + index * columnWidth
const y = baseY - barHeight
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
})
.join('')}
</svg>`
}
/** Vertical columns stacked by phase (money by year), total label above. */
export const stackedColumnChart = ({
rows,
width = 330,
height = 132,
format,
fullYearLabel = false,
}) => {
const max = Math.max(1, ...rows.map((row) => row.value))
const baseY = height - 18
const plotHeight = baseY - 16
const columnWidth = (width - 8) / Math.max(1, rows.length)
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
${rows
.map((row, index) => {
const x = 4 + index * columnWidth
let y = baseY
const bars = row.segments
.map((segment) => {
const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight))
y -= segmentHeight
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${segmentHeight}" fill="${CHART_COLORS[segment.key] ?? CHART_COLORS.default}" />`
})
.join('')
const top = baseY - Math.round((row.value / max) * plotHeight)
return `${bars}
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
})
.join('')}
</svg>`
}
/**
* Time-series line (Instagram): every point a hollow marker, the last one the
* single red highlight with its value. Honest scale — the baseline is zero.
*/
export const lineChart = ({
rows,
width = 880,
height = 420,
format,
stroke = '#78716c',
highlight = '#c51414',
paper = '#faf9f7',
ink = '#1c1917',
grid = '#a8a29e',
}) => {
const max = Math.max(1, ...rows.map((row) => row.value))
const padX = 46
const padTop = 42
const baseline = height - 46
const plotHeight = baseline - padTop
const step = rows.length > 1 ? (width - padX * 2) / (rows.length - 1) : 0
const pointAt = (row, index) => {
const x = padX + index * step
const y = baseline - Math.round((row.value / max) * plotHeight)
return { x, y }
}
const points = rows.map(pointAt)
const last = points[points.length - 1]
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" preserveAspectRatio="xMidYMid meet">
<line x1="${padX}" y1="${baseline}" x2="${width - padX}" y2="${baseline}" stroke="${grid}" stroke-width="2" />
<polyline points="${points.map((point) => `${point.x},${point.y}`).join(' ')}" fill="none" stroke="${stroke}" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
<g fill="${paper}" stroke="${stroke}" stroke-width="4">
${points
.slice(0, -1)
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="7" />`)
.join('')}
</g>
<circle cx="${last.x}" cy="${last.y}" r="10" fill="${highlight}" />
<text x="${last.x}" y="${last.y - 22}" text-anchor="end" fill="${ink}" font-size="34" font-weight="800">${htmlEscape(format(rows[rows.length - 1]))}</text>
${rows
.map(
(row, index) =>
`<text x="${pointAt(row, index).x}" y="${height - 10}" text-anchor="middle" fill="#57534e" font-size="30" font-weight="650">${htmlEscape(row.label)}</text>`,
)
.join('')}
</svg>`
}

View file

@ -5,6 +5,13 @@
* shared escaping/source-token contract lives in `reportText.mjs`.
*/
import {
CHART_COLORS,
barChart,
columnChart,
stackedColumnChart,
valueList,
} from './chartPrimitives.mjs'
import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs'
@ -897,118 +904,6 @@ const groupUnits = (chunk) => {
const tableHead = (columns) =>
`<thead><tr>${columns.map((column) => `<th>${htmlEscape(column)}</th>`).join('')}</tr></thead>`
/* ---------------------------------------------------------------- *
* Synthesis charts (inline SVG, print-safe): aggregations of the *
* sourced rows only — never a new fact, never a combined total *
* across abrangências. *
* ---------------------------------------------------------------- */
const CHART_COLORS = {
municipio: '#315c75',
regiao: '#9d8b64',
polo: '#8f819c',
instituicao: '#315c75',
setor: '#9d8b64',
rede: '#8f819c',
empenhado: '#6b4918',
liquidado: '#4a6b7c',
pago: '#285338',
autorizado: '#8a5a18',
restos: '#5b6470',
nao_informado: '#8b96a2',
default: '#315c75',
}
const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => {
const max = Math.max(1, ...rows.map((row) => row.count))
const barMax = width - labelWidth - 26
const height = rows.length * rowHeight + 4
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img">
${rows
.map((row, index) => {
const y = index * rowHeight + 3
const barWidth = Math.max(2, Math.round((row.count / max) * barMax))
return `<text x="0" y="${y + 11}" class="chart-label">${htmlEscape(row.label)}</text>
<rect x="${labelWidth}" y="${y + 2}" width="${barWidth}" height="${rowHeight - 7}" rx="1" fill="${row.color ?? CHART_COLORS.default}" />
<text x="${labelWidth + barWidth + 4}" y="${y + 11}" class="chart-value">${row.count}</text>`
})
.join('')}
</svg>`
}
/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */
const valueList = ({ rows, format }) => {
const max = Math.max(1, ...rows.map((row) => row.value))
return `<ul class="value-list">
${rows
.map(
(row) => `<li>
<span>${htmlEscape(row.label)}</span>
<span class="value-list-bar"><span style="width:${Math.max(2, Math.round((row.value / max) * 100))}%"></span></span>
<span class="value-list-value">${htmlEscape(format(row))}</span>
</li>`,
)
.join('')}
</ul>`
}
/**
* Vertical columns with the value label above and the year label below. `fullYearLabel`
* prints the whole year (theme axis); the institution keeps the 2-digit label.
*/
const columnChart = ({
rows,
width = 330,
height = 132,
format = (row) => String(row.count),
fullYearLabel = false,
}) => {
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
const baseY = height - 18
const plotHeight = baseY - 16
const columnWidth = (width - 8) / Math.max(1, rows.length)
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
${rows
.map((row, index) => {
const value = row.value ?? row.count
const barHeight = Math.max(2, Math.round((value / max) * plotHeight))
const x = 4 + index * columnWidth
const y = baseY - barHeight
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
})
.join('')}
</svg>`
}
/** Vertical columns stacked by phase (money by year), total label above. */
const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false }) => {
const max = Math.max(1, ...rows.map((row) => row.value))
const baseY = height - 18
const plotHeight = baseY - 16
const columnWidth = (width - 8) / Math.max(1, rows.length)
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
${rows
.map((row, index) => {
const x = 4 + index * columnWidth
let y = baseY
const bars = row.segments
.map((segment) => {
const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight))
y -= segmentHeight
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${segmentHeight}" fill="${CHART_COLORS[segment.key] ?? CHART_COLORS.default}" />`
})
.join('')
const top = baseY - Math.round((row.value / max) * plotHeight)
return `${bars}
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
})
.join('')}
</svg>`
}
const identityBadgeList = (report) =>
report.meta.identityBadges
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)

View file

@ -0,0 +1,242 @@
/**
* Instagram chart renderer (C191): composes the approved hi-fi template
* (`docs/plans/graficos-dados-instagram-ui-design.html`) into a single canvas
* HTML document, class-by-class, with inline CSS (the pipeline has no bundler
* and the render must never need the network). One red highlight, honest scale,
* headline-manchete, source and brand inside the image.
*
* The page is authored at the exact export size; `buildPdf.screenshotHtmlPng`
* only sets the viewport and clips the canvas.
*/
import { RELATION_LABEL, SIZES } from './chartData.mjs'
import { lineChart, proportionalPercent } from './chartPrimitives.mjs'
import { htmlEscape } from './reportText.mjs'
/** Solla palette (verbatim from the intention). */
export const SOLLA_PALETTE = {
highlight: '#c51414',
brandDark: '#ae1603',
ptRed: '#a21c1c',
ptYellow: '#ffe607',
ink: '#1c1917',
hairline: '#e7e5e4',
paper: '#faf9f7',
bar: '#d6d3d1',
barStrong: '#a8a29e',
axis: '#78716c',
label: '#57534e',
}
const FONT_STACK =
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
const formatValue = (value) =>
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)
const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value)
const highlightLabel = (spec) => spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null
const rankingBody = (spec) => {
const rows = sortedByValue(spec.rows)
const max = Math.max(...rows.map((row) => row.value))
const winner = highlightLabel(spec)
return `<div class="rank">
${rows
.map((row) => {
const percent = proportionalPercent(row.value, max)
const isHighlight = row.label === winner
return `<div class="rank-row">
<span class="rank-label">${htmlEscape(row.label)}</span>
<div class="bar-track">
<div class="bar${isHighlight ? ' highlight' : ''}${percent === 0 ? ' zero' : ''}" style="width:${percent}%">${formatValue(row.value)}</div>
</div>
</div>`
})
.join('')}
</div>`
}
const columnBody = (spec) => {
const rows = spec.rows
const max = Math.max(...rows.map((row) => row.value))
const winner = highlightLabel(spec)
return `<div class="columns" style="grid-template-columns:repeat(${rows.length}, 1fr)">
${rows
.map((row) => {
const isHighlight = row.label === winner
return `<div class="column-cell">
<span class="column-value">${formatValue(row.value)}</span>
<div class="column${isHighlight ? ' highlight' : ''}" style="height:${proportionalPercent(row.value, max)}%"></div>
<span class="column-label">${htmlEscape(row.label)}</span>
</div>`
})
.join('')}
</div>`
}
const anchorBody = (spec) => {
const row = spec.rows[0]
const copy = row.label || spec.subtitle || ''
return `<div class="anchor">
<div class="anchor-number">${formatValue(row.value)}</div>
${copy ? `<p class="anchor-copy">${htmlEscape(copy)}</p>` : ''}
</div>`
}
const plotBody = (spec) => {
if (spec.chartType === 'anchor') return anchorBody(spec)
if (spec.chartType === 'column') return columnBody(spec)
if (spec.chartType === 'line') {
return `<div class="line-plot">${lineChart({
rows: spec.rows,
width: 880,
height: 430,
format: (row) => formatValue(row.value),
stroke: SOLLA_PALETTE.axis,
highlight: SOLLA_PALETTE.highlight,
paper: SOLLA_PALETTE.paper,
ink: SOLLA_PALETTE.ink,
grid: SOLLA_PALETTE.barStrong,
})}</div>`
}
return rankingBody(spec)
}
const brandLockup = () => `<div class="brand">
<span class="brand-signal"></span>
<div>
<div class="brand-name">JORGE SOLLA</div>
<div class="brand-office">MANDATO DEPUTADO FEDERAL</div>
</div>
</div>`
const footer = (spec) => `<footer class="footer">
<p class="source"><strong>Fonte:</strong> ${htmlEscape(spec.source)}${spec.note ? `<br />Nota: ${htmlEscape(spec.note)}` : ''}</p>
${brandLockup()}
</footer>`
const CSS = `* { box-sizing: border-box; }
html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
.canvas {
position: relative;
overflow: hidden;
color: ${SOLLA_PALETTE.ink};
background: ${SOLLA_PALETTE.paper};
font-family: ${FONT_STACK};
-webkit-font-smoothing: antialiased;
}
.inner { position: absolute; inset: 0; display: flex; flex-direction: column; }
.top-rule { width: 64px; height: 10px; background: ${SOLLA_PALETTE.highlight}; }
.context { margin: 0; color: ${SOLLA_PALETTE.brandDark}; font-size: 30px; line-height: 1.2; font-weight: 750; }
.headline { margin: 0; font-weight: 800; line-height: 1.03; letter-spacing: -0.045em; }
.subtitle { margin: 0; color: ${SOLLA_PALETTE.label}; font-weight: 450; line-height: 1.35; }
.plot { flex: 1; min-height: 0; }
.rank { display: grid; align-content: start; }
.rank-row { display: grid; grid-template-columns: 250px 1fr; align-items: center; gap: 22px; }
.rank-label { font-size: 31px; line-height: 1.08; font-weight: 650; }
.bar-track { position: relative; height: 53px; border-left: 2px solid ${SOLLA_PALETTE.axis}; }
.bar {
display: flex; align-items: center; justify-content: flex-end; height: 100%;
min-width: 94px; padding-right: 16px; color: #292524; background: ${SOLLA_PALETTE.bar};
font-size: 31px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums;
}
.bar.highlight { color: #fff; background: ${SOLLA_PALETTE.highlight}; }
.bar.zero { min-width: 0; padding: 0 0 0 8px; justify-content: flex-start; }
.columns { display: grid; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
.column-cell { position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
.column-value { margin-bottom: 10px; text-align: center; font-size: 31px; font-weight: 800; font-variant-numeric: tabular-nums; }
.column { background: ${SOLLA_PALETTE.bar}; min-height: 6px; }
.column.highlight { background: ${SOLLA_PALETTE.highlight}; }
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; }
.line-plot { height: 100%; display: flex; align-items: center; }
.line-plot svg { width: 100%; height: auto; }
.anchor { padding-top: 20px; }
.anchor-number { color: ${SOLLA_PALETTE.highlight}; font-size: 300px; line-height: 0.86; letter-spacing: -0.065em; font-weight: 900; font-variant-numeric: tabular-nums; }
.anchor-copy { max-width: 760px; margin: 40px 0 0; font-size: 52px; line-height: 1.12; font-weight: 750; }
.footer {
margin-top: auto; padding-top: 25px; border-top: 2px solid ${SOLLA_PALETTE.hairline};
display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 30px;
}
.source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; }
.source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; }
.brand { display: flex; align-items: center; gap: 13px; justify-content: flex-end; }
.brand-signal { width: 12px; height: 48px; background: ${SOLLA_PALETTE.ptYellow}; }
.brand-name { color: ${SOLLA_PALETTE.brandDark}; font-size: 29px; line-height: 0.88; font-weight: 900; letter-spacing: -0.045em; }
.brand-office { margin-top: 7px; color: ${SOLLA_PALETTE.label}; font-size: 17px; line-height: 1; font-weight: 750; letter-spacing: 0.04em; }`
/** Per-size vertical rhythm of the template (feed 4:5, square, stories 9:16). */
const layoutFor = (size) => {
if (size === 'story') {
const { safeTop, safeBottom } = SIZES.story
return {
// The content clears the Stories safe bands, plus the template's margin.
padding: `${safeTop + 56}px 84px ${safeBottom + 60}px`,
ruleGap: 34,
gapAfterContext: 18,
headline: 78,
subtitle: 32,
plotGap: 78,
}
}
if (size === 'square') {
return {
padding: '64px 84px 56px',
ruleGap: 28,
gapAfterContext: 16,
headline: 62,
subtitle: 30,
plotGap: 44,
}
}
return {
padding: '76px 84px 58px',
ruleGap: 34,
gapAfterContext: 18,
headline: 67,
subtitle: 31,
plotGap: 54,
}
}
/**
* Full HTML document of the chart, at the exact export size. `spec` must have
* passed `validateSpec` (the entry does it before rendering).
*/
export const renderChartHtml = (spec) => {
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
const canvas = SIZES[sizeKey]
const layout = layoutFor(sizeKey)
const kicker = RELATION_LABEL[spec.chartType] ?? 'Gráfico'
const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label
const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle
return `<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<style>${CSS}</style>
<style>
.canvas { width: ${canvas.width}px; height: ${canvas.height}px; }
.inner { padding: ${layout.padding}; }
.top-rule { margin-bottom: ${layout.ruleGap}px; }
.context { margin-bottom: ${layout.gapAfterContext}px; }
.headline { font-size: ${layout.headline}px; max-width: ${canvas.width - 200}px; }
.subtitle { margin-top: ${Math.round(layout.gapAfterContext * 1.28)}px; font-size: ${layout.subtitle}px; max-width: ${canvas.width - 260}px; }
.plot { margin-top: ${layout.plotGap}px; }
</style>
</head>
<body>
<article class="canvas" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>
<h1 class="headline">${htmlEscape(spec.headline)}</h1>
${showSubtitle ? `<p class="subtitle">${htmlEscape(spec.subtitle)}</p>` : ''}
<div class="plot">${plotBody(spec)}</div>
${footer(spec)}
</div>
</article>
</body>
</html>`
}

View file

@ -47,6 +47,8 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/camaraFetch.mjs',
'scripts/lib/camaraSpeeches.mjs',
'scripts/lib/changelog.mjs',
'scripts/lib/chartData.mjs',
'scripts/lib/chartPrimitives.mjs',
'scripts/lib/cityReportBlocks.mjs',
'scripts/lib/cityReportDatabase.mjs',
'scripts/lib/cityReportFormat.mjs',
@ -71,6 +73,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/github-api.mjs',
'scripts/lib/github-branch-protection.mjs',
'scripts/lib/github-pr-flow.mjs',
'scripts/lib/graficosInstagramRender.mjs',
'scripts/lib/imageFill.mjs',
'scripts/lib/imageResize.mjs',
'scripts/lib/issues-panel.mjs',

View file

@ -2,7 +2,7 @@ import config from '@payload-config'
import { after, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { speechCoverUrl } from '@/lib/speechVod'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { ensureSpeechPoster, SPEECH_POSTER_WAIT_MS } from '@/utilities/speech/speechPosterJob'
@ -55,7 +55,7 @@ export const GET = async (
{ params }: { params: Promise<{ id: string }> },
): Promise<NextResponse> => {
const user = await getCampaignUser()
if (!user || !canReadSpeechCatalog(user.role)) return emptyResponse(404)
if (!user || !canReadCommunicationCatalog(user.role)) return emptyResponse(404)
const { id } = await params
const speechId = Number(id)

View file

@ -12,7 +12,10 @@ import {
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFilters'
import { SpeechRefineSearchButton } from '@/components/campaign/speech/SpeechRefineSearchButton'
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
import { SpeechThemeFallbackNotice } from '@/components/campaign/speech/SpeechThemeFallbackNotice'
import { SpeechThemeRetryButton } from '@/components/campaign/speech/SpeechThemeRetryButton'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
@ -36,6 +39,8 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
if (data.redirectHref) redirect(data.redirectHref)
const hasFilters = buildSpeechFiltersKey(data.state) !== ''
const themeMode = data.state.mode === 'tema'
const themeActive = themeMode && data.themeApplied && !data.themeUnavailable
return (
<CampaignPageShell aria-label="Acervo de falas">
@ -53,28 +58,74 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
key={buildSpeechFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
/>
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
<CampaignListResults>
{themeMode ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">
{data.themeUnavailable
? 'Resultados por termo exato'
: themeActive
? 'Resultados por tema'
: 'Resultados'}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{data.themeUnavailable
? 'Comportamento atual do acervo.'
: themeActive
? 'Confira o indício em cada fala antes de abrir.'
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
</p>
</div>
{data.themeUnavailable ? <SpeechThemeRetryButton /> : null}
</div>
) : null}
{data.rows.length > 0 ? (
<SpeechResultList rows={data.rows} query={data.state.q} />
) : (
<CampaignListEmptyState
icon={SearchXIcon}
className={themeActive ? 'border-solid' : undefined}
mediaClassName={themeActive ? 'size-12 rounded-full' : undefined}
contentClassName={themeActive ? 'max-w-none' : undefined}
title={
data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
themeActive
? 'Nenhuma fala encontrada para este tema'
: data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
}
description={
hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
themeActive
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
: hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
}
>
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
</Button>
{themeActive ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<SpeechRefineSearchButton />
<Button asChild variant="outline" className="min-h-11">
<Link href={buildSpeechListHref({ ...data.state, mode: undefined }, 1)}>
Usar termo exato
</Link>
</Button>
<Button asChild variant="ghost" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar filtros</Link>
</Button>
</div>
) : (
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
</Button>
)}
</CampaignListEmptyState>
)}

View file

@ -0,0 +1,66 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
canServeReelMedia,
isReelMediaKind,
REEL_MEDIA_SLUG,
reelMediaFieldByKind,
} from '@/lib/reel'
import { REEL_MEDIA_CACHE_CONTROL } from '@/lib/reelMedia'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { buildReelMediaResponse } from '@/utilities/reels/reelMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C193 — the only door to a reel file. Lives under `/campanha` because the
* `campaign-token` cookie is scoped to that path (a `<video>`/`<img>` sends the
* cookie, never an Authorization header). The gate is the same communication
* catalog predicate as the collection access; only a `published` reel serves
* its files (the kill switch), and every denial is a silent `404` so the route
* never leaks which reels exist.
*/
const notFound = (): NextResponse =>
new NextResponse(null, { status: 404, headers: { 'Cache-Control': REEL_MEDIA_CACHE_CONTROL } })
export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string; kind: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const { id, kind } = await params
const reelId = Number(id)
if (!Number.isInteger(reelId) || reelId <= 0 || !isReelMediaKind(kind)) return notFound()
const payload = await getPayload({ config })
const reel = await payload
.findByID({
collection: 'reel',
id: reelId,
depth: 1,
user,
overrideAccess: false,
})
.catch(() => null)
if (!reel || !canServeReelMedia(reel.status)) return notFound()
const media = reel[reelMediaFieldByKind[kind]]
if (!media || typeof media !== 'object') return notFound()
const upload = payload.collections[REEL_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir ? upload.staticDir : REEL_MEDIA_SLUG
return buildReelMediaResponse({
media,
staticDir,
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})
}

View file

@ -2,7 +2,7 @@
import type { Payload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
@ -56,7 +56,7 @@ export const resolveSpeechVodForActor = async (input: {
const { speechId } = speechVodRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_VOD_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_VOD_FORBIDDEN_MESSAGE)
const result = await payload.find({
collection: 'speech',
@ -227,7 +227,7 @@ export const saveSpeechCutForActor = async (
const parsed = speechCutRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
return 'retryOf' in parsed
? retrySpeechCut(payload, actor, parsed.retryOf)
@ -241,7 +241,7 @@ export const getSpeechCutStatusForActor = async (input: {
const { cutId } = speechCutStatusRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
let cut = await loadSpeechCutForActor(payload, actor, cutId)
if (!cut) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -273,7 +273,7 @@ export const updateSpeechCutTextForActor = async (
const parsed = speechCutTextUpdateRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -300,7 +300,7 @@ export const setSpeechCutPublishedForActor = async (
const parsed = speechCutPublicationRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -333,7 +333,7 @@ export const deleteSpeechCutForActor = async (
const parsed = speechCutCutIdRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -358,7 +358,7 @@ export const retrySpeechCutForActor = async (
const parsed = speechCutCutIdRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
return retrySpeechCut(payload, actor, parsed.cutId)
}
@ -372,7 +372,7 @@ export const suggestSpeechCutMetadataForActor = async (input: {
const parsed = speechCutSuggestionRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const speech = await loadSpeechForCut(payload, actor, parsed.speechId)
if (!speech) throw new Error(SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE)

157
src/collections/Reel.ts Normal file
View file

@ -0,0 +1,157 @@
import type { CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
import {
REEL_FEATURES,
REEL_MEDIA_SLUG,
REEL_STATUSES,
REEL_TITLE_MAX_LENGTH,
reelFeatureLabels,
reelStatusLabels,
} from '@/lib/reel'
import { canDeleteReel, canReadReel, canSetCampaignSystemField } from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
/**
* C193 — one reel of tutorial of the site's functionalities, produced outside
* the Teqo and registered here with its artifacts. Everything is private: the
* files live in `reelMedia` and are served only through the authenticated
* `/campanha/.../media/[kind]` route. `status` is the kill switch (`published`
* → `unpublished`); approving never triggers an external action. The C194
* library lists the published rows and the C195 ingest writes through the Local
* API.
*/
const STATUS_OPTIONS = REEL_STATUSES.map((value) => ({ value, label: reelStatusLabels[value] }))
const FEATURE_OPTIONS = REEL_FEATURES.map((value) => ({ value, label: reelFeatureLabels[value] }))
const reelMediaField = ({
name,
label,
description,
required = false,
}: {
name: string
label: string
description: string
required?: boolean
}): Field => ({
name,
type: 'upload',
relationTo: REEL_MEDIA_SLUG,
label,
required: required ?? false,
admin: { description },
})
/**
* Stamps `publishedAt` on every transition into `published` (create included),
* so the C194 list can order by publication without each caller remembering it.
* Leaving `published` never clears the stamp — it records the last publication.
*/
const stampReelPublishedAt: CollectionBeforeChangeHook = ({ data, operation, originalDoc }) => {
if (
data.status === 'published' &&
(operation === 'create' || originalDoc?.status !== 'published')
) {
data.publishedAt = new Date().toISOString()
}
return data
}
export const Reel: CollectionConfig = {
slug: 'reel',
labels: {
singular: 'Reel',
plural: 'Reels',
},
admin: {
group: 'Comunicação',
useAsTitle: 'title',
defaultColumns: ['title', 'feature', 'status', 'publishedAt'],
description:
'Reels de tutorial da assessoria. Os arquivos são privados e só abrem com login da campanha; publicar não dispara nada no Instagram.',
},
access: {
create: canReadReel,
read: canReadReel,
update: canReadReel,
delete: canDeleteReel,
},
hooks: {
beforeChange: [stampReelPublishedAt, stampCampaignCreatedBy],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
maxLength: REEL_TITLE_MAX_LENGTH,
},
{
name: 'feature',
type: 'select',
label: 'Funcionalidade-alvo',
required: true,
options: FEATURE_OPTIONS,
admin: {
description: 'Funcionalidade do site que o tutorial ensina.',
},
},
{
name: 'status',
type: 'select',
label: 'Estado',
required: true,
defaultValue: 'draft',
index: true,
options: STATUS_OPTIONS,
admin: {
description:
'Despublicar tira o reel da biblioteca e para de servir os arquivos (kill switch).',
},
},
reelMediaField({
name: 'video',
label: 'Vídeo (sem áudio, primário)',
required: true,
description: 'MP4 1080×1920 sem áudio — o arquivo principal que a assessoria baixa.',
}),
reelMediaField({
name: 'videoWithAudio',
label: 'Vídeo com áudio (rascunho)',
description: 'Variante com a narração embutida, quando existir.',
}),
reelMediaField({
name: 'narrationAudio',
label: 'Narração (MP3)',
description: 'Rascunho da narração em áudio, quando existir.',
}),
reelMediaField({
name: 'captions',
label: 'Legendas (.srt)',
description: 'Arquivo de legenda pronto, vindo da produção.',
}),
reelMediaField({
name: 'cover',
label: 'Capa (1080×1920)',
required: true,
description: 'Imagem vertical usada na biblioteca e no player.',
}),
{
name: 'transcript',
type: 'textarea',
label: 'Transcrição / roteiro',
admin: {
description: 'Texto da narração ou roteiro, vindo da produção.',
},
},
{
name: 'publishedAt',
type: 'date',
label: 'Publicado em',
admin: { readOnly: true },
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}

View file

@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { REEL_MEDIA_SLUG } from '@/lib/reel'
import { canDeleteReel, canReadReel } from '@/utilities/campaignAccess'
/**
* C193 — private upload collection of a reel's artifacts. Deliberately NOT
* `media`: that collection is anonymous-read by contract (public pages), while
* a reel file may only be read by the communication roles. The browser reaches
* these files through the authenticated
* `/campanha/comunicacao/reels/[id]/media/[kind]` route, never the public
* `/api/media/file` proxy.
*/
export const ReelMedia: CollectionConfig = {
slug: REEL_MEDIA_SLUG,
labels: {
singular: 'Mídia de reel',
plural: 'Mídias de reel',
},
admin: {
group: 'Comunicação',
description:
'Arquivos privados dos reels (vídeo, narração, legenda, capa). Só abrem com login da campanha.',
},
access: {
create: canReadReel,
read: canReadReel,
update: canReadReel,
delete: canDeleteReel,
},
fields: [
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do artefato para acessibilidade (capa e vídeo).',
},
},
],
upload: true,
}

View file

@ -9,32 +9,40 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { cn } from '@/lib/utils'
/**
* The standard campaign list empty state (icon, title, description, optional
* CTA). Rendered inside `CampaignListResults` so it dims with the shared
* transition like the rows it replaces.
* transition like the rows it replaces. `className` / `mediaClassName` /
* `contentClassName` let a caller tune the shell without forking it (C192).
*/
export const CampaignListEmptyState = ({
icon: Icon,
title,
description,
children,
className,
mediaClassName,
contentClassName,
}: {
icon: LucideIcon
title: string
description: ReactNode
/** Call-to-action button(s). */
children?: ReactNode
className?: string
mediaClassName?: string
contentClassName?: string
}) => (
<Empty className="min-h-72 border">
<Empty className={cn('min-h-72 border', className)}>
<EmptyHeader>
<EmptyMedia variant="icon">
<EmptyMedia variant="icon" className={mediaClassName}>
<Icon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
{children ? <EmptyContent>{children}</EmptyContent> : null}
{children ? <EmptyContent className={contentClassName}>{children}</EmptyContent> : null}
</Empty>
)

View file

@ -29,7 +29,7 @@ import {
LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths'
import {
canReadSpeechCatalog,
canReadCommunicationCatalog,
isStaffCampaignRole,
isUnrestrictedCampaignRole,
} from '@/lib/campaignRoles'
@ -138,8 +138,8 @@ export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] =>
return staffNav.filter((item) => {
if (item.href === '/campanha/apoiadores') return canAccessSupporterArea(role)
if (item.href === '/campanha/assessores') return isUnrestrictedCampaignRole(role)
// C154 — advisor has no speech catalog access; coordinator/candidate do.
if (item.href === CAMPAIGN_COMMUNICATION_HOME) return canReadSpeechCatalog(role)
// C154 — advisor has no communication vertical access; coordinator/candidate do.
if (item.href === CAMPAIGN_COMMUNICATION_HOME) return canReadCommunicationCatalog(role)
return true
})
}

View file

@ -1,5 +1,6 @@
'use client'
import { SparklesIcon, TriangleAlertIcon } from 'lucide-react'
import { useMemo, useOptimistic, useState } from 'react'
import {
@ -12,6 +13,7 @@ import {
} from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
import { cn } from '@/lib/utils'
import {
buildSpeechListHref,
SPEECH_DURATION_BUCKETS,
@ -20,14 +22,17 @@ import {
speechTopicLabels,
type SpeechFilterOptions,
type SpeechListState,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
import {
applySpeechOmniboxSuggestion,
applySpeechSearchMode,
buildSpeechOmniboxChips,
buildSpeechOmniboxSuggestionSeeds,
clearSpeechOmnibox,
filterSpeechOmniboxSuggestions,
removeSpeechOmniboxChip,
SPEECH_OMNIBOX_ID,
type SpeechOmniboxAction,
} from '@/utilities/speech/speechOmnibox'
@ -58,12 +63,27 @@ const selectedTriggerLabel = (
return `${base}: ${values.length}`
}
/** C192 — segmented search-mode button (mobile full width, desktop inline). */
const modeButtonClass = (active: boolean, tone: 'termo' | 'tema'): string =>
cn(
'min-h-11 rounded-md px-3 text-sm md:min-h-9',
active
? cn(
'bg-white font-semibold shadow-sm ring-1 ring-border',
tone === 'tema' ? 'text-primary' : 'text-foreground',
)
: 'font-medium text-muted-foreground',
)
export const SpeechAcervoFilters = ({
state,
filterOptions,
themeUnavailable = false,
}: {
state: SpeechListState
filterOptions: SpeechFilterOptions
/** C192 — the theme expansion is down; the selector reflects the fallback. */
themeUnavailable?: boolean
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -72,6 +92,9 @@ export const SpeechAcervoFilters = ({
const [query, setQuery] = useState('')
const [viewState, setOptimisticState] = useOptimistic(state)
const activeMode: SpeechSearchMode =
viewState.mode === 'tema' && !themeUnavailable ? 'tema' : 'termo'
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of filterOptions.municipalities) {
@ -110,6 +133,12 @@ export const SpeechAcervoFilters = ({
navigate(action.state)
}
const runMode = (mode: SpeechSearchMode) => {
const next = applySpeechSearchMode({ state: viewState, mode }).state
setOptimisticState(next)
navigate(next)
}
const facetRows = (
prefix: string,
options: readonly { value: string; label: string }[],
@ -206,7 +235,7 @@ export const SpeechAcervoFilters = ({
}}
>
<CampaignListOmnibox
id="speech-omnibox"
id={SPEECH_OMNIBOX_ID}
label="Buscar no acervo de falas"
placeholder="Busque por assunto, tema, município ou palavra-chave…"
chips={chips}
@ -228,6 +257,55 @@ export const SpeechAcervoFilters = ({
}}
/>
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3 md:flex-row md:flex-wrap md:items-center md:justify-between">
<div className="flex w-full items-center gap-2 md:w-auto">
<span className="hidden text-xs font-medium text-muted-foreground md:inline">
Buscar por
</span>
<div
role="group"
aria-label="Modo de busca"
className="grid w-full grid-cols-2 rounded-lg bg-muted p-1 md:inline-flex md:w-auto"
>
<button
type="button"
aria-pressed={activeMode === 'termo'}
className={modeButtonClass(activeMode === 'termo', 'termo')}
onClick={() => runMode('termo')}
>
Termo exato
</button>
<button
type="button"
aria-pressed={activeMode === 'tema'}
aria-disabled={themeUnavailable || undefined}
className={modeButtonClass(activeMode === 'tema', 'tema')}
onClick={() => {
if (themeUnavailable) return
runMode('tema')
}}
>
<span className="inline-flex items-center gap-1.5">
{themeUnavailable ? (
<TriangleAlertIcon
className="size-3.5 text-estimate-pending-foreground"
aria-hidden="true"
/>
) : (
<SparklesIcon className="size-3.5" aria-hidden="true" />
)}
Por tema
</span>
</button>
</div>
</div>
<p className="hidden text-xs text-muted-foreground md:block">
{themeUnavailable
? 'Indisponível agora; mostramos o termo exato.'
: 'Encontra falas relacionadas pelo sentido.'}
</p>
</div>
<div
role="group"
aria-label="Filtros do acervo"

View file

@ -0,0 +1,21 @@
'use client'
import { Button } from '@/components/ui/button'
import { SPEECH_OMNIBOX_ID } from '@/utilities/speech/speechOmnibox'
/** C192 — the honest empty state's "reformulate" exit: focuses the omnibox. */
export const SpeechRefineSearchButton = () => (
<Button
type="button"
className="min-h-11"
onClick={() => {
const input = document.getElementById(SPEECH_OMNIBOX_ID)
if (input instanceof HTMLInputElement) {
input.scrollIntoView({ block: 'nearest' })
input.focus()
}
}}
>
Reformular busca
</Button>
)

View file

@ -1,4 +1,4 @@
import { ExternalLinkIcon, PlayIcon } from 'lucide-react'
import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
@ -32,6 +32,42 @@ const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
)
}
/**
* C192 — the theme result provenance: the real excerpt that matched the theme,
* with the expanded term highlighted. It replaces the common excerpt (never
* coexists with it) and carries no score.
*/
const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
if (excerpt.parts.length === 0) return null
return (
<div className="rounded-lg border-l-2 border-primary bg-primary/[0.035] px-3 py-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<p className="mt-1 text-sm leading-6 text-foreground/90">
{`“${excerpt.truncatedStart ? '… ' : ''}`}
<SpeechHighlightParts parts={excerpt.parts} />
{`${excerpt.truncatedEnd ? ' …' : ''}”`}
</p>
</div>
)
}
const SpeechThemeBadges = ({ matchedTextSearch }: { matchedTextSearch: boolean }) => (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex h-5 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
<SparklesIcon className="size-3" aria-hidden="true" />
Tema
</span>
{matchedTextSearch ? (
<span className="inline-flex h-5 items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
Termo exato
</span>
) : null}
</div>
)
const ActionLink = ({
href,
children,
@ -60,34 +96,47 @@ const SpeechMetaLine = ({
}: {
speech: SpeechListItemViewModel
originLabel?: boolean
}) => {
const items: string[] = []
if (originLabel) items.push('Fala de origem')
items.push(speech.speechAtLabel)
if (speech.type) items.push(speech.type)
if (speech.durationLabel) items.push(speech.durationLabel)
if (speech.presidingOfficer) items.push(`Presidiu: ${speech.presidingOfficer}`)
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{items.map((item, index) => (
<span key={item} className="inline-flex items-center gap-x-2 whitespace-nowrap">
{index > 0 ? <MetaSeparator /> : null}
<span>{item}</span>
</span>
))}
</div>
)
}
/**
* The primary CTA of every result card: a link to the speech (or its trecho)
* whose variant/width the caller owns (C192 adds the theme appearance).
*/
const SpeechWatchButton = ({
speech,
watchLabel,
variant,
className,
}: {
speech: SpeechListItemViewModel
watchLabel: string
variant: 'default' | 'outline'
className: string
}) => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{originLabel ? (
<>
<span>Fala de origem</span>
<MetaSeparator />
</>
) : null}
<span>{speech.speechAtLabel}</span>
{speech.type ? (
<>
<MetaSeparator />
<span>{speech.type}</span>
</>
) : null}
{speech.durationLabel ? (
<>
<MetaSeparator />
<span>{speech.durationLabel}</span>
</>
) : null}
{speech.presidingOfficer ? (
<>
<MetaSeparator />
<span>Presidiu: {speech.presidingOfficer}</span>
</>
) : null}
</div>
<Button asChild variant={variant} className={className}>
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{watchLabel}
</Link>
</Button>
)
const SpeechActions = ({
@ -100,12 +149,12 @@ const SpeechActions = ({
fullWidthOnMobile?: boolean
}) => (
<div className={className}>
<Button asChild className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}>
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
</Link>
</Button>
<SpeechWatchButton
speech={speech}
watchLabel={speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
variant="default"
className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}
/>
{speech.officialTextUrl ? (
<ActionLink
href={speech.officialTextUrl}
@ -129,8 +178,9 @@ export const SpeechResultCard = ({
}) => {
// C174 (option B): a speech that surfaced only because a cut matched the term
// shows the origin note instead of an excerpt the speech does not contain, and
// the actions sit at the top right (approved scene 05).
if (!speech.matchedTextSearch) {
// the actions sit at the top right (approved scene 05). C192 — a theme result
// outranks this branch: the theme evidence lives in the speech itself.
if (!speech.matchedTextSearch && speech.themeMatchTerm === null) {
return (
<article className="rounded-xl border bg-card p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
@ -168,67 +218,109 @@ export const SpeechResultCard = ({
visibleTopics.length +
(speech.scopes.length - visibleScopes.length) +
(speech.keywords.length - visibleKeywords.length)
const watchLabel = speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'
const excerptNode = <SpeechExcerpt excerpt={speech.excerpt} />
const isTheme = speech.themeMatchTerm !== null
// C192 — a theme card only offers the trecho seek when the speech really
// contains the literal query (a stopword-only segment hit must not claim it).
const watchLabel =
speech.matchKind === 'segment' && speech.matchedTextSearch ? 'Assistir no trecho' : 'Ver fala'
// C192 — the theme card replaces the common excerpt with the provenance block;
// the two never coexist (design gate).
const bodyNode = isTheme ? (
<SpeechThemeProvenance excerpt={speech.excerpt} />
) : (
<SpeechExcerpt excerpt={speech.excerpt} />
)
const chips = (
<>
{visibleTopics.map((topic) => (
<Badge key={topic.value} variant="secondary" className="font-normal">
{topic.label}
</Badge>
))}
{visibleScopes.map((scope) => (
<Badge key={scope.value} variant="secondary" className="font-normal">
{scope.label}
</Badge>
))}
{visibleKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
{keyword}
</Badge>
))}
{hiddenChips > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hiddenChips}
</Badge>
) : null}
</>
)
const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length)
return (
<article className="rounded-xl border bg-card p-4">
<SpeechMetaLine speech={speech} />
{isTheme ? (
<div className="flex flex-col items-start gap-2">
<SpeechThemeBadges matchedTextSearch={speech.matchedTextSearch} />
<SpeechMetaLine speech={speech} />
</div>
) : (
<SpeechMetaLine speech={speech} />
)}
<div className="mt-2">
<div className={isTheme ? 'mt-3' : 'mt-2'}>
{speech.thumbnailUrl ? (
<div className="flex flex-col items-start gap-3 sm:flex-row sm:gap-4">
<SpeechResultThumbnail
href={speech.watchHref}
src={speech.thumbnailUrl}
label={watchLabel}
fullWidthOnMobile={isTheme}
/>
<div className="min-w-0 flex-1">{excerptNode}</div>
<div className="min-w-0 flex-1">{bodyNode}</div>
</div>
) : (
excerptNode
bodyNode
)}
</div>
{speech.topics.length || speech.scopes.length || speech.keywords.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{visibleTopics.map((topic) => (
<Badge key={topic.value} variant="secondary" className="font-normal">
{topic.label}
</Badge>
))}
{visibleScopes.map((scope) => (
<Badge key={scope.value} variant="secondary" className="font-normal">
{scope.label}
</Badge>
))}
{visibleKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
{keyword}
</Badge>
))}
{hiddenChips > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hiddenChips}
</Badge>
) : null}
{isTheme ? (
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{hasChips ? <div className="flex flex-wrap gap-1.5">{chips}</div> : <span />}
<div className="flex flex-col items-stretch gap-2 md:flex-none md:flex-row md:items-center md:justify-end">
<SpeechWatchButton
speech={speech}
watchLabel={watchLabel}
variant="outline"
className="min-h-11 w-full max-md:border-primary max-md:bg-primary max-md:text-primary-foreground md:min-h-10 md:w-auto"
/>
{speech.officialTextUrl ? (
<ActionLink href={speech.officialTextUrl} variant="ghost" fullWidthOnMobile>
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir Diário Oficial
</ActionLink>
) : null}
</div>
</div>
) : null}
) : (
<>
{hasChips ? <div className="mt-3 flex flex-wrap gap-1.5">{chips}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button asChild className="min-h-11">
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{watchLabel}
</Link>
</Button>
{speech.officialTextUrl ? (
<ActionLink href={speech.officialTextUrl} variant="ghost">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir Diário Oficial
</ActionLink>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SpeechWatchButton
speech={speech}
watchLabel={watchLabel}
variant="default"
className="min-h-11"
/>
{speech.officialTextUrl ? (
<ActionLink href={speech.officialTextUrl} variant="ghost">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir Diário Oficial
</ActionLink>
) : null}
</div>
</>
)}
{speech.cuts.length ? <SpeechNestedCuts cuts={speech.cuts} query={query} /> : null}
</article>

View file

@ -4,21 +4,25 @@ import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
/**
* C175/C182 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so
* the lazy image never shifts the card, with the skeleton filling it until the
* image paints (a missing image settles into the neutral slot). The link reuses
* the card's watch target, with the same accessible name as the CTA.
* C192 — theme results take the full width on mobile (`h-28`), like the gate.
*/
export const SpeechResultThumbnail = ({
href,
src,
label,
fullWidthOnMobile = false,
}: {
href: string
src: string
label: string
fullWidthOnMobile?: boolean
}) => {
const [settled, setSettled] = useState(false)
const imageRef = useRef<HTMLImageElement>(null)
@ -32,7 +36,10 @@ export const SpeechResultThumbnail = ({
<Link
href={href}
aria-label={label}
className="relative block h-20 w-32 shrink-0 overflow-hidden rounded-lg border bg-muted"
className={cn(
'relative block shrink-0 overflow-hidden rounded-lg border bg-muted',
fullWidthOnMobile ? 'h-28 w-full md:h-20 md:w-32' : 'h-20 w-32',
)}
>
{settled ? null : <Skeleton className="absolute inset-0 rounded-lg" />}
{/* eslint-disable-next-line @next/next/no-img-element -- external static image (poster route or YouTube cover). */}

View file

@ -0,0 +1,25 @@
import { TriangleAlertIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
/**
* C192 — the discreet fallback notice when `mode=tema` was asked but the theme
* expansion was unavailable. The list below shows the literal results; the
* notice keeps the user's request honest instead of hiding the downgrade.
*/
export const SpeechThemeFallbackNotice = () => (
<Alert
variant="pending"
data-testid="speech-theme-fallback"
className="gap-3 border-estimate-pending/80 bg-estimate-pending/55 px-4 py-3"
>
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle className="text-sm font-medium">
A busca por tema está indisponível agora.
</AlertTitle>
<AlertDescription className="text-xs leading-5">
Você pediu “Por tema”. Para não interromper o trabalho, mostramos abaixo os resultados por
termo exato.
</AlertDescription>
</Alert>
)

View file

@ -0,0 +1,24 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { Button } from '@/components/ui/button'
/** C192 — re-runs the same `mode=tema` request after a degraded fallback. */
export const SpeechThemeRetryButton = () => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
return (
<Button
type="button"
variant="outline"
className="min-h-10"
disabled={isPending}
onClick={() => startTransition(() => router.refresh())}
>
Tentar por tema novamente
</Button>
)
}

View file

@ -18,9 +18,11 @@ export const isUnrestrictedCampaignRole = (role: CampaignRole): boolean =>
role === 'coordinator' || role === 'candidate'
/**
* Speech catalog readers (C153): the communication assessor plus the
* unrestricted roles. Deliberately NOT part of `isStaffCampaignRole` — the
* communicator does not inherit any staff area, only the catalog vertical.
* Communication vertical readers (C153/C193): the communication assessor plus
* the unrestricted roles. Owns the gate of the whole vertical — acervo de
* falas, biblioteca de cortes and the private reel library. Deliberately NOT
* part of `isStaffCampaignRole` — the communicator does not inherit any staff
* area, only the communication vertical.
*/
export const canReadSpeechCatalog = (role: CampaignRole): boolean =>
export const canReadCommunicationCatalog = (role: CampaignRole): boolean =>
role === 'communicator' || isUnrestrictedCampaignRole(role)

66
src/lib/reel.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* C193 — pure rules of a reel: the status vocabulary (kill switch), the
* feature catalog, the artifact `kind` → upload-field map and the private
* media path. No I/O and no `server-only`: the collection config, the
* authenticated media route and the C194 library share this module.
*/
/** Upload collection of the artifacts; also its local disk directory in dev. */
export const REEL_MEDIA_SLUG = 'reelMedia'
export const REEL_STATUSES = ['draft', 'published', 'unpublished'] as const
export type ReelStatus = (typeof REEL_STATUSES)[number]
/** One owner for the persisted field limit (collection, schema and C194). */
export const REEL_TITLE_MAX_LENGTH = 200
export const reelStatusLabels: Record<ReelStatus, string> = {
draft: 'Rascunho',
published: 'Publicado',
unpublished: 'Despublicado',
}
export const isReelStatus = (value: unknown): value is ReelStatus =>
REEL_STATUSES.includes(value as ReelStatus)
/**
* The closed feature vocabulary. Starts with `cards` (the home support cards at
* `#cards`); a new tutorial feature appends here — never free text.
*/
export const REEL_FEATURES = ['cards'] as const
export type ReelFeature = (typeof REEL_FEATURES)[number]
export const reelFeatureLabels: Record<ReelFeature, string> = {
cards: 'Cards de apoio (#cards)',
}
/**
* The artifacts a reel can carry, each mapping to exactly one upload field.
* `video` is the primary file (silent); the others are optional variants.
*/
export const REEL_MEDIA_KINDS = ['video', 'video-audio', 'narration', 'captions', 'cover'] as const
export type ReelMediaKind = (typeof REEL_MEDIA_KINDS)[number]
export type ReelMediaField = 'video' | 'videoWithAudio' | 'narrationAudio' | 'captions' | 'cover'
export const reelMediaFieldByKind: Record<ReelMediaKind, ReelMediaField> = {
video: 'video',
'video-audio': 'videoWithAudio',
narration: 'narrationAudio',
captions: 'captions',
cover: 'cover',
}
export const isReelMediaKind = (value: unknown): value is ReelMediaKind =>
REEL_MEDIA_KINDS.includes(value as ReelMediaKind)
/**
* The authenticated serving path of one artifact. Lives under `/campanha` on
* purpose: the `campaign-token` cookie is scoped to that path, so `<video>`/
* `<img>` reach it without an `Authorization` header.
*/
export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
`/campanha/comunicacao/reels/${reelId}/media/${kind}`
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'

66
src/lib/reelMedia.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* C193 — pure response rules of a private reel artifact: the safe content type,
* the content disposition and the range/private headers. No I/O and no
* `server-only`: the streaming utility and the unit tests share this module.
*/
/** Only these may render inline; anything else is forced to download. */
const REEL_MEDIA_INLINE_MIME_TYPES = ['video/mp4', 'audio/mpeg', 'image/png', 'image/jpeg'] as const
export const REEL_MEDIA_FALLBACK_MIME_TYPE = 'application/octet-stream'
export const REEL_MEDIA_CACHE_CONTROL = 'private, no-store'
export type ReelMediaRange = {
status: number
headers: Record<string, string>
}
const isInlineSafe = (mimeType: unknown): mimeType is string =>
typeof mimeType === 'string' &&
(REEL_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
/**
* The served content type is the stored one only when it is safe to render
* inline; everything else degrades to `application/octet-stream` (never an
* attacker-chosen type executed in the campaign origin).
*/
export const reelMediaContentType = (mimeType: string | null | undefined): string =>
isInlineSafe(mimeType) ? mimeType : REEL_MEDIA_FALLBACK_MIME_TYPE
/** Both `filename` and `filename*` (RFC 5987) so non-ASCII names survive. */
export const reelMediaContentDisposition = (
filename: string | null | undefined,
download: boolean,
): string => {
const name = filename?.trim() || 'arquivo'
const asciiFallback = name.replace(/[^\w.-]+/g, '_')
const disposition = download ? 'attachment' : 'inline'
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The response headers of one artifact: the range headers computed from the
* real file size plus the private streaming contract. Pure.
*/
export const reelMediaHeaders = ({
range,
mimeType,
filename,
download,
}: {
range: ReelMediaRange
mimeType: string | null | undefined
filename: string | null | undefined
download: boolean
}): Headers => {
const headers = new Headers(range.headers)
const contentType = reelMediaContentType(mimeType)
const forceDownload = download || contentType === REEL_MEDIA_FALLBACK_MIME_TYPE
headers.set('Content-Type', contentType)
headers.set('Content-Disposition', reelMediaContentDisposition(filename, forceDownload))
headers.set('Cache-Control', REEL_MEDIA_CACHE_CONTROL)
headers.set('X-Content-Type-Options', 'nosniff')
if (forceDownload) headers.set('Content-Security-Policy', "default-src 'none'")
return headers
}

View file

@ -13,7 +13,7 @@ export const speechVodRequestSchema = z.object({
* end only would silently collapse a real refusal into the generic error.
*/
/** The catalog gate (`canReadSpeechCatalog`) refused the actor. */
/** The catalog gate (`canReadCommunicationCatalog`) refused the actor. */
export const SPEECH_VOD_FORBIDDEN_MESSAGE = 'Você não tem acesso ao acervo de falas.'
/** The speech does not exist (or the actor cannot read it). */

View file

@ -100,9 +100,41 @@ export const findHighlightRanges = (text: string, query: string): SpeechHighligh
}
}
ranges.sort((left, right) => left.start - right.start || left.end - right.end)
return mergeHighlightRanges(ranges)
}
/**
* C192 — every occurrence of the whole normalized PHRASE as a single range, so
* a theme match like "acesso universal à saúde" is one continuous band (the
* per-term search would drop the "à" and split the band). Falls back to the
* per-term ranges when the phrase does not occur contiguously.
*/
export const findPhraseRanges = (text: string, phrase: string): SpeechHighlightRange[] => {
const needle = normalizeForSearch(phrase)
if (!needle || text.length === 0) return []
const { normalized, sourceIndex } = buildNormalizedIndex(text)
if (normalized.length === 0) return []
const ranges: SpeechHighlightRange[] = []
let from = 0
while (from <= normalized.length - needle.length) {
const at = normalized.indexOf(needle, from)
if (at === -1) break
ranges.push({
start: sourceIndex[at]!,
end: sourceIndex[at + needle.length - 1]! + 1,
})
from = at + 1
}
return ranges.length > 0 ? mergeHighlightRanges(ranges) : findHighlightRanges(text, phrase)
}
const mergeHighlightRanges = (ranges: readonly SpeechHighlightRange[]): SpeechHighlightRange[] => {
const sorted = [...ranges].sort((left, right) => left.start - right.start || left.end - right.end)
const merged: SpeechHighlightRange[] = []
for (const range of ranges) {
for (const range of sorted) {
const last = merged[merged.length - 1]
if (last && range.start <= last.end) {
if (range.end > last.end) last.end = range.end
@ -142,12 +174,12 @@ export const splitHighlightedParts = (text: string, query: string): SpeechHighli
export const buildHighlightedExcerpt = (
text: string,
query: string,
{ radius = 160 }: { radius?: number } = {},
{ radius = 160, phrase = false }: { radius?: number; phrase?: boolean } = {},
): SpeechHighlightedExcerpt => {
const trimmed = text.trim()
if (trimmed === '') return { parts: [], truncatedStart: false, truncatedEnd: false }
const ranges = findHighlightRanges(trimmed, query)
const ranges = phrase ? findPhraseRanges(trimmed, query) : findHighlightRanges(trimmed, query)
const focus = ranges[0]
const windowStart = focus ? Math.max(0, focus.start - radius) : 0
const windowEnd = focus

View file

@ -21,12 +21,29 @@ type SpeechSearchableRecord = {
}
/**
* C180 — pure mirror of the textual `where` branch
* C180 — pure mirror of a SINGLE textual `where` branch
* (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE
* the normalized query, OR a raw official keyword containing the query.
* the normalized term, OR a raw official keyword containing the term.
* `keywords` compare case-insensitively (accent still significant) to match the
* ILIKE `%q%` Postgres runs. Adding or removing a branch there means changing
* this predicate and its test in the same commit.
* ILIKE `%term%` Postgres runs. Adding or removing a branch there means
* changing this predicate and its test in the same commit. An empty term never
* matches (a branch with no term cannot surface a row).
*/
export const speechMatchesSearchTerm = (
speech: SpeechSearchableRecord,
term: string | undefined,
): boolean => {
const trimmed = term?.trim()
if (!trimmed) return false
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
const lowered = trimmed.toLowerCase()
return (speech.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(lowered))
}
/**
* C180 — the search query mirror: empty/missing query is an unconditional match
* (no textual filter), otherwise any of its terms. C192 reuses
* `speechMatchesSearchTerm` for the expanded theme terms.
*/
export const speechMatchesSearchQuery = (
speech: SpeechSearchableRecord,
@ -34,9 +51,7 @@ export const speechMatchesSearchQuery = (
): boolean => {
const trimmed = query?.trim()
if (!trimmed) return true
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
const lowered = trimmed.toLowerCase()
return (speech.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(lowered))
return speechMatchesSearchTerm(speech, trimmed)
}
/**

View file

@ -0,0 +1,30 @@
/**
* C192 — sanitization of the LLM-expanded theme terms before they reach the
* Payload `where`. The expansion is untrusted model output: `%`/`_`/`\` would
* widen the `LIKE`/`ILIKE` patterns, very short tokens produce noise, and the
* list has to stay bounded so the textual OR does not explode. Pure and
* client-safe.
*/
import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch'
export const MAX_SPEECH_THEME_TERMS = 8
export const MIN_SPEECH_THEME_TERMS = 3
export const MAX_SPEECH_THEME_TERM_LENGTH = 60
const MIN_THEME_TERM_LENGTH = 3
const LIKE_WILDCARDS = /[%_\\]/g
const sanitizeSpeechThemeTerm = (value: string): string =>
value
.replace(LIKE_WILDCARDS, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_SPEECH_THEME_TERM_LENGTH)
.trim()
/** Removes wildcards, drops too-short/empty terms, dedupes and caps the list. */
export const normalizeSpeechThemeTerms = (values: readonly string[]): string[] =>
uniqueByNormalizedForm(
values
.map(sanitizeSpeechThemeTerm)
.filter((term) => normalizeForSearch(term).length >= MIN_THEME_TERM_LENGTH),
).slice(0, MAX_SPEECH_THEME_TERMS)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_reel_feature" AS ENUM('cards');
CREATE TYPE "public"."enum_reel_status" AS ENUM('draft', 'published', 'unpublished');
CREATE TABLE "reel" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"feature" "enum_reel_feature" NOT NULL,
"status" "enum_reel_status" DEFAULT 'draft' NOT NULL,
"video_id" integer NOT NULL,
"video_with_audio_id" integer,
"narration_audio_id" integer,
"captions_id" integer,
"cover_id" integer NOT NULL,
"transcript" varchar,
"published_at" timestamp(3) with time zone,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "reel_media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "reel_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "reel_media_id" integer;
ALTER TABLE "reel" ADD CONSTRAINT "reel_video_id_reel_media_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_video_with_audio_id_reel_media_id_fk" FOREIGN KEY ("video_with_audio_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_narration_audio_id_reel_media_id_fk" FOREIGN KEY ("narration_audio_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_captions_id_reel_media_id_fk" FOREIGN KEY ("captions_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_cover_id_reel_media_id_fk" FOREIGN KEY ("cover_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "reel_status_idx" ON "reel" USING btree ("status");
CREATE INDEX "reel_video_idx" ON "reel" USING btree ("video_id");
CREATE INDEX "reel_video_with_audio_idx" ON "reel" USING btree ("video_with_audio_id");
CREATE INDEX "reel_narration_audio_idx" ON "reel" USING btree ("narration_audio_id");
CREATE INDEX "reel_captions_idx" ON "reel" USING btree ("captions_id");
CREATE INDEX "reel_cover_idx" ON "reel" USING btree ("cover_id");
CREATE INDEX "reel_created_by_idx" ON "reel" USING btree ("created_by_id");
CREATE INDEX "reel_updated_at_idx" ON "reel" USING btree ("updated_at");
CREATE INDEX "reel_created_at_idx" ON "reel" USING btree ("created_at");
CREATE INDEX "reel_media_updated_at_idx" ON "reel_media" USING btree ("updated_at");
CREATE INDEX "reel_media_created_at_idx" ON "reel_media" USING btree ("created_at");
CREATE UNIQUE INDEX "reel_media_filename_idx" ON "reel_media" USING btree ("filename");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_reel_fk" FOREIGN KEY ("reel_id") REFERENCES "public"."reel"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_reel_media_fk" FOREIGN KEY ("reel_media_id") REFERENCES "public"."reel_media"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_reel_id_idx" ON "payload_locked_documents_rels" USING btree ("reel_id");
CREATE INDEX "payload_locked_documents_rels_reel_media_id_idx" ON "payload_locked_documents_rels" USING btree ("reel_media_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "reel" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "reel_media" DISABLE ROW LEVEL SECURITY;
DROP TABLE "reel" CASCADE;
DROP TABLE "reel_media" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_reel_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_reel_media_fk";
DROP INDEX "payload_locked_documents_rels_reel_id_idx";
DROP INDEX "payload_locked_documents_rels_reel_media_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "reel_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "reel_media_id";
DROP TYPE "public"."enum_reel_feature";
DROP TYPE "public"."enum_reel_status";`)
}

View file

@ -72,6 +72,7 @@ import * as migration_20260913_145820_add_speech_search_text from './20260913_14
import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index';
import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add_speech_cut';
import * as migration_20260915_142058_activity_google_import_link from './20260915_142058_activity_google_import_link';
import * as migration_20260918_201009_add_reel from './20260918_201009_add_reel';
export const migrations = [
{
@ -442,6 +443,11 @@ export const migrations = [
{
up: migration_20260915_142058_activity_google_import_link.up,
down: migration_20260915_142058_activity_google_import_link.down,
name: '20260915_142058_activity_google_import_link'
name: '20260915_142058_activity_google_import_link',
},
{
up: migration_20260918_201009_add_reel.up,
down: migration_20260918_201009_add_reel.down,
name: '20260918_201009_add_reel'
},
];

View file

@ -89,6 +89,8 @@ export interface Config {
speech: Speech;
speechSegment: SpeechSegment;
speechCut: SpeechCut;
reel: Reel;
reelMedia: ReelMedia;
calendarFeed: CalendarFeed;
googleCalendarSync: GoogleCalendarSync;
electionTally: ElectionTally;
@ -133,6 +135,8 @@ export interface Config {
speech: SpeechSelect<false> | SpeechSelect<true>;
speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>;
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
reel: ReelSelect<false> | ReelSelect<true>;
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
@ -1061,6 +1065,76 @@ export interface SpeechCut {
updatedAt: string;
createdAt: string;
}
/**
* Reels de tutorial da assessoria. Os arquivos são privados e só abrem com login da campanha; publicar não dispara nada no Instagram.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reel".
*/
export interface Reel {
id: number;
title: string;
/**
* Funcionalidade do site que o tutorial ensina.
*/
feature: 'cards';
/**
* Despublicar tira o reel da biblioteca e para de servir os arquivos (kill switch).
*/
status: 'draft' | 'published' | 'unpublished';
/**
* MP4 1080×1920 sem áudio — o arquivo principal que a assessoria baixa.
*/
video: number | ReelMedia;
/**
* Variante com a narração embutida, quando existir.
*/
videoWithAudio?: (number | null) | ReelMedia;
/**
* Rascunho da narração em áudio, quando existir.
*/
narrationAudio?: (number | null) | ReelMedia;
/**
* Arquivo de legenda pronto, vindo da produção.
*/
captions?: (number | null) | ReelMedia;
/**
* Imagem vertical usada na biblioteca e no player.
*/
cover: number | ReelMedia;
/**
* Texto da narração ou roteiro, vindo da produção.
*/
transcript?: string | null;
publishedAt?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Arquivos privados dos reels (vídeo, narração, legenda, capa). Só abrem com login da campanha.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reelMedia".
*/
export interface ReelMedia {
id: number;
/**
* Descrição do artefato para acessibilidade (capa e vídeo).
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed".
@ -1610,6 +1684,14 @@ export interface PayloadLockedDocument {
relationTo: 'speechCut';
value: number | SpeechCut;
} | null)
| ({
relationTo: 'reel';
value: number | Reel;
} | null)
| ({
relationTo: 'reelMedia';
value: number | ReelMedia;
} | null)
| ({
relationTo: 'calendarFeed';
value: number | CalendarFeed;
@ -2191,6 +2273,43 @@ export interface SpeechCutSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reel_select".
*/
export interface ReelSelect<T extends boolean = true> {
title?: T;
feature?: T;
status?: T;
video?: T;
videoWithAudio?: T;
narrationAudio?: T;
captions?: T;
cover?: T;
transcript?: T;
publishedAt?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reelMedia_select".
*/
export interface ReelMediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed_select".
@ -2924,6 +3043,8 @@ export interface TaskCreateCollectionExport {
| 'speech'
| 'speechSegment'
| 'speechCut'
| 'reel'
| 'reelMedia'
| 'calendarFeed'
| 'googleCalendarSync'
| 'electionTally'

View file

@ -32,6 +32,8 @@ import { Organization } from './collections/Organization'
import { Petition } from './collections/Petition'
import { Post } from './collections/Post'
import { PushSubscription } from './collections/PushSubscription'
import { Reel } from './collections/Reel'
import { ReelMedia } from './collections/ReelMedia'
import { Signature } from './collections/Signature'
import { Speech } from './collections/Speech'
import { SpeechCut } from './collections/SpeechCut'
@ -117,6 +119,8 @@ export default buildConfig({
Speech,
SpeechSegment,
SpeechCut,
Reel,
ReelMedia,
CalendarFeed,
GoogleCalendarSync,
ElectionTally,
@ -162,6 +166,9 @@ export default buildConfig({
s3Storage({
collections: {
media: true,
// C193 — private reel artifacts; without this entry they would
// fall back to the container's ephemeral disk in production.
reelMedia: true,
},
bucket: mediaStorage.bucket,
config: {

View file

@ -0,0 +1,34 @@
// ---------------------------------------------------------------------------
// C193 — the private reel library (reel + reelMedia)
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { getFreshCampaignUser, isPayloadAdmin } from '@/utilities/access/shared'
/**
* Readers/managers of the private reel library: Payload admin or campaign
* users with the `communicator`, `coordinator` or `candidate` role. Advisors
* and leaders are denied — the library is not municipality-scoped and not a
* staff area.
*/
export const canReadReel: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may remove a reel (or one of its files): the same library audience plus
* the Payload admin. Written as its own predicate, never an alias of
* `canReadReel`, so a future widening of the read surface cannot silently
* grant delete.
*/
export const canDeleteReel: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}

View file

@ -4,7 +4,7 @@
import type { Access } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
getFreshCampaignUser,
isCampaignUnrestricted,
@ -20,7 +20,7 @@ export const canReadSpeech: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadSpeechCatalog(currentUser.role) : false
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/** Manual facet correction — unrestricted roles (and Payload admin) only. */
@ -40,7 +40,7 @@ export const canDeleteSpeechCut: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadSpeechCatalog(currentUser.role) : false
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
@ -54,7 +54,7 @@ export const canReadSpeechCut: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser && canReadSpeechCatalog(currentUser.role)
return currentUser && canReadCommunicationCatalog(currentUser.role)
? true
: { status: { equals: 'published' } }
}

View file

@ -0,0 +1,74 @@
import 'server-only'
import { deepSeek } from '@ai-sdk/deepseek'
import { generateObject } from 'ai'
import { z } from 'zod'
import {
MAX_SPEECH_THEME_TERM_LENGTH,
MAX_SPEECH_THEME_TERMS,
MIN_SPEECH_THEME_TERMS,
normalizeSpeechThemeTerms,
} from '@/lib/speechThemeTerms'
const EXPAND_TIMEOUT_MS = 4000
const MAX_OUTPUT_TOKENS = 200
const MAX_THEME_LENGTH = 200
const expandSchema = z.object({
terms: z
.array(z.string())
.describe(
`Termos e expressões curtas (${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS}) que podem aparecer na transcrição de falas sobre o tema. Lista vazia quando não houver termo confiável.`,
),
})
const EXPAND_SYSTEM_PROMPT =
'Você ajuda a assessoria de comunicação a achar, no acervo de discursos do deputado Jorge Solla, ' +
'falas sobre um tema mesmo quando ele não usou as palavras exatas da busca. ' +
'Receberá o tema digitado e deve devolver termos e expressões curtas que provavelmente aparecem ' +
'na transcrição de uma fala sobre esse tema — sinônimos, nomes de programas, termos técnicos e ' +
'palavras relacionadas. Inclua a própria expressão do tema quando ela for útil. ' +
`Devolva de ${MIN_SPEECH_THEME_TERMS} a ${MAX_SPEECH_THEME_TERMS} termos, cada um com no máximo ${MAX_SPEECH_THEME_TERM_LENGTH} caracteres, sem explicações, ` +
'sem pontuação extra e sem termos genéricos que apareceriam em qualquer discurso (ex.: "Brasil", ' +
'"povo", "governo"). Se não houver termo confiável, devolva a lista vazia. Nunca invente fatos.'
type SpeechThemeExpansion = { terms: string[] }
/**
* The injectable expansion boundary (tests pass a deterministic resolver, the
* same pattern as `SpeechVideoStartResolver`).
*/
export type SpeechThemeExpansionResolver = (theme: string) => Promise<SpeechThemeExpansion | null>
/**
* C192 — resolves the query into a theme expansion. Never throws: missing key,
* empty theme, timeout, provider error or malformed output resolve to `null`,
* and the caller falls back to the literal search with a discreet notice. An
* empty `terms` list is a legitimate answer (the model ran but had nothing to
* add) and is distinct from `null` ("mechanism unavailable").
*/
export const expandSpeechSearchTheme: SpeechThemeExpansionResolver = async (theme) => {
if (!process.env.DEEPSEEK_API_KEY) return null
const trimmed = theme.trim().slice(0, MAX_THEME_LENGTH)
if (!trimmed) return null
try {
const { object } = await generateObject({
model: deepSeek('deepseek-flash'),
schema: expandSchema,
system: EXPAND_SYSTEM_PROMPT,
prompt: trimmed,
// Deterministic as the provider allows: pages 2+ of the same search must
// not be built from a different expansion.
temperature: 0,
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: AbortSignal.timeout(EXPAND_TIMEOUT_MS),
})
return { terms: normalizeSpeechThemeTerms(object.terms) }
} catch {
return null
}
}

View file

@ -2,7 +2,7 @@ import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { formatSpeechClock } from '@/lib/speechClock'
import {
buildSpeechExcerpt,
@ -87,7 +87,7 @@ export const findSpeechExcerpts = (ctx: AIToolContext) =>
.describe('Máximo de trechos sugeridos (default 3, max 5).'),
}),
execute: async ({ tema, intencao, limit }) => {
if (!canReadSpeechCatalog(ctx.user.role)) return { error: DENIED_MESSAGE }
if (!canReadCommunicationCatalog(ctx.user.role)) return { error: DENIED_MESSAGE }
const terms = speechExcerptTerms(tema)
const consulta = { tema: tema.trim(), termos: terms, intencao: intencao?.trim() ?? null }

View file

@ -175,3 +175,5 @@ export {
canReadSpeechCut,
canUpdateSpeech,
} from '@/utilities/access/speeches'
export { canDeleteReel, canReadReel } from '@/utilities/access/reels'

View file

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
import { CAMPAIGN_COMMUNICATION_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import {
canReadSpeechCatalog,
canReadCommunicationCatalog,
isStaffCampaignRole,
isUnrestrictedCampaignRole,
} from '@/lib/campaignRoles'
@ -71,7 +71,7 @@ export const requireCampaignPageActor = async (
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
// C154 — the speech catalog vertical: communicator/coordinator/candidate,
// the same predicate the collection access uses.
if (gate === 'speechCatalog' && !canReadSpeechCatalog(user.role)) redirect(denyRedirect)
if (gate === 'speechCatalog' && !canReadCommunicationCatalog(user.role)) redirect(denyRedirect)
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
if (gate === 'writable' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)

View file

@ -0,0 +1,173 @@
import 'server-only'
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import { getRangeRequestInfo } from 'payload/internal'
import { REEL_MEDIA_SLUG } from '@/lib/reel'
import { REEL_MEDIA_CACHE_CONTROL, reelMediaHeaders, type ReelMediaRange } from '@/lib/reelMedia'
import type { ReelMedia } from '@/payload-types'
import { resolveS3StorageEnv } from '@/utilities/mediaStorage'
/**
* C193 — serves one private reel artifact as an HTTP response. The reel media
* never goes through the public `/api/media/file` proxy: the authenticated
* route under `/campanha` opens the object here and streams it back.
*
* In production the object lives in the private Garage bucket (same S3_* envs
* as `media`); with no S3_* set (dev/test) it is read from the upload
* collection's local disk directory, mirroring Payload's own file handler.
*/
const DEFAULT_STATIC_DIR = REEL_MEDIA_SLUG
type ReelMediaFile = Pick<ReelMedia, 'filename' | 'filesize' | 'mimeType'>
type ReelMediaStorage = { bucket: string; client: S3Client }
let cachedStorage: ReelMediaStorage | null | undefined
const reelMediaStorage = (): ReelMediaStorage | null => {
if (cachedStorage !== undefined) return cachedStorage
const config = resolveS3StorageEnv(process.env)
cachedStorage = config.enabled
? {
bucket: config.bucket,
client: new S3Client({
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
region: config.region,
endpoint: config.endpoint,
forcePathStyle: true,
}),
}
: null
return cachedStorage
}
const isMissingObject = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false
const name = 'name' in error ? String(error.name) : ''
const code = 'Code' in error ? String(error.Code) : ''
const metadata =
'$metadata' in error && error.$metadata && typeof error.$metadata === 'object'
? (error.$metadata as { httpStatusCode?: number })
: null
return (
name === 'NoSuchKey' ||
name === 'NotFound' ||
code === 'NoSuchKey' ||
metadata?.httpStatusCode === 404
)
}
type OpenedObject = { range: ReelMediaRange; body: BodyInit | null }
const openS3Object = async ({
storage,
filename,
rangeHeader,
}: {
storage: ReelMediaStorage
filename: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
const head = await storage.client.send(
new HeadObjectCommand({ Bucket: storage.bucket, Key: filename }),
)
const fileSize = head.ContentLength
if (fileSize == null) throw new Error(`Objeto de reel sem tamanho: ${filename}`)
const range = getRangeRequestInfo({ fileSize, rangeHeader })
if (range.type === 'invalid') return { range, body: null }
const object = await storage.client.send(
new GetObjectCommand({
Bucket: storage.bucket,
Key: filename,
...(range.type === 'partial' ? { Range: `bytes=${range.rangeStart}-${range.rangeEnd}` } : {}),
}),
)
if (!object.Body) throw new Error(`Objeto de reel sem corpo: ${filename}`)
return { range, body: object.Body as unknown as BodyInit }
}
const openLocalObject = async ({
staticDir,
filename,
rangeHeader,
}: {
staticDir: string
filename: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
const resolvedDir = path.resolve(staticDir || DEFAULT_STATIC_DIR)
const filePath = path.resolve(resolvedDir, filename)
if (!filePath.startsWith(resolvedDir + path.sep)) {
throw new Error('Nome de arquivo de reel inválido.')
}
const stats = await stat(filePath)
const range = getRangeRequestInfo({ fileSize: stats.size, rangeHeader })
if (range.type === 'invalid') return { range, body: null }
const body =
range.type === 'partial'
? (createReadStream(filePath, {
start: range.rangeStart,
end: range.rangeEnd,
}) as unknown as BodyInit)
: (createReadStream(filePath) as unknown as BodyInit)
return { range, body }
}
const notFound = (): Response =>
new Response(null, { status: 404, headers: { 'Cache-Control': REEL_MEDIA_CACHE_CONTROL } })
/**
* Streams one artifact. A missing object answers `404`; an unsatisfiable range
* answers `416` (both with the private headers) — never a leaked S3 error.
*/
export const buildReelMediaResponse = async ({
media,
staticDir,
rangeHeader,
download,
}: {
media: ReelMediaFile
staticDir: string
rangeHeader: string | null
download: boolean
}): Promise<Response> => {
const filename = media.filename
if (!filename) return notFound()
try {
const storage = reelMediaStorage()
const opened = storage
? await openS3Object({ storage, filename, rangeHeader })
: await openLocalObject({ staticDir, filename, rangeHeader })
return new Response(opened.body, {
status: opened.range.status,
headers: reelMediaHeaders({
range: opened.range,
mimeType: media.mimeType,
filename,
download,
}),
})
} catch (error) {
if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return notFound()
}
throw error
}
}

View file

@ -49,22 +49,48 @@ const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
return filters
}
const buildSpeechTextBranches = (q: string): Where[] => [
{ searchText: { like: normalizeForSearch(q) } },
{ keywords: { contains: q } },
]
const buildSpeechTextBranches = (q: string, themeTerms: readonly string[] = []): Where[] => {
const branches: Where[] = []
const seenText = new Set<string>()
const seenKeyword = new Set<string>()
// C192 — the literal query plus the expanded theme terms, each contributing
// the same two branches (normalized text / raw keyword). Duplicates are
// dropped so an expansion that repeats the query does not widen the OR.
for (const term of [q, ...themeTerms]) {
const normalized = normalizeForSearch(term)
if (normalized && !seenText.has(normalized)) {
seenText.add(normalized)
branches.push({ searchText: { like: normalized } })
}
const raw = term.trim()
const lowered = raw.toLowerCase()
if (raw && !seenKeyword.has(lowered)) {
seenKeyword.add(lowered)
branches.push({ keywords: { contains: raw } })
}
}
return branches
}
/**
* The textual branch: normalized speech text OR a raw official keyword. The
* keyword `contains` compiles to ILIKE `%q%` — case-insensitive, accent still
* significant. The view-model mirror is `lib/speechSearch.speechMatchesSearchQuery`
* (C180); a semantics change here has to land there too.
* The textual branch: normalized speech text OR a raw official keyword, for the
* query and (C192) every expanded theme term. The keyword `contains` compiles
* to ILIKE `%q%` — case-insensitive, accent still significant. The view-model
* mirror is `lib/speechSearch.speechMatchesSearchTerm`; a semantics change here
* has to land there too.
*/
const buildSpeechTextWhere = (q: string): Where => ({ or: buildSpeechTextBranches(q) })
const buildSpeechTextWhere = (q: string, themeTerms: readonly string[] = []): Where => ({
or: buildSpeechTextBranches(q, themeTerms),
})
export const buildSpeechListWhere = (state: SpeechListState): Where => {
export const buildSpeechListWhere = (
state: SpeechListState,
themeTerms: readonly string[] = [],
): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) filters.push(buildSpeechTextWhere(state.q))
if (state.q) filters.push(buildSpeechTextWhere(state.q, themeTerms))
return filters.length ? { and: filters } : {}
}
@ -72,15 +98,18 @@ export const buildSpeechListWhere = (state: SpeechListState): Where => {
/**
* C174 (option B) — the acervo `where` widened to the origin speech of a
* matching cut: the page still paginates by SPEECH, so the origin ids are OR-ed
* into the textual branch (facets stay AND-ed over every row).
* into the textual branch (facets stay AND-ed over every row). C192 adds the
* expanded theme terms to that same textual branch; only the literal query
* drives the cut-origin lookup.
*/
export const buildSpeechListWhereIncludingCutOrigins = (
state: SpeechListState,
originSpeechIds: readonly number[],
themeTerms: readonly string[] = [],
): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) {
const branches = buildSpeechTextBranches(state.q)
const branches = buildSpeechTextBranches(state.q, themeTerms)
if (originSpeechIds.length) branches.push({ id: { in: [...originSpeechIds] } })
filters.push({ or: branches })
}

View file

@ -26,6 +26,14 @@ import {
export const speechPageSize = 25
/**
* C192 — search mode of the acervo. `termo` is the literal search (default,
* never serialized so existing deep links stay byte-identical); `tema` asks the
* semantic bridge for related terms. Kept as a single-value param, only
* meaningful alongside `q`.
*/
export type SpeechSearchMode = 'termo' | 'tema'
/** Duration buckets are ranges over `durationSeconds`; `sem_duracao` is null. */
export const SPEECH_DURATION_BUCKETS = [
{ value: 'curta', label: 'Até 2 min' },
@ -51,6 +59,7 @@ export const speechScopeLabels = Object.fromEntries(
export type SpeechListState = {
page: number
q?: string
mode?: SpeechSearchMode
years?: number[]
topics?: SpeechTopic[]
scopes?: SpeechScope[]
@ -70,6 +79,7 @@ type RawSpeechListParams = RawSearchParams
const speechListParamNames = [
'q',
'mode',
'year',
'topic',
'scope',
@ -95,10 +105,15 @@ const parseMunicipalityValues = (raw: string | string[] | undefined): number[] =
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => id !== undefined && id > 0)
/** Only `tema` is meaningful; anything else (and `termo`) means the default. */
const parseSpeechSearchMode = (raw: string | string[] | undefined): SpeechSearchMode | undefined =>
firstValue(raw) === 'tema' ? 'tema' : undefined
export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const rawQ = normalizedText(firstValue(params.q))
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
const mode = parseSpeechSearchMode(params.mode)
const years = parseYearValues(params.year)
const topics = parseExhaustiveEnumParam<SpeechTopic>(params.topic, speechTopicSet)
const scopes = parseExhaustiveEnumParam<SpeechScope>(params.scope, speechScopeSet)
@ -112,6 +127,7 @@ export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListSt
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(mode ? { mode } : {}),
...(years.length ? { years } : {}),
...(topics.length ? { topics } : {}),
...(scopes.length ? { scopes } : {}),
@ -127,6 +143,7 @@ const speechListStateToRawParams = (
): RawSpeechListParams => ({
page: String(page),
q: state.q,
mode: state.mode,
year: state.years?.map(String),
topic: state.topics,
scope: state.scopes,
@ -141,6 +158,9 @@ export const serializeCanonicalSpeechListSearchParams = (
): URLSearchParams => {
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
// `termo` is the default and never serialized; `tema` only makes sense with a
// query, so a theme param without `q` canonicalizes away.
if (canonicalState.mode === 'tema' && canonicalState.q) params.set('mode', 'tema')
for (const year of canonicalState.years ?? []) params.append('year', String(year))
for (const topic of canonicalState.topics ?? []) params.append('topic', topic)
for (const scope of canonicalState.scopes ?? []) params.append('scope', scope)

View file

@ -22,6 +22,7 @@ import {
speechTopicLabels,
type SpeechDurationBucket,
type SpeechListState,
type SpeechSearchMode,
} from '@/utilities/speech/speechListUrl'
export type SpeechFilterOption = {
@ -29,6 +30,12 @@ export type SpeechFilterOption = {
label: string
}
/**
* C192 — the omnibox input's DOM id, shared by the filters bar and the empty
* state's "Reformular busca" focus action.
*/
export const SPEECH_OMNIBOX_ID = 'speech-omnibox'
export type SpeechOmniboxAction =
| { kind: 'url'; state: SpeechListState }
| { kind: 'clear'; state: SpeechListState }
@ -238,13 +245,32 @@ export const removeSpeechOmniboxChip = ({
state: SpeechListState
chipId: string
}): SpeechOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
// Dropping the query also drops the theme mode: a theme without a query has
// nothing to expand.
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined, mode: undefined }) }
}
const { key, value } = chipPrefix(chipId)
const next = mutateFacet(withPageReset(state), key, value, 'remove')
return { kind: 'url', state: next ?? state }
}
/**
* C192 — the search mode toggle. `tema` only sticks with a query; `termo`
* (the default) clears the param so the URL stays canonical and shareable.
*/
export const applySpeechSearchMode = ({
state,
mode,
}: {
state: SpeechListState
mode: SpeechSearchMode
}): SpeechOmniboxAction => ({
kind: 'url',
state: withPageReset({ ...state, mode: mode === 'tema' && state.q ? 'tema' : undefined }),
})
export const clearSpeechOmnibox = (_state: SpeechListState): SpeechOmniboxAction => ({
kind: 'clear',
state: { page: 1 },

View file

@ -2,8 +2,13 @@ import 'server-only'
import type { Payload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import type { CampaignUser, Speech } from '@/payload-types'
import {
expandSpeechSearchTheme,
type SpeechThemeExpansionResolver,
} from '@/utilities/ai/expandSpeechSearchTheme'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
import { getYouTubeVideoStart } from '@/utilities/socialFeed/youtubeFeed'
@ -155,17 +160,36 @@ export type SpeechAcervoPageData = {
totalDocs: number
totalPages: number
filterOptions: SpeechFilterOptions
/**
* C192 — true when the actor asked for `mode=tema` but the expansion
* mechanism was unavailable; the page shows the discreet fallback notice.
*/
themeUnavailable: boolean
/** C192 — true when the expansion actually contributed terms. */
themeApplied: boolean
}
export const loadSpeechAcervoPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
): Promise<SpeechAcervoPageData> => {
const rawSearchParams = await searchParams
const canonicalUrl = resolveSpeechListUrl(rawSearchParams)
const state = canonicalUrl.state
// C192 — only expand for an actor who may read the catalog (the `find` below
// is still the final, fail-closed barrier) and only with a query.
const themeRequested = state.mode === 'tema' && Boolean(state.q)
let themeTerms: readonly string[] = []
let themeUnavailable = false
if (themeRequested && canReadCommunicationCatalog(user.role)) {
const expansion = await expandTheme(state.q ?? '')
if (expansion) themeTerms = expansion.terms
else themeUnavailable = true
}
// C174 (option B) — a speech also shows up when one of its cuts matches the
// term by title/description, even if the speech text itself does not.
const originSpeechIds = state.q ? await loadSpeechCutOriginSpeechIds(payload, user, state.q) : []
@ -176,7 +200,7 @@ export const loadSpeechAcervoPageData = async (
limit: speechPageSize,
page: state.page,
sort: '-speechAt',
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds),
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds, themeTerms),
// C174 — the row only needs its normalized text to tell whether it matched
// the term itself (option B); the unsearched list does not pay for it.
select: state.q ? { ...speechListSelect, searchText: true } : speechListSelect,
@ -210,6 +234,7 @@ export const loadSpeechAcervoPageData = async (
query: state.q,
municipalityLabels,
cuts: cutsBySpeech.get(speech.id) ?? [],
themeTerms,
}),
),
state: resolvedUrl.state,
@ -217,6 +242,8 @@ export const loadSpeechAcervoPageData = async (
totalDocs: result.totalDocs,
totalPages: result.totalPages,
filterOptions,
themeUnavailable,
themeApplied: themeTerms.length > 0,
}
}

View file

@ -17,7 +17,11 @@ import {
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { speechPosterHref, speechPosterTarget } from '@/lib/speechPoster'
import { normalizeForSearch, speechMatchesSearchQuery } from '@/lib/speechSearch'
import {
normalizeForSearch,
speechMatchesSearchQuery,
speechMatchesSearchTerm,
} from '@/lib/speechSearch'
import {
correctedExcerptOffsetSeconds,
excerptOffsetSeconds,
@ -55,7 +59,7 @@ export type SpeechListRecord = {
searchText?: string | null
}
type SpeechListMatchKind = 'segment' | 'keyword' | 'fallback'
type SpeechListMatchKind = 'segment' | 'keyword' | 'theme' | 'fallback'
export type SpeechListItemViewModel = {
id: number
@ -89,6 +93,12 @@ export type SpeechListItemViewModel = {
* the term; the card then swaps the excerpt for the honest origin note.
*/
matchedTextSearch: boolean
/**
* C192 — the expanded theme term that matched the speech text/keywords, or
* null when the search was literal (or the expansion did not surface this
* row). The card uses it to label the result and to explain why it appeared.
*/
themeMatchTerm: string | null
}
export type SpeechDetailSegmentViewModel = {
@ -185,6 +195,41 @@ const pickMatchingSegment = (
})
}
/**
* C192 — the first expanded theme term that actually surfaced this speech. The
* gate is the `speechMatchesSearchTerm` mirror (the same predicate the `where`
* runs), so the card never claims a theme the search did not use. The evidence
* is the passage that carries the term, or the official keyword when that is
* what matched. Returns undefined when no theme term applies to this row.
*/
const pickThemeMatch = (
speech: SpeechListRecord,
segments: readonly SpeechSegmentRecord[],
themeTerms: readonly string[],
): { term: string; segment?: SpeechSegmentRecord; keyword?: string } | undefined => {
for (const term of themeTerms) {
const trimmed = term.trim()
const normalized = normalizeForSearch(trimmed)
if (!normalized || !speechMatchesSearchTerm(speech, trimmed)) continue
if (normalizeForSearch(speech.searchText ?? '').includes(normalized)) {
const segment =
segments.find((item) => normalizeForSearch(item.text).includes(normalized)) ??
segments.find((item) => {
const text = normalizeForSearch(item.text)
return speechSearchTerms(trimmed).some((word) => text.includes(word))
}) ??
segments[0]
return { term: trimmed, segment }
}
const lowered = trimmed.toLowerCase()
const keyword = (speech.keywords ?? []).find((item) => item.toLowerCase().includes(lowered))
if (keyword) return { term: trimmed, keyword }
}
return undefined
}
export const buildWatchHref = (
speechId: number,
segment: SpeechSegmentRecord | undefined,
@ -216,6 +261,7 @@ export const toSpeechListItemViewModel = ({
query,
municipalityLabels,
cuts = [],
themeTerms = [],
}: {
speech: SpeechListRecord
segments: readonly SpeechSegmentRecord[]
@ -223,6 +269,8 @@ export const toSpeechListItemViewModel = ({
municipalityLabels: ReadonlyMap<number, string>
/** C174 — the cuts of this speech, nested in the result card. */
cuts?: SpeechCuts
/** C192 — expanded theme terms; empty in the literal search. */
themeTerms?: readonly string[]
}): SpeechListItemViewModel => {
const matchedSegment = pickMatchingSegment(segments, query)
const q = query?.trim()
@ -232,8 +280,25 @@ export const toSpeechListItemViewModel = ({
Boolean(normalizedQuery) &&
(speech.keywords ?? []).some((keyword) => normalizeForSearch(keyword).includes(normalizedQuery))
// C192 — the theme evidence wins the excerpt so the card's "Por que apareceu"
// block shows the passage that actually matched, even when the speech also
// contains the literal query.
const themeMatch = pickThemeMatch(speech, segments, themeTerms)
const excerptSource =
matchedSegment?.text ?? segments[0]?.text ?? speech.summary ?? speech.officialTranscript ?? ''
themeMatch?.segment?.text ??
themeMatch?.keyword ??
matchedSegment?.text ??
segments[0]?.text ??
speech.summary ??
speech.officialTranscript ??
''
// C192 — the theme passage highlights the whole phrase as one band (the
// per-term split would drop the "à" and break it into pieces).
const excerpt = themeMatch
? buildHighlightedExcerpt(excerptSource, themeMatch.term, { phrase: true })
: buildHighlightedExcerpt(excerptSource, q ?? '')
return {
id: speech.id,
@ -241,8 +306,14 @@ export const toSpeechListItemViewModel = ({
type: speech.type ?? null,
durationLabel: formatSpeechDuration(speech.durationSeconds),
presidingOfficer: speech.presidingOfficer ?? null,
excerpt: buildHighlightedExcerpt(excerptSource, q ?? ''),
matchKind: matchedSegment ? 'segment' : keywordMatch ? 'keyword' : 'fallback',
excerpt,
matchKind: matchedSegment
? 'segment'
: keywordMatch
? 'keyword'
: themeMatch
? 'theme'
: 'fallback',
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
keywords: speech.keywords ?? [],
@ -252,6 +323,7 @@ export const toSpeechListItemViewModel = ({
officialTextUrl: speech.officialTextUrl ?? null,
cuts: [...cuts],
matchedTextSearch: speechMatchesSearchQuery(speech, query),
themeMatchTerm: themeMatch?.term ?? null,
}
}

View file

@ -335,6 +335,43 @@ test.describe('communication vertical (C154/C162)', () => {
expect(filteredHtml).toContain('Limpar busca e filtros')
})
test('theme mode degrades honestly to the exact search without the key (C192)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('temadeg')
await createSpeech(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
// The e2e environment blanks DEEPSEEK_API_KEY (playwright.config.ts), so the
// expansion is unavailable and the page must fall back with the notice.
const themed = await request.get(`/campanha/comunicacao/acervo?q=${marker}&mode=tema`)
expect(themed.status()).toBe(200)
const themedHtml = rendered(await themed.text())
expect(themedHtml).toContain('Modo de busca')
expect(themedHtml).toContain('Termo exato')
expect(themedHtml).toContain('Por tema')
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
expect(themedHtml).toContain('A busca por tema está indisponível agora.')
expect(themedHtml).toContain('Tentar por tema novamente')
expect(themedHtml).toContain('Resultados por termo exato')
expect(themedHtml).toContain('data-testid="speech-theme-fallback"')
// The exact results still render and keep the highlighted term.
expect(themedHtml).toContain(marker)
expect(themedHtml).toContain('<mark')
// No theme badge is fabricated.
expect(themedHtml).not.toContain('Por que apareceu')
// The default mode stays untouched: no mode param means no notice.
const exact = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
expect(exact.status()).toBe(200)
const exactHtml = rendered(await exact.text())
expect(exactHtml).toContain(marker)
expect(exactHtml).not.toContain('A busca por tema está indisponível agora.')
})
test('advisor and leader are redirected away from the acervo', async ({
campaign,
campaignRequest,

290
tests/int/reel.int.spec.ts Normal file
View file

@ -0,0 +1,290 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
const { getCampaignUserMock } = vi.hoisted(() => ({ getCampaignUserMock: vi.fn() }))
vi.mock('@/utilities/campaignAuth', () => ({
getCampaignUser: getCampaignUserMock,
}))
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/media/[kind]/route'
import type { CampaignUser, Reel, ReelMedia } from '@/payload-types'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const MP4_BYTES = Buffer.from('fixture-mp4-bytes')
const SRT_BYTES = Buffer.from('1\n00:00:00,000 --> 00:00:01,000\nOlá\n')
const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
let payload: Payload
const createdReelIds = new Set<number>()
const createdMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const createMedia = async (name: string, data: Buffer, mimetype: string): Promise<ReelMedia> => {
const media = await payload.create({
collection: 'reelMedia',
data: { alt: `Artefato ${name}` },
file: { data, mimetype, name, size: data.length },
overrideAccess: true,
})
createdMediaIds.add(media.id)
return media
}
const createReel = async ({
status = 'draft',
withCaptions = false,
title = 'Reel de tutorial',
}: {
status?: Reel['status']
withCaptions?: boolean
title?: string
} = {}): Promise<{ reel: Reel; video: ReelMedia }> => {
const video = await createMedia('reel.mp4', MP4_BYTES, 'video/mp4')
const cover = await createMedia('capa.png', PNG_BYTES, 'image/png')
const captions = withCaptions
? await createMedia('narracao.srt', SRT_BYTES, 'application/x-subrip')
: null
const reel = await payload.create({
collection: 'reel',
data: {
title,
feature: 'cards',
status,
video: video.id,
cover: cover.id,
...(captions ? { captions: captions.id } : {}),
},
overrideAccess: true,
})
createdReelIds.add(reel.id)
return { reel, video }
}
const callRoute = async ({
reelId,
kind,
user,
range,
download,
}: {
reelId: number
kind: string
user?: CampaignUser | null
range?: string
download?: boolean
}): Promise<Response> => {
getCampaignUserMock.mockResolvedValue(user ?? null)
const request = new Request(
`http://localhost/campanha/comunicacao/reels/${reelId}/media/${kind}${download ? '?download=1' : ''}`,
{ headers: range ? { range } : undefined },
)
return GET(request, { params: Promise.resolve({ id: String(reelId), kind }) })
}
const findReelsAs = (user: CampaignUser | undefined, id: number) =>
payload.find({
collection: 'reel',
where: { id: { equals: id } },
depth: 0,
limit: 1,
...(user ? { user } : {}),
overrideAccess: false,
})
describe('reel private library (C193)', () => {
let communicator: CampaignUser
let coordinator: CampaignUser
let candidate: CampaignUser
let advisor: CampaignUser
let leader: CampaignUser
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
beforeEach(async () => {
const fixtures = campaignFixtures()
;[communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
})
afterAll(async () => {
for (const id of createdReelIds) {
await payload.delete({ collection: 'reel', id, overrideAccess: true }).catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'reelMedia', id, overrideAccess: true })
.catch(() => undefined)
}
})
it('reads the reel only with the communication roles', async () => {
const { reel } = await createReel({ status: 'published' })
await expect(findReelsAs(undefined, reel.id)).rejects.toThrow()
await expect(findReelsAs(advisor, reel.id)).rejects.toThrow()
await expect(findReelsAs(leader, reel.id)).rejects.toThrow()
expect((await findReelsAs(communicator, reel.id)).docs).toHaveLength(1)
expect((await findReelsAs(coordinator, reel.id)).docs).toHaveLength(1)
expect((await findReelsAs(candidate, reel.id)).docs).toHaveLength(1)
})
it('stamps the actor and the publication through the create path', async () => {
const video = await createMedia('stamp.mp4', MP4_BYTES, 'video/mp4')
const cover = await createMedia('stamp.png', PNG_BYTES, 'image/png')
const reel = await payload.create({
collection: 'reel',
data: {
title: 'Reel com carimbo',
feature: 'cards',
status: 'published',
video: video.id,
cover: cover.id,
},
depth: 0,
user: communicator,
overrideAccess: false,
})
createdReelIds.add(reel.id)
expect(reel.createdBy).toBe(communicator.id)
expect(reel.publishedAt).toBeTruthy()
})
it('serves a published artifact to a communication role', async () => {
const { reel } = await createReel({ status: 'published' })
const response = await callRoute({ reelId: reel.id, kind: 'video', user: communicator })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('video/mp4')
expect(response.headers.get('Content-Disposition')).toContain('inline')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(Buffer.from(await response.arrayBuffer())).toEqual(MP4_BYTES)
})
it('answers a byte range with 206 and the exact slice', async () => {
const { reel } = await createReel({ status: 'published' })
const response = await callRoute({
reelId: reel.id,
kind: 'video',
user: coordinator,
range: 'bytes=0-3',
})
expect(response.status).toBe(206)
expect(response.headers.get('Content-Range')).toBe(`bytes 0-3/${MP4_BYTES.length}`)
expect(response.headers.get('Content-Length')).toBe('4')
expect(Buffer.from(await response.arrayBuffer())).toEqual(MP4_BYTES.subarray(0, 4))
})
it('forces a download when asked and for unsafe types', async () => {
const { reel } = await createReel({ status: 'published', withCaptions: true })
const download = await callRoute({
reelId: reel.id,
kind: 'video',
user: communicator,
download: true,
})
expect(download.headers.get('Content-Disposition')).toContain('attachment')
const captions = await callRoute({
reelId: reel.id,
kind: 'captions',
user: communicator,
})
expect(captions.status).toBe(200)
expect(captions.headers.get('Content-Type')).toBe('application/octet-stream')
expect(captions.headers.get('Content-Disposition')).toContain('attachment')
expect(Buffer.from(await captions.arrayBuffer())).toEqual(SRT_BYTES)
})
it('kill switch: draft and unpublished never serve the file', async () => {
const draft = await createReel({ status: 'draft' })
const unpublished = await createReel({ status: 'unpublished' })
expect(
(await callRoute({ reelId: draft.reel.id, kind: 'video', user: communicator })).status,
).toBe(404)
expect(
(await callRoute({ reelId: unpublished.reel.id, kind: 'video', user: communicator })).status,
).toBe(404)
})
it('denies anonymous, advisor and leader with a silent 404', async () => {
const { reel } = await createReel({ status: 'published' })
for (const user of [undefined, advisor, leader]) {
const response = await callRoute({ reelId: reel.id, kind: 'video', user })
expect(response.status).toBe(404)
}
})
it('answers 404 for a missing artifact, an unknown kind and an unknown reel', async () => {
const { reel } = await createReel({ status: 'published' })
expect(
(await callRoute({ reelId: reel.id, kind: 'narration', user: communicator })).status,
).toBe(404)
expect((await callRoute({ reelId: reel.id, kind: 'poster', user: communicator })).status).toBe(
404,
)
expect((await callRoute({ reelId: 999999, kind: 'video', user: communicator })).status).toBe(
404,
)
})
it('keeps delete out of the advisor and in the communication roles', async () => {
const { reel } = await createReel({ status: 'published' })
await expect(
payload.delete({
collection: 'reel',
id: reel.id,
user: advisor,
overrideAccess: false,
}),
).rejects.toThrow()
await expect(
payload.delete({
collection: 'reel',
id: reel.id,
user: communicator,
overrideAccess: false,
}),
).resolves.toBeTruthy()
createdReelIds.delete(reel.id)
})
})

View file

@ -347,4 +347,73 @@ describe('speech acervo (C154)', () => {
loadSpeechDetailPageData(payload, communicator, 999_999_999),
).rejects.toBeInstanceOf(SpeechNotFoundError)
})
it('finds a speech by expanded theme terms and explains the match (C192)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `tema${runId}`
const id = await createSpeech({
segments: [
{
startSeconds: 0,
endSeconds: 3,
text: `Garantir o atendimento universal ${marker} na rede pública`,
},
],
})
const { coordinator } = await createUsers()
const expansionCalls: string[] = []
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: 'defesa do SUS', mode: 'tema' },
async (theme) => {
expansionCalls.push(theme)
return { terms: [`atendimento universal ${marker}`] }
},
)
expect(expansionCalls).toEqual(['defesa do SUS'])
expect(data.themeUnavailable).toBe(false)
expect(data.themeApplied).toBe(true)
const row = data.rows.find((item) => item.id === id)
expect(row?.matchKind).toBe('theme')
expect(row?.themeMatchTerm).toBe(`atendimento universal ${marker}`)
expect(row?.matchedTextSearch).toBe(false)
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('degrades to the literal search when the expansion is unavailable (C192)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `literal${runId}`
const id = await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 3, text: `A defesa do SUS ${marker} no plenário` }],
})
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `defesa do SUS ${marker}`, mode: 'tema' },
async () => null,
)
expect(data.themeUnavailable).toBe(true)
expect(data.themeApplied).toBe(false)
const row = data.rows.find((item) => item.id === id)
expect(row?.themeMatchTerm).toBeNull()
expect(row?.matchedTextSearch).toBe(true)
})
it('never expands for an actor outside the catalog gate (C192)', async () => {
const { advisor } = await createUsers()
let called = false
await expect(
loadSpeechAcervoPageData(payload, advisor, { q: 'SUS', mode: 'tema' }, async () => {
called = true
return { terms: ['saúde'] }
}),
).rejects.toThrow(/permissão/i)
expect(called).toBe(false)
})
})

View file

@ -43,3 +43,57 @@ describe('emitHtmlPairPdf', () => {
expect(typeof emitHtmlPairPdf).toBe('function')
})
})
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { screenshotHtmlPng } from '../../scripts/lib/buildPdf.mjs'
// C191: the PNG path is additive to the Chromium owner. The guard is on the
// critical path (a >8 MB export must fail closed), so it is pinned with a fake
// browser — no real Chromium launch in the unit suite.
const fakeBrowser = (calls: string[]) => ({
newPage: async () => ({
setViewportSize: async (size: { width: number; height: number }) =>
calls.push(`viewport:${size.width}x${size.height}`),
setContent: async () => calls.push('content'),
evaluate: async () => calls.push('fonts'),
screenshot: async (options: { clip: { width: number; height: number } }) =>
calls.push(`shot:${options.clip.width}x${options.clip.height}`),
close: async () => calls.push('close'),
}),
})
describe('screenshotHtmlPng', () => {
it('sets the exact viewport, clips the canvas and closes the page', async () => {
const calls: string[] = []
const outPath = join(await mkdtemp(join(tmpdir(), 'c191-')), 'out.png')
await writeFile(outPath, 'x')
const result = await screenshotHtmlPng(fakeBrowser(calls), {
html: '<html></html>',
width: 1080,
height: 1350,
outPath,
})
expect(result.size).toBe(1)
expect(calls).toContain('viewport:1080x1350')
expect(calls).toContain('shot:1080x1350')
expect(calls.at(-1)).toBe('close')
})
it('fails closed when the PNG exceeds maxBytes', async () => {
const outPath = join(await mkdtemp(join(tmpdir(), 'c191-')), 'big.png')
await writeFile(outPath, 'x'.repeat(10))
await expect(
screenshotHtmlPng(fakeBrowser([]), {
html: '',
width: 1080,
height: 1080,
outPath,
maxBytes: 4,
}),
).rejects.toThrow(/> 4/)
})
})

View file

@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import {
CHART_TYPES,
MAX_POINTS,
MAX_POINTS_STORY,
RELATION_LABEL,
SIZES,
classifyRelation,
isTemporalLabel,
parseInput,
parseNumber,
validateSpec,
} from '../../scripts/lib/chartData.mjs'
describe('parseNumber — pt-BR/en amounts, never invents a zero', () => {
it('reads thousands and decimals', () => {
expect(parseNumber('1.234,56')).toBe(1234.56)
expect(parseNumber('1,5')).toBe(1.5)
expect(parseNumber('1,234.56')).toBe(1234.56)
expect(parseNumber('R$ 84')).toBe(84)
expect(parseNumber('12%')).toBe(12)
expect(parseNumber(-3)).toBe(-3)
})
it('returns null for non-numeric input', () => {
expect(parseNumber('oitenta')).toBeNull()
expect(parseNumber('')).toBeNull()
expect(parseNumber(null)).toBeNull()
})
})
describe('parseInput — the data as it came', () => {
it('reads a free text of label<space>value pairs', () => {
const dataset = parseInput({ text: 'Ilhéus 84\nItabuna 68\n', format: 'txt' })
expect(dataset.rows).toEqual([
{ label: 'Ilhéus', value: 84 },
{ label: 'Itabuna', value: 68 },
])
expect(dataset.issues).toEqual([])
})
it('reads colon-separated pairs and labels with spaces', () => {
const dataset = parseInput({ text: 'Votos em Ilhéus: 84\nVotos em Itabuna: 68', format: 'txt' })
expect(dataset.rows[0]).toEqual({ label: 'Votos em Ilhéus', value: 84 })
})
it('reads a GFM markdown table and drops the header', () => {
const dataset = parseInput({
text: '| Cidade | Votos |\n| --- | --- |\n| Ilhéus | 84 |\n| Itabuna | 68 |',
format: 'md',
})
expect(dataset.format).toBe('md')
expect(dataset.rows).toEqual([
{ label: 'Ilhéus', value: 84 },
{ label: 'Itabuna', value: 68 },
])
})
it('reads CSV with a header', () => {
const dataset = parseInput({ text: 'cidade,votos\nIlhéus,84\nItabuna,68\n', format: 'csv' })
expect(dataset.rows).toEqual([
{ label: 'Ilhéus', value: 84 },
{ label: 'Itabuna', value: 68 },
])
})
it('flags a non-numeric value instead of dropping it silently', () => {
const dataset = parseInput({ text: 'Ilhéus 84\nItabuna ???\n', format: 'txt' })
expect(dataset.rows).toHaveLength(1)
expect(dataset.issues.join(' ')).toContain('Itabuna')
})
it('does not mistake a first data row with a missing value for a header', () => {
const dataset = parseInput({ text: 'Ilhéus n/d\nItabuna 68\n', format: 'txt' })
expect(dataset.rows).toEqual([{ label: 'Itabuna', value: 68 }])
expect(dataset.issues.join(' ')).toContain('Ilhéus')
})
it('flags repeated labels', () => {
const dataset = parseInput({ text: 'A 1\nA 2\n', format: 'txt' })
expect(dataset.issues.join(' ')).toContain('repetido')
})
it('reads a bare number as an unlabeled single measure', () => {
const dataset = parseInput({ text: '72', format: 'txt' })
expect(dataset.rows).toEqual([{ label: '', value: 72 }])
expect(dataset.issues).toEqual([])
})
})
describe('classifyRelation — the relation of the data picks the type', () => {
it('single measure is an anchor', () => {
expect(classifyRelation([{ label: '', value: 72 }])).toBe('anchor')
})
it('few temporal periods are columns, many are a line', () => {
expect(
classifyRelation([
{ label: '2020', value: 1 },
{ label: '2021', value: 2 },
]),
).toBe('column')
expect(
classifyRelation(
['2018', '2019', '2020', '2021', '2022', '2023', '2024'].map((label) => ({
label,
value: 1,
})),
),
).toBe('line')
})
it('non-temporal categories are a ranking (bar)', () => {
expect(
classifyRelation([
{ label: 'Ilhéus', value: 1 },
{ label: 'Itabuna', value: 2 },
]),
).toBe('bar')
})
it('honors a forced type and refuses an unknown one', () => {
expect(classifyRelation([{ label: 'A', value: 1 }], 'bar')).toBe('bar')
expect(classifyRelation([{ label: 'A', value: 1 }], 'pie')).toBe('pie')
expect(() => classifyRelation([{ label: 'A', value: 1 }], 'donut')).toThrow(/desconhecido/)
})
})
describe('isTemporalLabel', () => {
it('detects years, months, quarters and P-periods', () => {
for (const label of ['2024', '2024-06', '06/2024', 'jan/24', 'Q1', 'T3', 'P2']) {
expect(isTemporalLabel(label), label).toBe(true)
}
expect(isTemporalLabel('Ilhéus')).toBe(false)
})
})
describe('validateSpec — fail-closed guardrails', () => {
const base = { chartType: 'bar', rows: [{ label: 'A', value: 1 }], headline: 'Takeaway' }
it('accepts a valid spec', () => {
expect(validateSpec(base)).toBe(base)
})
it('refuses pizza with an actionable message', () => {
expect(() => validateSpec({ ...base, chartType: 'pie' })).toThrow(/barras horizontais/)
})
it(`refuses more than ${MAX_POINTS} points`, () => {
const rows = Array.from({ length: MAX_POINTS + 1 }, (_v, index) => ({
label: `A${index}`,
value: 1,
}))
expect(() => validateSpec({ ...base, rows })).toThrow(/resuma/)
})
it('caps a story ranking at five points (approved vertical rhythm)', () => {
const rows = Array.from({ length: MAX_POINTS_STORY + 1 }, (_v, index) => ({
label: `A${index}`,
value: index + 1,
}))
expect(() => validateSpec({ ...base, size: 'feed', rows })).not.toThrow()
expect(() => validateSpec({ ...base, size: 'story', rows })).toThrow(/cinco|5/)
})
it('refuses negative values (bars come from zero)', () => {
expect(() => validateSpec({ ...base, rows: [{ label: 'A', value: -1 }] })).toThrow(/zero/)
})
it('refuses a missing headline', () => {
expect(() => validateSpec({ ...base, headline: '' })).toThrow(/manchete/)
})
it('refuses an unknown size', () => {
expect(() => validateSpec({ ...base, size: 'huge' })).toThrow(/tamanho inválido/)
})
it('refuses a highlight that is not in the data', () => {
expect(() => validateSpec({ ...base, highlight: 'Sumiu' })).toThrow(/destaque/)
})
it('refuses an anchor with more than one value', () => {
expect(() =>
validateSpec({
...base,
chartType: 'anchor',
rows: [
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
],
}),
).toThrow(/âncora/)
})
it('refuses an unlabeled bar row', () => {
expect(() => validateSpec({ ...base, rows: [{ label: '', value: 1 }] })).toThrow(/rótulo/)
})
})
describe('contracts', () => {
it('declares the four v1 types, labels and sizes', () => {
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor'])
expect(RELATION_LABEL.line).toBe('Série de tempo')
expect(SIZES.story).toMatchObject({ width: 1080, height: 1920, safeTop: 250, safeBottom: 250 })
expect(SIZES.feed).toMatchObject({ width: 1080, height: 1350 })
expect(SIZES.square).toMatchObject({ width: 1080, height: 1080 })
})
})

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import {
CHART_COLORS,
barChart,
columnChart,
lineChart,
proportionalPercent,
stackedColumnChart,
valueList,
} from '../../scripts/lib/chartPrimitives.mjs'
// C191: the chart primitives were extracted from dossieRender.mjs so the
// Instagram generator (second consumer) never grows a twin. These snapshots
// freeze the dossier emission byte-for-byte — a change here is a change to the
// published dossiê, not a cosmetic tweak.
describe('chartPrimitives — dossier emitters (byte-identical)', () => {
it('barChart', () => {
expect(
barChart({
rows: [
{ label: 'A', count: 3 },
{ label: 'B', count: 1 },
],
}),
).toMatchInlineSnapshot(`
"<svg class="chart" viewBox="0 0 330 42" role="img">
<text x="0" y="14" class="chart-label">A</text>
<rect x="118" y="5" width="186" height="12" rx="1" fill="#315c75" />
<text x="308" y="14" class="chart-value">3</text><text x="0" y="33" class="chart-label">B</text>
<rect x="118" y="24" width="62" height="12" rx="1" fill="#315c75" />
<text x="184" y="33" class="chart-value">1</text>
</svg>"
`)
})
it('columnChart', () => {
expect(
columnChart({
rows: [
{ key: '2020', value: 2 },
{ key: '2021', value: 4 },
],
format: (row: { value: number }) => String(row.value),
}),
).toMatchInlineSnapshot(`
"<svg class="chart" viewBox="0 0 330 152" role="img">
<rect x="6" y="65" width="156" height="49" fill="#315c75" />
<text x="84.5" y="62" class="chart-value" text-anchor="middle">2</text>
<text x="84.5" y="144" class="chart-label" text-anchor="middle">20</text><rect x="167" y="16" width="156" height="98" fill="#315c75" />
<text x="245.5" y="13" class="chart-value" text-anchor="middle">4</text>
<text x="245.5" y="144" class="chart-label" text-anchor="middle">21</text>
</svg>"
`)
})
it('stackedColumnChart', () => {
expect(
stackedColumnChart({
rows: [
{
key: '2020',
value: 3,
segments: [
{ key: 'pago', amount: 2 },
{ key: 'empenhado', amount: 1 },
],
},
],
format: (row: { value: number }) => String(row.value),
}),
).toMatchInlineSnapshot(`
"<svg class="chart" viewBox="0 0 330 152" role="img">
<rect x="6" y="49" width="317" height="65" fill="#285338" /><rect x="6" y="16" width="317" height="33" fill="#6b4918" />
<text x="165" y="13" class="chart-value" text-anchor="middle">3</text>
<text x="165" y="144" class="chart-label" text-anchor="middle">20</text>
</svg>"
`)
})
it('valueList', () => {
expect(
valueList({
rows: [
{ label: 'A', value: 2 },
{ label: 'B', value: 1 },
],
format: (row: { value: number }) => String(row.value),
}),
).toMatchInlineSnapshot(`
"<ul class="value-list">
<li>
<span>A</span>
<span class="value-list-bar"><span style="width:100%"></span></span>
<span class="value-list-value">2</span>
</li><li>
<span>B</span>
<span class="value-list-bar"><span style="width:50%"></span></span>
<span class="value-list-value">1</span>
</li>
</ul>"
`)
})
it('escapes untrusted labels', () => {
expect(barChart({ rows: [{ label: '<b>x</b>', count: 1 }] })).toContain('&lt;b&gt;')
})
it('keeps the dossier domain palette as the default', () => {
expect(CHART_COLORS.default).toBe('#315c75')
expect(CHART_COLORS.pago).toBe('#285338')
})
})
describe('proportionalPercent — honest scale, zero stays zero', () => {
it('maps value/max to 0..100 and floors a present value at 2%', () => {
expect(proportionalPercent(84, 84)).toBe(100)
expect(proportionalPercent(42, 84)).toBe(50)
expect(proportionalPercent(1, 84)).toBe(2)
expect(proportionalPercent(0, 84)).toBe(0)
expect(proportionalPercent(5, 0)).toBe(100)
})
})
describe('lineChart — series with one highlighted end', () => {
const rows = [
{ label: '2020', value: 2 },
{ label: '2021', value: 4 },
]
const svg = lineChart({ rows, format: (row: { value: number }) => String(row.value) })
it('draws every label and the last value', () => {
expect(svg).toContain('>2020<')
expect(svg).toContain('>2021<')
expect(svg).toContain('>4<')
})
it('marks the last point with the Solla red', () => {
expect(svg).toContain('fill="#c51414"')
})
})

View file

@ -0,0 +1,69 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { generateObjectMock, deepSeekMock } = vi.hoisted(() => ({
generateObjectMock: vi.fn(),
deepSeekMock: vi.fn(() => 'mock-model'),
}))
vi.mock('ai', () => ({ generateObject: generateObjectMock }))
vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock }))
import { expandSpeechSearchTheme } from '@/utilities/ai/expandSpeechSearchTheme'
beforeEach(() => {
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key'
generateObjectMock.mockReset()
deepSeekMock.mockClear()
})
afterEach(() => {
delete process.env.DEEPSEEK_API_KEY
})
describe('expandSpeechSearchTheme (C192)', () => {
it('returns the sanitized terms the model produced', async () => {
generateObjectMock.mockResolvedValue({
object: { terms: ['SUS%', 'saúde pública', 'sus', 'ab'] },
})
await expect(expandSpeechSearchTheme('defesa do SUS')).resolves.toEqual({
terms: ['SUS', 'saúde pública'],
})
})
it('honra a lista vazia como resposta legítima (não é indisponibilidade)', async () => {
generateObjectMock.mockResolvedValue({ object: { terms: [] } })
await expect(expandSpeechSearchTheme('tema qualquer')).resolves.toEqual({ terms: [] })
})
it('curto-circuita sem chave sem chamar o provedor', async () => {
delete process.env.DEEPSEEK_API_KEY
await expect(expandSpeechSearchTheme('SUS')).resolves.toBeNull()
expect(generateObjectMock).not.toHaveBeenCalled()
})
it('curto-circuita um tema vazio', async () => {
await expect(expandSpeechSearchTheme(' ')).resolves.toBeNull()
expect(generateObjectMock).not.toHaveBeenCalled()
})
it('degrada para null em erro do provedor — nunca lança', async () => {
generateObjectMock.mockRejectedValue(new Error('provider down'))
await expect(expandSpeechSearchTheme('SUS')).resolves.toBeNull()
})
it('envia o tema e um abortSignal limitado ao modelo', async () => {
generateObjectMock.mockResolvedValue({ object: { terms: ['SUS'] } })
await expandSpeechSearchTheme('defesa do SUS')
const call = generateObjectMock.mock.calls[0]![0] as { prompt: string; abortSignal: unknown }
expect(call.prompt).toBe('defesa do SUS')
expect(call.abortSignal).toBeInstanceOf(AbortSignal)
})
})

View file

@ -0,0 +1,54 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// C191: the skill and the command are prompt text, so the product contract
// lives in the SKILL.md prose. This guard fails the build if the flow, the
// guardrails, the gitignored artifacts or the CLI contract are dropped.
const repoRoot = process.cwd()
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
const skill = read('.agents/skills/graficos-dados/SKILL.md')
const command = read('.opencode/commands/graficos-dados.md')
describe('skill /graficos-dados documents the flow', () => {
it('declares when to use it and the canonical builder CLI', () => {
expect(skill).toContain('## Quando usar')
expect(skill).toContain('node scripts/build-chart-from-data.mjs')
expect(skill).toContain('--inspect')
expect(skill).toContain('--spec=')
})
it('pins the launch command and the gitignored artifacts', () => {
expect(skill).toContain('/graficos-dados')
expect(skill).toContain('data/graficos-instagram/')
expect(skill).toContain('docs/research/graficos-instagram/')
expect(skill).toContain('gitignored')
})
it('pins the three output sizes', () => {
expect(skill).toContain('1080×1350')
expect(skill).toContain('1080×1080')
expect(skill).toContain('1080×1920')
})
it('pins the fail-closed guardrails', () => {
expect(skill).toMatch(/partem do zero/i)
expect(skill).toMatch(/7 pontos/)
expect(skill).toMatch(/pizza/i)
expect(skill).toMatch(/pergunta, nunca completa/i)
expect(skill).toContain('--source')
expect(skill).toMatch(/fail-closed/i)
})
})
describe('command /graficos-dados couples to the skill', () => {
it('references the canonical skill file and passes arguments', () => {
expect(existsSync(resolve(repoRoot, '.opencode/commands/graficos-dados.md'))).toBe(true)
expect(command).toContain('`graficos-dados`')
expect(command).toContain('$ARGUMENTS')
expect(command).toContain('.agents/skills/graficos-dados/SKILL.md')
})
})

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { renderChartHtml, SOLLA_PALETTE } from '../../scripts/lib/graficosInstagramRender.mjs'
// C191: the renderer ports the approved hi-fi template
// (docs/plans/graficos-dados-instagram-ui-design.html) class-by-class. These
// pins freeze the export canvas size, the palette, the minimum typography and
// the safe-band adaptation for Stories.
const rows = [
{ label: 'Território A', value: 84 },
{ label: 'Território B', value: 68 },
{ label: 'Território C', value: 53 },
]
const spec = (overrides = {}) => ({
chartType: 'bar',
size: 'feed',
headline: 'Um território concentra o maior resultado',
subtitle: 'Valores de 2026',
source: 'TSE 2026',
highlight: 'Território B',
rows,
...overrides,
})
describe('renderChartHtml — canvas and template', () => {
it('authors the feed canvas at exactly 1080×1350', () => {
const html = renderChartHtml(spec())
expect(html).toContain('width: 1080px; height: 1350px')
})
it('adapts to square and to Stories safe bands', () => {
expect(renderChartHtml(spec({ size: 'square' }))).toContain('width: 1080px; height: 1080px')
const story = renderChartHtml(spec({ size: 'story' }))
expect(story).toContain('width: 1080px; height: 1920px')
expect(story).toContain('padding: 306px 84px 310px')
})
it('keeps the headline at/above the minimum size', () => {
expect(renderChartHtml(spec())).toMatch(/\.headline \{ font-size: 67px/)
expect(renderChartHtml(spec({ size: 'story' }))).toMatch(/\.headline \{ font-size: 78px/)
})
it('carries the source and the provisional brand lockup inside the image', () => {
const html = renderChartHtml(spec())
expect(html).toContain('TSE 2026')
expect(html).toContain('JORGE SOLLA')
expect(html).toContain('MANDATO DEPUTADO FEDERAL')
})
it('marks exactly one row with the Solla highlight', () => {
const html = renderChartHtml(spec())
expect((html.match(/bar highlight/g) ?? []).length).toBe(1)
expect(html).toContain('Território B')
expect(html).toContain('#c51414')
})
it('sorts the ranking by value, descending', () => {
const html = renderChartHtml(spec())
expect(html.indexOf('Território A')).toBeLessThan(html.indexOf('Território B'))
expect(html.indexOf('Território B')).toBeLessThan(html.indexOf('Território C'))
})
it('renders a column chart with a zero base line', () => {
const html = renderChartHtml(spec({ chartType: 'column' }))
expect(html).toContain('columns')
expect(html).toContain('column highlight')
})
it('renders the line chart with the last point highlighted', () => {
const html = renderChartHtml(spec({ chartType: 'line' }))
expect(html).toContain('<polyline')
expect(html).toContain('#c51414')
})
it('renders the anchor with the value and the copy', () => {
const html = renderChartHtml(
spec({
chartType: 'anchor',
rows: [{ label: '', value: 72 }],
subtitle: 'unidades no período',
}),
)
expect(html).toContain('72')
expect(html).toContain('unidades no período')
expect(html).toContain('anchor-number')
})
it('escapes untrusted headline and source', () => {
const html = renderChartHtml(spec({ headline: '<script>x</script>', source: 'a & b' }))
expect(html).not.toContain('<script>x</script>')
expect(html).toContain('&lt;script&gt;')
expect(html).toContain('a &amp; b')
})
})
describe('SOLLA_PALETTE — the verbatim brand colors', () => {
it('matches the intention literals', () => {
expect(SOLLA_PALETTE).toMatchObject({
highlight: '#c51414',
brandDark: '#ae1603',
ptRed: '#a21c1c',
ptYellow: '#ffe607',
paper: '#faf9f7',
})
})
})

View file

@ -19,6 +19,7 @@ const commands = [
'dossie-solla-cidade',
'dossie-solla-instituicao',
'dossie-solla-tema',
'graficos-dados',
] as const
describe('opencode commands couple to their skill by exact name', () => {

View file

@ -0,0 +1,136 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
canServeReelMedia,
isReelMediaKind,
isReelStatus,
REEL_FEATURES,
REEL_MEDIA_KINDS,
REEL_STATUSES,
reelFeatureLabels,
reelMediaFieldByKind,
reelMediaPath,
reelStatusLabels,
} from '@/lib/reel'
import {
REEL_MEDIA_FALLBACK_MIME_TYPE,
reelMediaContentDisposition,
reelMediaContentType,
reelMediaHeaders,
type ReelMediaRange,
} from '@/lib/reelMedia'
const fullRange: ReelMediaRange = {
status: 200,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '100' },
}
const partialRange: ReelMediaRange = {
status: 206,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '10', 'Content-Range': 'bytes 0-9/100' },
}
const invalidRange: ReelMediaRange = {
status: 416,
headers: { 'Content-Range': 'bytes */100' },
}
describe('reel vocabulary (C193)', () => {
it('pins the status kill switch and its labels', () => {
expect(REEL_STATUSES).toEqual(['draft', 'published', 'unpublished'])
expect(Object.keys(reelStatusLabels).sort()).toEqual([...REEL_STATUSES].sort())
expect(isReelStatus('published')).toBe(true)
expect(isReelStatus('processing')).toBe(false)
})
it('pins the closed feature catalog starting at cards', () => {
expect(REEL_FEATURES).toEqual(['cards'])
expect(Object.keys(reelFeatureLabels).sort()).toEqual([...REEL_FEATURES].sort())
})
it('maps every artifact kind to exactly one upload field', () => {
expect(REEL_MEDIA_KINDS).toEqual(['video', 'video-audio', 'narration', 'captions', 'cover'])
expect(Object.keys(reelMediaFieldByKind).sort()).toEqual([...REEL_MEDIA_KINDS].sort())
expect(new Set(Object.values(reelMediaFieldByKind)).size).toBe(REEL_MEDIA_KINDS.length)
expect(reelMediaFieldByKind.video).toBe('video')
expect(isReelMediaKind('captions')).toBe(true)
expect(isReelMediaKind('poster')).toBe(false)
})
it('serves only a published reel and builds the cookie-scoped path', () => {
expect(canServeReelMedia('published')).toBe(true)
expect(canServeReelMedia('draft')).toBe(false)
expect(canServeReelMedia('unpublished')).toBe(false)
expect(reelMediaPath(42, 'video')).toBe('/campanha/comunicacao/reels/42/media/video')
})
})
describe('reel media response rules (C193)', () => {
it('serves only allowlisted types inline; everything else degrades', () => {
expect(reelMediaContentType('video/mp4')).toBe('video/mp4')
expect(reelMediaContentType('audio/mpeg')).toBe('audio/mpeg')
expect(reelMediaContentType('image/jpeg')).toBe('image/jpeg')
expect(reelMediaContentType('text/html')).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
expect(reelMediaContentType(null)).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
})
it('builds both filename forms and encodes non-ASCII', () => {
expect(reelMediaContentDisposition('reel.mp4', false)).toBe(
'inline; filename="reel.mp4"; filename*=UTF-8\'\'reel.mp4',
)
expect(reelMediaContentDisposition('capa ção.png', true)).toBe(
'attachment; filename="capa_o.png"; filename*=UTF-8\'\'capa%20%C3%A7%C3%A3o.png',
)
expect(reelMediaContentDisposition(null, false)).toContain('filename="arquivo"')
})
it('keeps the range headers and adds the private streaming contract', () => {
const headers = reelMediaHeaders({
range: partialRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes 0-9/100')
expect(headers.get('Content-Length')).toBe('10')
expect(headers.get('Accept-Ranges')).toBe('bytes')
expect(headers.get('Content-Type')).toBe('video/mp4')
expect(headers.get('Content-Disposition')).toContain('inline')
expect(headers.get('Cache-Control')).toBe('private, no-store')
expect(headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(headers.get('Content-Security-Policy')).toBeNull()
})
it('forces a download for unsafe types and for ?download=1', () => {
const unsafe = reelMediaHeaders({
range: fullRange,
mimeType: 'text/html',
filename: 'roteiro.html',
download: false,
})
expect(unsafe.get('Content-Type')).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
expect(unsafe.get('Content-Disposition')).toContain('attachment')
expect(unsafe.get('Content-Security-Policy')).toBe("default-src 'none'")
const explicit = reelMediaHeaders({
range: fullRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: true,
})
expect(explicit.get('Content-Disposition')).toContain('attachment')
})
it('passes the 416 headers through untouched', () => {
const headers = reelMediaHeaders({
range: invalidRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes */100')
expect(headers.get('Content-Type')).toBe('video/mp4')
})
})

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import {
buildHighlightedExcerpt,
findHighlightRanges,
findPhraseRanges,
matchesSearchTerms,
splitHighlightedParts,
} from '@/lib/speechHighlight'
@ -42,6 +43,32 @@ describe('findHighlightRanges', () => {
})
})
describe('findPhraseRanges (C192)', () => {
it('highlights the whole phrase as one continuous range, keeping the accents', () => {
const text = 'Defendemos o atendimento público e acesso universal à saúde para todos'
const ranges = findPhraseRanges(text, 'acesso universal à saúde')
expect(ranges).toHaveLength(1)
expect(text.slice(ranges[0]!.start, ranges[0]!.end)).toBe('acesso universal à saúde')
})
it('finds overlapping occurrences of the same phrase', () => {
expect(findPhraseRanges('ana ana', 'ana')).toEqual([
{ start: 0, end: 3 },
{ start: 4, end: 7 },
])
})
it('falls back to the per-term ranges when the phrase is not contiguous', () => {
const ranges = findPhraseRanges('saúde pública e muito acesso', 'acesso universal')
expect(ranges).toEqual([{ start: 22, end: 28 }])
})
it('returns nothing for an empty phrase', () => {
expect(findPhraseRanges('texto', '')).toEqual([])
})
})
describe('matchesSearchTerms', () => {
it('requires every term, in any order', () => {
expect(matchesSearchTerms('A defesa do SUS e da saúde', 'saude sus')).toBe(true)

View file

@ -122,3 +122,61 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
expect(buildSpeechListWhereIncludingCutOrigins(state, [])).toEqual(buildSpeechListWhere(state))
})
})
describe('buildSpeechListWhere theme expansion (C192)', () => {
it('ORs the query with every expanded theme term', () => {
const where = buildSpeechListWhere(parseSpeechListParams({ q: 'defesa do SUS' }), [
'saúde pública',
'SUS',
])
expect(where).toEqual({
and: [
{
or: [
{ searchText: { like: 'defesa do sus' } },
{ keywords: { contains: 'defesa do SUS' } },
{ searchText: { like: 'saude publica' } },
{ keywords: { contains: 'saúde pública' } },
{ searchText: { like: 'sus' } },
{ keywords: { contains: 'SUS' } },
],
},
],
})
})
it('dedupes a term that repeats the query', () => {
expect(buildSpeechListWhere(parseSpeechListParams({ q: 'SUS' }), ['SUS'])).toEqual({
and: [{ or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
})
})
it('keeps the facets AND-ed and the cut origins OR-ed alongside the expansion', () => {
expect(
buildSpeechListWhereIncludingCutOrigins(
parseSpeechListParams({ q: 'reforma', topic: ['saude'] }),
[7],
['saúde'],
),
).toEqual({
and: [
{ topics: { in: ['saude'] } },
{
or: [
{ searchText: { like: 'reforma' } },
{ keywords: { contains: 'reforma' } },
{ searchText: { like: 'saude' } },
{ keywords: { contains: 'saúde' } },
{ id: { in: [7] } },
],
},
],
})
})
it('stays byte-identical to today without theme terms', () => {
const state = parseSpeechListParams({ q: 'reforma' })
expect(buildSpeechListWhere(state, [])).toEqual(buildSpeechListWhere(state))
})
})

View file

@ -67,6 +67,12 @@ describe('parseSpeechListParams', () => {
expect(state.topics).toBeUndefined()
expect(state.durations).toBeUndefined()
})
it('parses the theme mode and ignores any other value (C192)', () => {
expect(parseSpeechListParams({ q: 'SUS', mode: 'tema' }).mode).toBe('tema')
expect(parseSpeechListParams({ q: 'SUS', mode: 'termo' }).mode).toBeUndefined()
expect(parseSpeechListParams({ q: 'SUS', mode: 'exato' }).mode).toBeUndefined()
})
})
describe('serializeCanonicalSpeechListSearchParams', () => {
@ -84,6 +90,18 @@ describe('serializeCanonicalSpeechListSearchParams', () => {
expect(params.getAll('topic')).toEqual(['saude', 'educacao'])
expect(params.getAll('duration')).toEqual(['curta'])
})
it('serializes mode=tema only alongside a query (C192)', () => {
expect(
serializeCanonicalSpeechListSearchParams(
parseSpeechListParams({ q: 'SUS', mode: 'tema' }),
).toString(),
).toBe('q=SUS&mode=tema')
// A theme without a query canonicalizes away.
expect(
serializeCanonicalSpeechListSearchParams(parseSpeechListParams({ mode: 'tema' })).toString(),
).toBe('')
})
})
describe('buildSpeechListHref', () => {
@ -93,6 +111,15 @@ describe('buildSpeechListHref', () => {
)
expect(buildSpeechListHref(parseSpeechListParams({}), 1)).toBe('/campanha/comunicacao/acervo')
})
it('keeps the exact-search deep link byte-identical (C192)', () => {
expect(buildSpeechListHref(parseSpeechListParams({ q: 'SUS' }), 1)).toBe(
'/campanha/comunicacao/acervo?q=SUS',
)
expect(buildSpeechListHref(parseSpeechListParams({ q: 'SUS', mode: 'tema' }), 1)).toBe(
'/campanha/comunicacao/acervo?q=SUS&mode=tema',
)
})
})
describe('resolveSpeechListUrl', () => {
@ -107,6 +134,15 @@ describe('resolveSpeechListUrl', () => {
expect(resolved.state.page).toBe(2)
expect(resolved.redirectHref).toContain('page=2')
})
it('canonicalizes a theme mode without a query into a redirect (C192)', () => {
const withQuery = resolveSpeechListUrl({ q: 'SUS', mode: 'tema' })
expect(withQuery.state.mode).toBe('tema')
expect(withQuery.redirectHref).toBeUndefined()
const empty = resolveSpeechListUrl({ mode: 'tema' })
expect(empty.redirectHref).toBe('/campanha/comunicacao/acervo')
})
})
describe('buildSpeechFiltersKey', () => {

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { parseSpeechListParams, type SpeechListState } from '@/utilities/speech/speechListUrl'
import {
applySpeechOmniboxSuggestion,
applySpeechSearchMode,
buildSpeechOmniboxChips,
clearSpeechOmnibox,
removeSpeechOmniboxChip,
@ -96,9 +97,34 @@ describe('removeSpeechOmniboxChip', () => {
expect(emptied.state.topics).toBeUndefined()
})
it('removes the search chip', () => {
const action = removeSpeechOmniboxChip({ state: state({ q: 'SUS' }), chipId: 'q' })
it('removes the search chip and the theme mode with it (C192)', () => {
const action = removeSpeechOmniboxChip({
state: state({ q: 'SUS', mode: 'tema' }),
chipId: 'q',
})
expect(action.state.q).toBeUndefined()
expect(action.state.mode).toBeUndefined()
})
})
describe('applySpeechSearchMode (C192)', () => {
it('sets tema only with a query and resets the page', () => {
expect(applySpeechSearchMode({ state: state({ q: 'SUS', page: '4' }), mode: 'tema' })).toEqual({
kind: 'url',
state: { page: 1, q: 'SUS', mode: 'tema' },
})
expect(applySpeechSearchMode({ state: state({ page: '4' }), mode: 'tema' }).state.mode).toBe(
undefined,
)
})
it('clears the mode back to the exact search', () => {
const action = applySpeechSearchMode({
state: state({ q: 'SUS', mode: 'tema' }),
mode: 'termo',
})
expect(action.state.mode).toBeUndefined()
})
})

View file

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import {
normalizeForSearch,
speechMatchesSearchQuery,
speechMatchesSearchTerm,
uniqueByNormalizedForm,
} from '@/lib/speechSearch'
@ -47,3 +48,18 @@ describe('speechMatchesSearchQuery', () => {
expect(speechMatchesSearchQuery(speech, ' ')).toBe(true)
})
})
describe('speechMatchesSearchTerm (C192)', () => {
it('mirrors a single textual where branch', () => {
const speech = { searchText: 'a saude publica baiana', keywords: ['Farmácia Popular'] }
expect(speechMatchesSearchTerm(speech, 'SAÚDE Pública')).toBe(true)
expect(speechMatchesSearchTerm(speech, 'FARMÁCIA')).toBe(true)
expect(speechMatchesSearchTerm(speech, 'educação')).toBe(false)
})
it('never matches an empty term', () => {
const speech = { searchText: 'a saude publica', keywords: ['SUS'] }
expect(speechMatchesSearchTerm(speech, undefined)).toBe(false)
expect(speechMatchesSearchTerm(speech, ' ')).toBe(false)
})
})

View file

@ -0,0 +1,32 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { MAX_SPEECH_THEME_TERMS, normalizeSpeechThemeTerms } from '@/lib/speechThemeTerms'
describe('normalizeSpeechThemeTerms (C192)', () => {
it('removes LIKE wildcards so the expansion cannot widen the pattern', () => {
expect(normalizeSpeechThemeTerms(['saúde%', 'a_b', 'c\\d'])).toEqual(['saúde', 'a b', 'c d'])
})
it('drops empty and too-short terms', () => {
expect(normalizeSpeechThemeTerms(['', ' ', 'ab', 'SUS', 'de'])).toEqual(['SUS'])
})
it('dedupes by normalized form keeping the first spelling', () => {
expect(normalizeSpeechThemeTerms(['Saúde Pública', 'saude publica', 'SUS'])).toEqual([
'Saúde Pública',
'SUS',
])
})
it('caps the list length', () => {
const values = Array.from({ length: 20 }, (_, index) => `termo ${index}`)
expect(normalizeSpeechThemeTerms(values)).toHaveLength(MAX_SPEECH_THEME_TERMS)
})
it('truncates a single term to the max length', () => {
const term = 'x'.repeat(90)
expect(normalizeSpeechThemeTerms([term])[0]).toHaveLength(60)
})
})

View file

@ -64,3 +64,115 @@ describe('toSpeechListItemViewModel thumbnail (C182 — frame of the speech)', (
expect(viewModel({ durationSeconds: 252, eventId: 67091 }).thumbnailUrl).toBeNull()
})
})
describe('toSpeechListItemViewModel theme match (C192)', () => {
const segment = (text: string) => ({ startSeconds: 0, endSeconds: 3, text })
const themeRow = ({
text,
query,
themeTerms,
keywords = [],
searchText,
}: {
text: string
query?: string
themeTerms: string[]
keywords?: string[]
searchText?: string
}) =>
toSpeechListItemViewModel({
speech: { id: 1, speechAt: '2026-08-11T18:48', keywords, searchText },
segments: [segment(text)],
query,
municipalityLabels: new Map(),
themeTerms,
})
it('surfaces the theme passage with the expanded term highlighted', () => {
const row = themeRow({
text: 'Defendemos o atendimento público e acesso universal à saúde',
searchText: 'defendemos o atendimento publico e acesso universal a saude',
query: 'defesa do SUS',
themeTerms: ['acesso universal'],
})
expect(row.matchKind).toBe('theme')
expect(row.themeMatchTerm).toBe('acesso universal')
expect(row.matchedTextSearch).toBe(false)
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('keeps the theme term when the speech also matches the literal query', () => {
const row = themeRow({
text: 'A defesa do SUS e da saúde pública baiana',
searchText: 'a defesa do sus e da saude publica baiana',
query: 'SUS',
themeTerms: ['saúde pública'],
})
expect(row.matchKind).toBe('segment')
expect(row.themeMatchTerm).toBe('saúde pública')
expect(row.matchedTextSearch).toBe(true)
})
it('matches a keyword as theme evidence and uses it as the excerpt', () => {
const row = themeRow({
text: 'Uma fala sobre outro assunto',
query: 'defesa do SUS',
themeTerms: ['Farmácia Popular'],
keywords: ['Farmácia Popular'],
})
expect(row.matchKind).toBe('theme')
expect(row.themeMatchTerm).toBe('Farmácia Popular')
const excerptText = row.excerpt.parts.map((part) => part.text).join('')
expect(excerptText).toContain('Farmácia Popular')
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
it('does not claim a theme the where never used (word-only coincidence)', () => {
const row = themeRow({
text: 'O atendimento público é prioridade',
searchText: 'o atendimento publico e prioridade',
query: 'defesa do SUS',
themeTerms: ['atendimento universal'],
})
expect(row.themeMatchTerm).toBeNull()
expect(row.matchKind).toBe('fallback')
})
it('stays literal without theme terms or when none matches', () => {
expect(
themeRow({ text: 'A defesa do SUS', query: 'SUS', themeTerms: [] }).themeMatchTerm,
).toBeNull()
expect(
themeRow({ text: 'A defesa do SUS', query: 'SUS', themeTerms: ['educação'] }).themeMatchTerm,
).toBeNull()
})
it('never claims a theme term the where mirror did not match (C192)', () => {
// "saúde pública" never appears contiguously in the search text, so the
// mirror rejects it and only the term that really matched is claimed.
expect(
themeRow({
text: 'Saúde para todos e a rede pública de atendimento',
searchText: 'saude para todos e a rede publica de atendimento',
query: 'defesa do SUS',
themeTerms: ['saúde pública', 'atendimento'],
}).themeMatchTerm,
).toBe('atendimento')
})
it('falls back to keywords as theme evidence when the text does not match', () => {
const row = themeRow({
text: 'Uma fala sobre outro assunto',
query: 'defesa do SUS',
themeTerms: ['Farmácia Popular'],
keywords: ['Farmácia Popular'],
})
expect(row.themeMatchTerm).toBe('Farmácia Popular')
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
})
})