feat(C209): dossiês e boletins Solla sóbrios e analíticos — O essencial, defesa e consulta rápida (Closes #1251)

This commit is contained in:
Francisco Solla 2026-09-22 21:56:35 -03:00
parent f63e5cb9b4
commit 3a1878691b
30 changed files with 2952 additions and 2937 deletions

View file

@ -192,7 +192,7 @@ consolidação determinística.
{
"id": "era_c_emendas", // id do checklist da era
"answer": "registro integral; aceita {{fonte}} / {{fonte:N}} — vai para o .md e para o lastro",
"summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber no resumo/boletim (C188)",
"position": "opcional — só em item `*_defesas`: rótulo curto da posição (ex. \"Ensino superior\")",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"brief": { // obrigatório no item publicado: copy reformulada, curta
"title": "≤80 chars — manchete (o quê + onde)",
@ -218,10 +218,18 @@ consolidação determinística.
Checklist por era (ids):
- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
`era_a_sas_ms`.
- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`.
`era_a_sas_ms`, `era_a_defesas`.
- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`,
`era_b_defesas`.
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`,
`era_c_titulos`, `era_c_atuacao`.
`era_c_titulos`, `era_c_atuacao`, `era_c_defesas`.
Os itens `era_X_defesas` são a dimensão **"O que Solla defende"** (`kind:
defense`): `answer` = frase da posição, `details` = lastro identificado
(ato/proposição, "Notícia: veículo · data" ou trecho de fala) e
`sourceUrl`/`sourceDate` da fonte do lastro — **sem registro datado vira lacuna**,
nunca posição inferida. Os demais itens (`kind: evidence`) alimentam as eras, a
região e a síntese; itens de defesa **não** entram nessas listas.
Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira
**lacuna** (`Não pesquisado.` / `Sem fonte: …`); `sphere` inválida vira lacuna
@ -229,20 +237,6 @@ Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira
(default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado
por item **com fonte** — o boletim não introduz fato novo.
**`summary` (C188):** o resumo de uma olhada e o boletim **nunca cortam texto no
meio**. Essas superfícies imprimem o `summary` do item quando existe; sem ele,
imprimem o `answer` integral e, se a página não couber, o builder re-renderiza
apontando o aprofundamento (as páginas das eras, que sempre guardam
`answer`/`details` integrais) — **sem "…"**. Escreva `summary` curto e
auto-contido para os itens que viram entrega/gancho no resumo; é a única forma de
o texto que você redigiu aparecer no resumo. Listas capadas (entregas, ganchos,
números, itens regionais, fatos do boletim) sempre exibem o contador `e mais N` /
`Mostrando X de Y` — nenhum item some em silêncio. `summary` e `brief` coexistem:
o `brief` é a copy reformulada que o PDF imprime nas páginas de era/cartões; o
`summary` alimenta as superfícies de resumo do dossiê e o ledger do boletim.
**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
**`<slug>.narrative.json` (opcional; escrito pelo redator, auditado pelo
orquestrador):
@ -252,75 +246,78 @@ orquestrador):
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
"title": "O que Jorge Solla fez por Ilhéus",
"opening": ["parágrafo", "parágrafo", "parágrafo"],
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" },
"betweenEras": ["bullet", "bullet"] // opcional — leitura entre eras
}
```
Sem o arquivo (ou com `municipalitySlug` diferente do snapshot), o builder **não
falha**: a abertura repete a leitura dos números e cada era usa a consolidação
determinística. `opening` e `eras` só podem conter fatos dos itens com fonte —
nenhum número, data, nome ou órgão novo.
falha**: a abertura repete a leitura dos números, cada era usa a consolidação
determinística e a leitura entre eras é derivada dos registros. `opening`,
`eras` e `betweenEras` só podem conter fatos dos itens com fonte — nenhum número,
data, nome ou órgão novo.
`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
em vez de cortar o texto com `…`, cada item publicado traz um `brief` **reescrito
para caber** (`title` ≤80 / `note` ≤120) preservando o essencial (o quê, onde,
valor + fase) — nunca inventa fato, nunca sugere exclusividade municipal para
item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). O cartão/linha
do PDF imprime `brief.title`/`brief.note`; o `answer`/`details` integrais ficam
no registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e
a guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas
item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). As folhas de era
imprimem `brief.title`/`brief.note`; o `answer`/`details` integrais ficam no
registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e a
guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas
longas (lacunas, notícias) não usam `brief`: são **paginadas** em folhas de
continuação com o texto inteiro.
## Conteúdo do dossiê
- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo adotado,
data, "como usar", escopo/versão.
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela
cidade e pela região ao longo das eras, em prosa, com a nota "Como ler" (cada
afirmação tem lastro em item datado; a redação não preenche por inferência).
- **Trajetória completa** — a linha do tempo da carreira.
- **Resumo de uma olhada** — identificação + entregas localizadas (badge de
esfera + valor + badge de fase + fonte); ganchos; o que falta. Guarda:
**autorizado ≠ empenhado ≠ liquidado ≠ pago**. É a única página com listas
capadas — e o "e mais N" aponta para a seção onde a lista completa está.
- **Síntese** — leitura dos números com lastro (pontos por era e esfera,
concentração relativa, itens com valor por fase, recursos por fase, temas,
lacunas) + guardas de leitura; nada que não venha dos itens.
- **Gráficos consolidados** — recursos **com execução por ano** (empilhado por
fase), **propostas/articulações sem fase informada** (lista com barra e valor),
abrangência (município × região × polo), trajetória por ano, áreas e lacunas
por era, painel do acervo. Valores sempre em R$ com a fase; recortes nunca
somados.
- **Seção por era (A/B/C)** — parágrafo de consolidação ("O que esta era
entrega", do `narrative.json` ou determinístico) + recorte e método + trilha de
recuperação; tabela **Objeto/Valor/Ano/Fase/Esfera/Fonte**; cards "O que fez —
item, alcance e lastro"; títulos/honrarias locais. **Sem cap**: a era flui por
quantas folhas precisar (continuações com cabeçalho "continuação N"). Uma era
sem evidência é omitida (nunca inventada).
- **Região / polo** — painel "Região não é cidade. Não some os dois recortes."
com contagem por recorte (**nenhum total combinado**) e prévia; em seguida
**as duas listas completas** em tabela (item/esfera/evidência/fonte), cada uma
fluindo por quantas folhas precisar; gancho e lacuna prioritária.
data, "como ler", escopo/versão.
- **O essencial** — abre o documento: leitura do recorte (a redação de abertura),
fatos-chave (pontos com fonte por era, abrangência sem soma, valores por fase,
lacunas) e o **índice** com o número de página de cada seção. É a leitura antes
do inventário.
- **Leitura entre eras** — bullets de concentração, instrumentos, continuidade,
alcance e lacunas que pesam (`betweenEras` do `narrative.json` ou derivação
determinística) + tabela **Era / Onde está a evidência / Como citar com
segurança / Limite**.
- **Trajetória completa** — a linha do tempo da carreira em tabela
(período/papel/como recuperar), incertezas na própria linha.
- **Seção por era (A/B/C)** — leitura da era ("Leitura da era", do
`narrative.json` ou determinística) + tabela **Objeto/Valor/Ano/Fase/Esfera/
Fonte** + lista **"O que fez · item — alcance — lastro"**. Fase e esfera são
texto, nunca selo colorido. **Sem cap**: a era flui por quantas folhas precisar
(continuações com cabeçalho "continuação N"). Uma era sem evidência é omitida
como seção e aparece no índice e na leitura entre eras com a nota "sem
evidência nominal suficiente; consulte as lacunas" — nunca inventada.
- **Região / polo** — callout "Não somar." + tabela de leitura lado a lado
(**nenhum total combinado**) e, em seguida, **as duas listas completas** em
tabela (item/esfera/evidência/fonte), cada uma fluindo por quantas folhas
precisar.
- **O que Solla defende** — seção própria e indexável: leitura dos registros
(não é opinião), lista de posições (`position-list`: rótulo da posição, leitura
com lastro, fonte + data + era) e, para cada item `era_X_defesas` sem registro,
a linha "Sem registro localizado" com a lacuna explícita. Só entra posição com
ato, notícia datada ou fala identificada como lastro.
- **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo
interno** — tabelas/listas completas, também correntes (sem cap). O acervo é
**amostra declarada** (as falas mais recentes com link, de N do recorte
municipal) com painel do total; região/polo não entram nessa conta.
interno** — tabelas/listas completas, também correntes (sem cap). As notícias
ganham a coluna "Uso no dossiê" (era) e o resumo datado; o acervo é **amostra
declarada** (as falas mais recentes com link, de N do recorte municipal);
região/polo não entram nessa conta.
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
defeso eleitoral 2026.
- **Nada some e nada é cortado:** o dossiê **não usa caps** — o builder mede a
altura real de cada linha e pagina (grow/shrink) até a página ficar cheia sem
estourar; listas continuam em folhas de continuação com o texto inteiro. O
único "e mais N" é o do resumo e o da amostra do acervo, ambos apontando para
onde o resto está.
único "e mais N" é o da amostra do acervo.
## Conteúdo do Boletim modelo (1 página A4)
- Cabeçalho com município + rótulo **"Modelo — insumo interno"**; abertura
"O que Jorge Solla fez por <cidade>"; **≤6 destaques** (eyebrow "Área ·
cidade/região", número em destaque, título curto, nota); timeline de 4 passos
da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com defeso.
- Cabeçalho com município + rótulo **"Modelo — insumo interno"**; título
"O que Jorge Solla fez por <cidade>"; lede curta; **≤6 destaques** em lista
(rótulo + texto, com o valor e a fase quando houver); **"O que Solla defende"**
com **≤2 defesas curtas** com lastro do ledger (bloco omitido quando não há
registro); trajetória em quatro períodos; **"E mais" com ≤14 itens** em lista;
rodapé com defeso.
- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
CTA de campanha; herda **apenas** fatos com fonte do dossiê.
@ -352,7 +349,7 @@ continuação com o texto inteiro.
## Guardrails de produto (não negociáveis)
- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna.
- **Redação e parágrafos com lastro**: a carta e o parágrafo de cada era só usam
- **Redação e parágrafos com lastro**: a leitura de abertura e o parágrafo de cada era só usam
fatos dos itens com fonte; o orquestrador audita citação por citação (nome,
data, valor, órgão) e remove o que não tiver lastro.
- **Dossiê sem caps**: o conteúdo flui por quantas páginas precisar; a página é
@ -379,8 +376,8 @@ continuação com o texto inteiro.
- **Folha do dossiê estourou**: o builder mede a altura real de cada linha e
repagina (grow/shrink) até estabilizar; se uma folha continuar estourando, é
porque **uma linha sozinha** não cabe — aperte a copy daquele item, nunca o
layout. Página **não-packed** (carta, resumo, síntese, gráficos, fontes)
estourando = corte copy ou reposicione o card.
layout. Página **não-packed** (O essencial, leitura entre eras, trajetória,
defende, fontes) estourando = corte copy ou enxugue o índice.
- **Página pela metade**: o pack estabilizou? Confira o log (`pack estável`); se
uma seção ficou rala, o problema é o custo medido (bloco novo) — não force cap,
ajuste a copy do item que abre a seção.
@ -406,10 +403,10 @@ continuação com o texto inteiro.
- Intenção: `docs/plans/dossie-solla-cidade.md`; impl:
`docs/plans/dossie-solla-cidade-impl.md`.
- Designs hi-fi (fonte de verdade do port): `docs/plans/dossie-solla-cidade-ui-design.html`
e `docs/plans/dossie-solla-cidade-boletim-ui-design.html` — **desatualizados
desde a revisão de 2026-09-18** (carta, síntese, gráficos e folhas correntes
ainda não estão neles); um passe do `designer` precisa re-sincronizá-los.
- Designs hi-fi (fonte de verdade do port): `docs/plans/dossies-sobrios-analiticos-ui-design.html`
(C209 — cenas do dossiê e do boletim, um artefato da família). Os hi-fi antigos
(`dossie-solla-cidade-ui-design.html` e `-boletim-ui-design.html`) ficam no repo
como registro e estão **superados**.
- Precedente: `.agents/skills/relatorio-cidade/SKILL.md`,
`.opencode/agent/relatorio-cidade.md`, `scripts/build-city-report.mjs`.
- Scripts: `scripts/build-dossie-solla-cidade.mjs`,

View file

@ -19,9 +19,9 @@ vem dos artefatos hi-fi aprovados pelo `designer`.
O dossiê institucional **não usa caps**: cada seção (eras, abrangência, títulos,
lacunas, notícias, acervo) flui por quantas páginas precisar, com quebra só entre
linhas — o orquestrador não decide o corte, o builder mede e pagina. Sobre essa
base vêm a **redação de abertura** (carta), a **síntese** dos números e os
**gráficos consolidados** (recursos com fase, abrangência, trajetória), e cada
era abre com um **parágrafo de consolidação** do que ela entrega.
base vêm **O essencial** (redação de abertura + fatos-chave + índice), a
**leitura entre eras**, a abrangência e o **O que Solla defende**, e cada era
abre com a **leitura da era**.
## Quando usar
@ -199,6 +199,7 @@ consolidação determinística.
{
"id": "era_c_emendas", // id do checklist da era
"answer": "R$ 5,0 milhões para estrutura de ensino e pesquisa",
"position": "opcional — só em item `*_defesas`: rótulo curto da posição (ex. \"Financiamento\")",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"sphere": "instituicao", // instituicao | setor | rede (default instituicao)
"numbers": [ // opcional
@ -220,11 +221,18 @@ consolidação determinística.
Checklist por era (ids):
- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
`era_a_sas_ms`, `era_a_vinculo`.
`era_a_sas_ms`, `era_a_vinculo`, `era_a_defesas`.
- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`,
`era_b_convenios`.
`era_b_convenios`, `era_b_defesas`.
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`,
`era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`.
`era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`, `era_c_defesas`.
Os itens `era_X_defesas` são a dimensão **"O que Solla defende"** (`kind:
defense`): `answer` = frase da posição, `details` = lastro identificado
(ato/proposição, "Notícia: veículo · data" ou trecho de fala) e
`sourceUrl`/`sourceDate` da fonte do lastro — **sem registro datado vira lacuna**,
nunca posição inferida. Os demais itens (`kind: evidence`) alimentam as eras, a
abrangência e a síntese; itens de defesa **não** entram nessas listas.
A **abrangência** (`sphere`) é `instituicao` | `setor` | `rede`; `setor` e `rede`
não são a instituição e **nunca são somados** a ela (cada linha informa sua
@ -243,67 +251,71 @@ orquestrador):
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
"title": "O que Jorge Solla fez pela e na UFBA",
"opening": ["parágrafo", "parágrafo", "parágrafo"],
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" },
"betweenEras": ["bullet", "bullet"] // opcional — leitura entre eras
}
```
Sem o arquivo (ou com `institutionSlug` diferente do snapshot), o builder **não
falha**: a abertura repete a leitura dos números e cada era usa a consolidação
determinística. `opening` e `eras` só podem conter fatos dos itens com fonte —
nenhum número, data, nome ou órgão novo.
falha**: a abertura repete a leitura dos números, cada era usa a consolidação
determinística e a leitura entre eras é derivada dos registros. `opening`,
`eras` e `betweenEras` só podem conter fatos dos itens com fonte — nenhum número,
data, nome ou órgão novo.
## Conteúdo do dossiê
- **Capa** — série institucional, "INSUMO INTERNO — defeso 2026", identificação
(tipo/esfera/alcance em badges), data, "como usar", escopo/versão.
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela e
na instituição ao longo das eras, em prosa, com a nota "Como ler" (cada
afirmação tem lastro em item datado; a redação não preenche por inferência).
- **Resumo de uma olhada** — identificação + linha do tempo documentada do
vínculo; principais entregas localizadas (badge de abrangência + valor + badge
de fase + fonte); gancho para a agenda; o que falta. Guarda: **autorizado ≠
empenhado ≠ liquidado ≠ pago**. É a única página com listas capadas — e o
"e mais N" aponta para a seção onde a lista completa está.
- **Síntese** — leitura dos números com lastro (pontos por era e abrangência,
concentração relativa, itens com valor por fase, recursos por fase, temas,
lacunas) + guardas de leitura; nada que não venha dos itens.
- **Gráficos consolidados** — recursos **com execução por ano** (empilhado por
fase), **propostas/articulações sem fase informada** (lista com barra e valor),
abrangência, trajetória por ano, áreas e lacunas por era, painel do acervo.
Valores sempre em R$ com a fase; recortes nunca somados.
- **Seção por era (A/B/C)** — parágrafo de consolidação ("O que esta era
entrega", do `narrative.json` ou determinístico) + recorte e método + trilha de
recuperação; tabela **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis
com evidência". **Sem cap**: a era flui por quantas folhas precisar
(continuações com cabeçalho "continuação N"). Uma era **sem evidência** vira
**página de lacuna explícita** (nunca seção em branco nem "zero").
- **Abrangência: instituição × setor × rede** — painel "Setor/rede não é a
instituição. Não some os recortes." com contagem por recorte (**nenhum total
combinado**) e prévia; em seguida **as três listas completas** em tabela
(item/abrangência/evidência/fonte), cada uma fluindo por quantas folhas
precisar; gancho e lacuna prioritária.
- **Títulos, honrarias e vínculos** — tabela reconhecimento/natureza/fonte.
(nome/tipo/esfera/alcance em tabela), data, "como usar", escopo/versão.
- **O essencial** — abre o documento: leitura do recorte (a redação de abertura),
identificação da instituição, fatos-chave (pontos com fonte por era,
abrangência sem soma, valores por fase, lacunas) e o **índice** com o número de
página de cada seção.
- **Leitura entre eras** — bullets de concentração, instrumentos, continuidade,
alcance e lacunas que pesam (`betweenEras` do `narrative.json` ou derivação
determinística) + tabela **Era / Onde está a evidência / Como citar com
segurança / Limite**.
- **Trajetória completa** — a linha do tempo da carreira em tabela
(período/papel/como recuperar), incertezas na própria linha.
- **Seção por era (A/B/C)** — leitura da era ("Leitura da era", do
`narrative.json` ou determinística) + tabela **Objeto/Valor/Ano/Fase/
Abrangência/Fonte** + lista **"O que fez · item — alcance — lastro"**. Fase e
abrangência são texto, nunca selo colorido. **Sem cap**: a era flui por quantas
folhas precisar (continuações com cabeçalho "continuação N"). Uma era **sem
evidência** vira **página de lacuna explícita** (nunca seção em branco nem
"zero").
- **Abrangência: instituição × setor × rede** — callout "Não somar." + tabela de
leitura lado a lado (**nenhum total combinado**) e, em seguida, **as três
listas completas** em tabela (item/abrangência/evidência/fonte), cada uma
fluindo por quantas folhas precisar.
- **Títulos, honrarias e vínculos** — tabela data/natureza/registro/fonte, com a
regra de leitura (vínculo não prova resultado).
- **O que Solla defende** — seção própria e indexável: leitura dos registros
(não é opinião), lista de posições (`position-list`: rótulo da posição, leitura
com lastro, fonte + data + era) e, para cada item `era_X_defesas` sem registro,
a linha "Sem registro localizado" com a lacuna explícita. Só entra posição com
ato, notícia datada ou fala identificada como lastro.
- **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo
interno** — tabelas/listas completas, também correntes (sem cap). O acervo é
**amostra declarada** (as falas mais recentes com link, de N do recorte
temático) com painel do total; o recorte é por tema, não nominal.
interno** — tabelas/listas completas, também correntes (sem cap). As notícias
ganham a coluna "Uso no dossiê" (era) e o resumo datado; o acervo é **amostra
declarada** (as falas mais recentes com link, de N do recorte temático); o
recorte é por tema, não nominal.
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
defeso eleitoral 2026.
- **Nada some e nada é cortado:** o dossiê institucional **não usa caps** — o
builder mede a altura real de cada linha e pagina (grow/shrink) até a página
ficar cheia sem estourar; listas continuam em folhas de continuação com o texto
inteiro. O único "e mais N" é o do resumo e o da amostra do acervo, ambos
apontando para onde o resto está.
inteiro. O único "e mais N" é o da amostra do acervo.
## Conteúdo do Boletim modelo (1 página A4)
- Cabeçalho com o nome da instituição + identity pills + rótulo **"Modelo —
insumo interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura
"O que Jorge Solla fez pela e na <instituição>"; **≤6 destaques** (eyebrow
"Área · abrangência", número em destaque, título curto, nota); timeline de 4
passos da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com
controle editorial e defeso. Com **poucos fatos**, usa a variação de lacuna
(não cria cards vazios para chegar a seis).
- Cabeçalho com o nome da instituição + rótulo **"Modelo — insumo interno"**;
título "O que Jorge Solla fez pela e na <instituição>"; lede curta; **≤6
destaques** em lista (rótulo + texto, com o valor e a fase quando houver);
**"O que Solla defende"** com **≤2 defesas curtas** com lastro do ledger (bloco
omitido quando não há registro); trajetória em quatro períodos; **"E mais" com
≤14 itens** em lista; rodapé com controle editorial e defeso. Com **poucos
fatos**, a página termina com espaço — sem preencher por inferência nem
inventar número.
- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
CTA de campanha; herda **apenas** fatos com fonte do dossiê.
@ -335,7 +347,7 @@ nenhum número, data, nome ou órgão novo.
## Guardrails de produto (não negociáveis)
- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna.
- **Redação e parágrafos com lastro**: a carta e o parágrafo de cada era só usam
- **Redação e parágrafos com lastro**: a leitura de abertura e o parágrafo de cada era só usam
fatos dos itens com fonte; o orquestrador audita citação por citação (nome,
data, valor, órgão) e remove o que não tiver lastro.
- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
@ -359,8 +371,8 @@ nenhum número, data, nome ou órgão novo.
- **Folha do dossiê estourou**: o builder mede a altura real de cada linha e
repagina (grow/shrink) até estabilizar; se uma folha continuar estourando, é
porque **uma linha sozinha** não cabe — aperte a copy/caps daquele item, nunca
o layout. Página **não-packed** (carta, resumo, síntese, gráficos, fontes)
estourando = corte copy ou reposicione o card.
o layout. Página **não-packed** (O essencial, leitura entre eras, trajetória,
títulos, defende, fontes) estourando = corte copy ou enxugue o índice.
- **Página pela metade**: o pack estabilizou? Confira o log (`pack estável`); se
uma seção ficou rala, o problema é o custo medido (bloco novo) — não force
cap, ajuste a copy do item que abre a seção.
@ -387,11 +399,10 @@ nenhum número, data, nome ou órgão novo.
- Intenção: `docs/plans/dossie-solla-instituicao.md`; impl:
`docs/plans/dossie-solla-instituicao-impl.md`.
- Designs hi-fi (fonte de verdade do port):
`docs/plans/dossie-solla-instituicao-ui-design.html` e
`docs/plans/dossie-solla-instituicao-boletim-ui-design.html` — **desatualizados
desde a revisão de 2026-09-18** (carta, síntese, gráficos e folhas correntes
ainda não estão neles); um passe do `designer` precisa re-sincronizá-los.
- Designs hi-fi (fonte de verdade do port): `docs/plans/dossies-sobrios-analiticos-ui-design.html`
(C209 — cenas do dossiê e do boletim, um artefato da família). Os hi-fi antigos
(`dossie-solla-instituicao-ui-design.html` e `-boletim-ui-design.html`) ficam no
repo como registro e estão **superados**.
- Skill irmã (dona do pipeline): `.agents/skills/dossie-solla-cidade/SKILL.md` —
o ramo cidade segue com caps; a adoção do fluxo corrente é decisão de produto.
- Scripts: `scripts/build-dossie-solla-instituicao.mjs`,

View file

@ -24,9 +24,9 @@ token fora dos 18 canônicos **falha fechado**, nunca vira slug inventado.
O dossiê temático **não usa caps**: cada seção (eras, abrangência, lacunas,
notícias, acervo) flui por quantas páginas precisar, com quebra só entre linhas
— o orquestrador não decide o corte, o builder mede e pagina. Sobre essa base
vêm a **redação de abertura** (carta), a **síntese** dos números e os **gráficos
consolidados**, e cada era abre com um **parágrafo de consolidação** do que ela
entrega.
vêm **O essencial** (redação de abertura + fatos-chave + índice), a **leitura
entre eras**, a abrangência e o **O que Solla defende**, e cada era abre com a
**leitura da era**.
## Quando usar
@ -203,7 +203,7 @@ consolidação determinística.
{
"id": "era_c_emendas", // id do checklist da era
"answer": "R$ 5,0 milhões para a rede de ensino",
"summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber no resumo/boletim (C188)",
"position": "opcional — só em item `*_defesas`: rótulo curto da posição (ex. \"Educação básica\")",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"brief": { // obrigatório no item publicado: copy reformulada, curta
"title": "≤80 chars — manchete (o quê + onde)",
@ -229,11 +229,19 @@ consolidação determinística.
Checklist por era (ids):
- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
`era_a_sas_ms`, `era_a_vinculo`.
`era_a_sas_ms`, `era_a_vinculo`, `era_a_defesas`.
- **B:** `era_b_sesab`, `era_b_politicas`, `era_b_investimentos`,
`era_b_convenios`, `era_b_articulacao`.
`era_b_convenios`, `era_b_articulacao`, `era_b_defesas`.
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_relatorias`,
`era_c_emendas`, `era_c_programas`, `era_c_audiencias`, `era_c_titulos`.
`era_c_emendas`, `era_c_programas`, `era_c_audiencias`, `era_c_titulos`,
`era_c_defesas`.
Os itens `era_X_defesas` são a dimensão **"O que Solla defende"** (`kind:
defense`): `answer` = frase da posição, `details` = lastro identificado
(ato/proposição, "Notícia: veículo · data" ou trecho de fala) e
`sourceUrl`/`sourceDate` da fonte do lastro — **sem registro datado vira lacuna**,
nunca posição inferida. Os demais itens (`kind: evidence`) alimentam as eras, a
abrangência e a síntese; itens de defesa **não** entram nessas listas.
A **abrangência** (`sphere`) é `area` | `segmento` | `rede`; `segmento` e `rede`
não são a área e **nunca são somados** a ela (cada linha informa sua
@ -243,16 +251,16 @@ vira lacuna. `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` (default
`nao_informado`; empenho **não** é pagamento). O `bulletinFacts` (ledger do
boletim) só é populado por item **com fonte** — o boletim não introduz fato novo.
**`brief` (copy reformulada, sem reticências) e `summary` (C188).** As páginas
A4 têm altura fixa: o `brief` é a copy que o PDF imprime nas páginas de era, nas
listas de abrangência e nos cartões do boletim (o `answer`/`details` integrais
ficam no `.md` e no lastro); sem ele o builder imprime o texto integral e a
página pode estourar. Escreva `brief.title` ≤ 80 e `brief.note` ≤ 120 chars para
**todo item publicado**; o `summary` alimenta as superfícies de resumo. O
boletim de uma página **mede e reduz por medição**: o painel do acervo (falas
com link) entra no contador "e mais N fatos com fonte", nunca nos destaques; se
ainda não couber, o builder re-renderiza com menos fatos impressos e declara o
resto — sem truncar texto e sem descarte silencioso.
**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
o `brief` é a copy que o PDF imprime nas páginas de era, nas listas de
abrangência e na lista do boletim (o `answer`/`details` integrais ficam no `.md`
e no lastro); sem ele o builder imprime o texto integral e a página pode
estourar. Escreva `brief.title` ≤ 80 e `brief.note` ≤ 120 chars para **todo item
publicado**. O boletim de uma página **mede e reduz por medição**: o painel do
acervo (falas com link) entra no contador "e mais N fatos com fonte", nunca nos
destaques; se ainda não couber, o builder re-renderiza com menos fatos impressos
e depois com menos defesas, declarando o resto — sem truncar texto e sem
descarte silencioso.
`<slug>.narrative.json` (opcional; escrito pelo redator, auditado pelo
orquestrador):
@ -263,71 +271,71 @@ orquestrador):
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
"title": "O que Jorge Solla fez pela Educação",
"opening": ["parágrafo", "parágrafo", "parágrafo"],
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" },
"betweenEras": ["bullet", "bullet"] // opcional — leitura entre eras
}
```
Sem o arquivo (ou com `themeSlug` diferente do snapshot), o builder **não
falha**: a abertura repete a leitura dos números e cada era usa a consolidação
determinística. `opening` e `eras` só podem conter fatos dos itens com fonte —
nenhum número, data, nome ou órgão novo.
falha**: a abertura repete a leitura dos números, cada era usa a consolidação
determinística e a leitura entre eras é derivada dos registros. `opening`,
`eras` e `betweenEras` só podem conter fatos dos itens com fonte — nenhum número,
data, nome ou órgão novo.
## Conteúdo do dossiê
- **Capa** — série temática, "INSUMO INTERNO — defeso 2026", identificação da
área (label `Educação` + valor canônico `educacao` + nota da taxonomia), data,
"como usar", escopo/versão.
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela
área ao longo das eras, em prosa, com a nota "Como ler" (cada afirmação tem
lastro em item datado; a redação não preenche por inferência).
- **Resumo de uma olhada** — identificação (área/token/origem/filtro) + linha do
tempo documentada do vínculo; principais entregas localizadas (badge de
abrangência + valor + badge de fase + fonte); gancho para a agenda; o que
falta. Guarda: **autorizado ≠ empenhado ≠ liquidado ≠ pago**. É a única página
com listas capadas — e o "e mais N" aponta para a seção onde a lista completa
está.
- **Síntese** — leitura dos números com lastro (pontos por era e abrangência,
concentração relativa, itens com valor por fase, recursos por fase, temas,
lacunas) + guardas de leitura; nada que não venha dos itens.
- **Gráficos consolidados** — recursos **com execução por ano** (empilhado por
fase), **propostas/articulações sem fase informada**, abrangência, trajetória
por ano, áreas e lacunas por era, painel do acervo. Valores sempre em R$ com a
fase; recortes nunca somados.
- **Seção por era (A/B/C)** — parágrafo de consolidação ("O que esta era
entrega") + recorte e método + trilha de recuperação; tabela
**Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis e iniciativas com
evidência visível". **Sem cap**: a era flui por quantas folhas precisar
(continuações com cabeçalho "continuação N"). Uma era **sem evidência** vira
**página de lacuna explícita** (nunca seção em branco nem "zero").
- **Abrangência: área × segmento × rede** — painel "Segmento/rede não é a área.
Não some os recortes." com contagem por recorte (**nenhum total combinado**) e
prévia; em seguida **as três listas completas** em tabela
(item/abrangência/evidência/fonte), cada uma fluindo por quantas folhas
precisar; gancho e lacuna prioritária.
área (label + valor canônico + nota da taxonomia), data, "como usar",
escopo/versão.
- **O essencial** — abre o documento: leitura do recorte (a redação de abertura),
identificação da área, fatos-chave (pontos com fonte por era, abrangência sem
soma, valores por fase, lacunas) e o **índice** com o número de página de cada
seção.
- **Leitura entre eras** — bullets de concentração, instrumentos, continuidade,
alcance e lacunas que pesam (`betweenEras` do `narrative.json` ou derivação
determinística) + tabela **Era / Onde está a evidência / Como citar com
segurança / Limite**.
- **Trajetória completa** — a linha do tempo da carreira em tabela
(período/papel/como recuperar), incertezas na própria linha.
- **Seção por era (A/B/C)** — leitura da era ("Leitura da era", do
`narrative.json` ou determinística) + tabela **Objeto/Valor/Ano/Fase/
Abrangência/Fonte** + lista **"O que fez · item — alcance — lastro"**. Fase e
abrangência são texto, nunca selo colorido. **Sem cap**: a era flui por quantas
folhas precisar (continuações com cabeçalho "continuação N"). Uma era **sem
evidência** vira **página de lacuna explícita** (nunca seção em branco nem
"zero").
- **Abrangência: área × segmento × rede** — callout "Não somar." + tabela de
leitura lado a lado (**nenhum total combinado**) e, em seguida, **as três
listas completas** em tabela (item/abrangência/evidência/fonte), cada uma
fluindo por quantas folhas precisar; a cena temática acrescenta Era/ano,
Valor/fase e a evidência temática.
- **O que Solla defende** — seção própria e indexável: leitura dos registros
(não é opinião), lista de posições (`position-list`: rótulo da posição, leitura
com lastro, fonte + data + era) e, para cada item `era_X_defesas` sem registro,
a linha "Sem registro localizado" com a lacuna explícita. Só entra posição com
ato, notícia datada ou fala identificada como lastro.
- **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo
interno por tema** — tabelas/listas completas, também correntes (sem cap). O
acervo é **amostra declarada** (as falas mais recentes com link, de N do
recorte `Speech.topics = <valor>`) com painel do total e do universo.
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
defeso eleitoral 2026.
interno por tema** — tabelas/listas completas, também correntes (sem cap). As
notícias ganham a coluna "Uso no dossiê" (era) e o resumo datado; o acervo é
**amostra declarada** (as falas mais recentes com link, de N do recorte
`Speech.topics = <valor>`).
- **Fontes e limites** — hierarquia de fontes; limites de cobertura; regras para
uso editorial; nota de defeso eleitoral 2026.
- **Nada some e nada é cortado:** o dossiê temático **não usa caps** — o builder
mede a altura real de cada linha e pagina (grow/shrink) até a página ficar
cheia sem estourar; listas continuam em folhas de continuação com o texto
inteiro. O único "e mais N" é o do resumo e o da amostra do acervo, ambos
apontando para onde o resto está.
inteiro. O único "e mais N" é o da amostra do acervo.
## Conteúdo do Boletim modelo (1 página A4)
- Cabeçalho com o nome da área + identity pills + rótulo **"Modelo — insumo
interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura "O que
Jorge Solla fez na área de <área>"; **≤6 destaques** (eyebrow "Área ·
abrangência", número em destaque, título curto, nota); timeline de 4 passos da
trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com controle
editorial e defeso. Com **poucos fatos**, usa a variação de lacuna (não cria
cards vazios para chegar a seis). O painel do acervo **não ocupa vaga de
destaque**: entra no contador "e mais N fatos com fonte"; se os fatos
impressos ainda estourarem a folha, o builder mede e reduz o conjunto
impresso até caber, declarando o resto — nunca corta texto.
- Cabeçalho com o nome da área + rótulo **"Modelo — insumo interno"**; título
"O que Jorge Solla fez pela área de <área>"; lede curta; **≤6 destaques** em
lista (rótulo + texto, com o valor e a fase quando houver); **"O que Solla
defende"** com **≤2 defesas curtas** com lastro do ledger (bloco omitido quando
não há registro); trajetória em quatro períodos; **"E mais" com ≤14 itens** em
lista; rodapé com controle editorial e defeso. Com **poucos fatos**, a página
termina com espaço — sem preencher por inferência nem inventar número. O painel do acervo
**não ocupa vaga de destaque**: entra no contador "e mais N fatos com fonte".
- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
CTA de campanha; herda **apenas** fatos com fonte do dossiê.
@ -359,7 +367,7 @@ nenhum número, data, nome ou órgão novo.
## Guardrails de produto (não negociáveis)
- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna.
- **Redação e parágrafos com lastro**: a carta e o parágrafo de cada era só usam
- **Redação e parágrafos com lastro**: a leitura de abertura e o parágrafo de cada era só usam
fatos dos itens com fonte; o orquestrador audita citação por citação (nome,
data, valor, órgão) e remove o que não tiver lastro.
- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
@ -386,8 +394,8 @@ nenhum número, data, nome ou órgão novo.
- **Folha do dossiê estourou**: o builder mede a altura real de cada linha e
repagina (grow/shrink) até estabilizar; se uma folha continuar estourando, é
porque **uma linha sozinha** não cabe — aperte a copy/caps daquele item, nunca
o layout. Página **não-packed** (carta, resumo, síntese, gráficos, fontes)
estourando = corte copy ou reposicione o card.
o layout. Página **não-packed** (O essencial, leitura entre eras, trajetória,
defende, fontes) estourando = corte copy ou enxugue o índice.
- **Página pela metade**: o pack estabilizou? Confira o log (`pack estável`); se
uma seção ficou rala, o problema é o custo medido (bloco novo) — não force
cap, ajuste a copy do item que abre a seção.
@ -410,10 +418,10 @@ nenhum número, data, nome ou órgão novo.
- Intenção: `docs/plans/dossie-solla-tema.md`; impl:
`docs/plans/dossie-solla-tema-impl.md`.
- Designs hi-fi (fonte de verdade do port):
`docs/plans/dossie-solla-tema-ui-design.html` e
`docs/plans/dossie-solla-tema-boletim-ui-design.html` — variantes da família
C187; um passe do `designer` os mantém sincronizados com a revisão corrente.
- Designs hi-fi (fonte de verdade do port): `docs/plans/dossies-sobrios-analiticos-ui-design.html`
(C209 — cenas do dossiê e do boletim, um artefato da família). Os hi-fi antigos
(`dossie-solla-tema-ui-design.html` e `-boletim-ui-design.html`) ficam no repo
como registro e estão **superados**.
- Skills irmãs: `.agents/skills/dossie-solla-cidade/SKILL.md` (dona do pipeline)
e `.agents/skills/dossie-solla-instituicao/SKILL.md` (segundo recorte).
- Scripts: `scripts/build-dossie-solla-tema.mjs`,

View file

@ -17,8 +17,10 @@ terem sido pesquisadas, pelo agente principal.
`gaps[]` são lacunas explícitas).
2. Escrever `data/dossie-solla-cidade/<slug>.narrative.json` no contrato da
skill: `municipalitySlug`, `generatedAt`, `title`, `opening` (3 parágrafos,
110–150 palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida,
contando a contribuição de Solla para a cidade e seu recorte regional.
110–150 palavras), `eras` (A/B/C, 80–120 palavras cada) — prosa corrida,
contando a contribuição de Solla para a cidade e seu recorte regional — e
`betweenEras` (2–5 bullets curtos de leitura entre eras: concentração,
instrumentos, continuidade, alcance/ruptura e lacunas que pesam).
3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill).
## Limites
@ -30,6 +32,9 @@ terem sido pesquisadas, pelo agente principal.
- Valor sempre com a fase informada; **empenho ≠ pagamento**; "emenda de bancada
proposta" não é execução.
- Sem bullets, sem listas, sem reticências, sem CTA de campanha, sem PII; não
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos.
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos (os
bullets de `betweenEras` seguem a mesma régua).
- **"O que Solla defende" não é sua:** as posições vêm dos itens `era_X_defesas`
com fonte; não atribua defesa sem registro em lugar nenhum da redação.
- **Não** rode ssh, extração ou build; **não** edite os `research.json`; **não**
commite nada (artefato gitignored, repo público).

View file

@ -18,8 +18,10 @@ pesquisadas, pelo agente principal.
`sourceDate`; `gaps[]` são lacunas explícitas).
2. Escrever `data/dossie-solla-instituicao/<slug>.narrative.json` no contrato da
skill: `institutionSlug`, `generatedAt`, `title`, `opening` (3 parágrafos,
110–150 palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida,
contando a contribuição de Solla para a instituição por era.
110–150 palavras), `eras` (A/B/C, 80–120 palavras cada) — prosa corrida,
contando a contribuição de Solla para a instituição por era — e
`betweenEras` (2–5 bullets curtos de leitura entre eras: concentração,
instrumentos, continuidade, alcance/ruptura e lacunas que pesam).
3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill).
## Limites
@ -31,6 +33,9 @@ pesquisadas, pelo agente principal.
- Valor sempre com a fase informada; **empenho ≠ pagamento**; "emenda de bancada
proposta" não é execução.
- Sem bullets, sem listas, sem reticências, sem CTA de campanha, sem PII; não
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos.
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos (os
bullets de `betweenEras` seguem a mesma régua).
- **"O que Solla defende" não é sua:** as posições vêm dos itens `era_X_defesas`
com fonte; não atribua defesa sem registro em lugar nenhum da redação.
- **Não** rode ssh, extração ou build; **não** edite os `research.json`; **não**
commite nada (artefato gitignored, repo público).

View file

@ -17,8 +17,10 @@ três eras terem sido pesquisadas, pelo agente principal.
`sourceDate`; `gaps[]` são lacunas explícitas).
2. Escrever `data/dossie-solla-tema/<slug>.narrative.json` no contrato da skill:
`themeSlug`, `generatedAt`, `title`, `opening` (3 parágrafos, 110–150
palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, contando a
contribuição de Solla para a área por era.
palavras), `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, contando a
contribuição de Solla para a área por era — e `betweenEras` (2–5 bullets
curtos de leitura entre eras: concentração, instrumentos, continuidade,
alcance/ruptura e lacunas que pesam).
3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill).
## Limites
@ -30,6 +32,9 @@ três eras terem sido pesquisadas, pelo agente principal.
- Valor sempre com a fase informada; **empenho ≠ pagamento**; "emenda de bancada
proposta" não é execução.
- Sem bullets, sem listas, sem reticências, sem CTA de campanha, sem PII; não
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos.
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos (os
bullets de `betweenEras` seguem a mesma régua).
- **"O que Solla defende" não é sua:** as posições vêm dos itens `era_X_defesas`
com fonte; não atribua defesa sem registro em lugar nenhum da redação.
- **Não** rode ssh, extração ou build; **não** edite os `research.json`; **não**
commite nada (artefato gitignored, repo público).

View file

@ -0,0 +1 @@
**Dossiês e boletins Solla sóbrios e analíticos (C209, Issue #1251):** a família de dossiês (cidade/instituição/tema) e o boletim modelo foram redesenhados do zero como documento sóbrio, list-based e analítico, portando classe-a-classe o hi-fi `docs/plans/dossies-sobrios-analiticos-ui-design.html`. O dossiê agora abre pela leitura: **O essencial** (redação + fatos-chave + índice com página real por seção), **Leitura entre eras** (concentração/instrumentos/continuidade/alcance/lacunas + síntese operacional), **Trajetória completa** em tabela, eras com leitura + tabela Objeto/Valor/Ano/Fase/Esfera/Fonte + lista "O que fez · item — alcance — lastro", **O que Solla defende** (dimensão nova: itens de checklist `era_X_defesas`, `kind: 'defense'`, com lastro obrigatório — ato, notícia datada ou trecho do acervo; sem registro vira lacuna explícita), região/polo e abrangência em sequências não somáveis, lacunas/notícias (coluna "Uso no dossiê")/acervo/fontes. Morreram os gráficos consolidados, os cartões, os selos coloridos de esfera/fase, os placeholders de imagem e os caps do resumo; fase e esfera são texto. O boletim de 1 página ganhou o bloco "O que Solla defende" (≤2 defesas com lastro, fora do ranking e dos caps, contadas no "e mais N"), destaques em lista e trajetória em quatro períodos. A engine edita os donos (`dossieResearch`/`dossieBlocks`/`dossieRender`/`dossieBulletin*`/`buildPdf`), o `narrative.json` ganhou `betweenEras` opcional (fallback determinístico), o índice do companion `.md` ganhou âncoras reais e a guarda A4 fail-closed foi preservada nos três builders. Sem `src/`, sem schema/migration/URL pública, sem tocar a campanha; débitos maiores triados na Issue #1265.

View file

@ -0,0 +1,188 @@
# Impl: Dossiês e boletins Solla sóbrios e analíticos — listas, análise (o que fez × o que defende) e consulta rápida (cidade · instituição · tema)
Status: **aprovado** — modo --auto
Atualizado em: 2026-09-22
Issue: #1251
Intenção: docs/plans/dossies-sobrios-analiticos.md
Appetite restante: **~4–5 dias eng** (herdado). Se estourar, corta-se o número de página no índice (vira nome de seção, precedente C163) e o `betweenEras` autoral; **nunca** o "defende" com lastro, a remoção de gráficos/cartões/selos, a guarda A4 nem os guardrails de produto.
## Leitura da intenção
- **Outcome:** as três variantes de dossiê (cidade/instituição/tema) e o boletim modelo relidos como documento sóbrio, list-based e analítico — "O essencial" abre (fez + defende + destaques + lacunas), cada era mantém anatomia constante, o "O que Solla defende" nasce com lastro (ato/notícia datada/trecho do acervo) e o índice+âncoras dão consulta rápida; nada some e os guardrails seguem intactos.
- **O que NÃO negociar:** sem fonte não publica; fase por valor (autorizado ≠ empenhado ≠ liquidado ≠ pago); esfera/abrangência explícita e nunca somada; leitura relativa (nunca % estadual); atribuição de emenda só com autoria checada; PII mínima; artefato gitignored; defeso (insumo interno/modelo, sem CTA); sem marca de campanha, sem imagens nem placeholders de imagem; boletim 1 página A4 com rótulo "Modelo — insumo interno". Outcome de produto inviolável.
- **O que reavaliar (hipóteses da intenção):**
- "O dossiê precisa de índice com página" — o paginador de 2 passadas (`dossieRender.mjs:535-540`, `:1990-1995`) já conhece a página de cada folha na 2ª passada; o mapa âncora→página é colhido sem reescrever o paginador (Decisão A).
- "'O que defende' reusa a pesquisa existente" — confirmado: item de checklist com `sourceUrl`+`sourceDate` obrigatórios já é o contrato de lastro (`dossieResearch.mjs:478-485`); falta só discriminar `kind: 'defense'` (MUDANÇA) e preservar `news.summary`, hoje normalizado e descartado (`dossieResearch.mjs:554` → `dossieBlocks.mjs:503-508`/`:1140-1145`).
- "'Gráficos consolidados' morre" — confirmado no render; `chartPrimitives.mjs` **não** morre (consumidor Instagram em `graficosInstagramRender.mjs:18`).
- O guard do emit exige `[data-page="resumo"]` (`buildPdf.mjs:242-246`): a folha "O essencial" mantém a âncora `resumo` e só muda título/conteúdo.
## Abordagem recomendada
```mermaid
flowchart LR
D["design aprovado<br/>dossies-sobrios-analiticos-ui-design.html<br/>(10 cenas dossiê + cena 12 boletim)"] --> U["dossieUnit (3 variantes)"]
U --> R["dossieResearch<br/>checklist + kind:'defense'<br/>+ news.summary preservado"]
R --> B["dossieBlocks<br/>report: defends / betweenEras / news"]
B --> H["dossieRender<br/>CSS sóbrio + seções novas<br/>+ índice âncora→página"]
B --> MD["companion .md espelhado"]
H --> P["dossiePack + buildPdf<br/>(probe, fit A4, emit)"]
B --> BL["dossieBulletin<br/>defenses fora do ranking"]
BL --> BR["dossieBulletinRender<br/>cena 12 list-based"]
P --> PDF["dossiê PDF + boletim PDF"]
BR --> PDF
```
**Opções consideradas:** A) port classe-a-classe do design aprovado sobre a engine existente, editando os donos (`dossie*`, builders, SKILLs) | B) renderer v2 em paralelo (fork com feature flag) | C) uma linguagem por variante (3 redesenhos).
**Recomendação: A** — o design é a restrição aprovada ("portar classe a classe, sem redesenhar") e a engine já é multiplexada por `unit` (`dossieUnit.mjs:440-451`); editar o dono mantém pack/fit/emit e não twina spec.
**Rejeitadas:** B porque duplica render/CSS/specs e deixaria dois caminhos de paginação para manter; C porque a intenção pede **um** desenho para a família e triplicaria a atualização de specs/skills.
### Componentes / mudanças
- **`scripts/lib/dossieResearch.mjs`** — checklists por era × variante (`:30-335`) ganham itens de posições/defesas com `kind: 'defense'` (MUDANÇA explícita); normalizer (`:422`) propaga `kind` ao item (`:497-515`); `news.summary` continua normalizado (`:554`) e passa a ser preservado no merge/report. Nenhum schema novo de arquivo.
- **`scripts/lib/dossieBlocks.mjs`** — `report.defends` (defesas com lastro, por variante), `report.news[].summary`, `bulletinFacts[].defense` (MUDANÇA); `buildDossierSynthesis` (`:641`) alimenta a leitura entre eras; `bulletinFacts` cidade `:383-427` e subject `:1049-1083`.
- **`scripts/lib/dossieRender.mjs`** — novo `DOSSIER_PRINT_CSS` sóbrio (substitui `PRINT_CSS :358-484`/`INSTITUTION_PRINT_CSS :732-761`); folhas novas/reescritas: capa, `resumo` = O essencial + índice, `sintese` = Leitura entre eras, `trajetoria` em tabela, eras (leitura + tabela + lista "O que fez"), `defende` (nova), região, lacunas, notícias/acervo/fontes; mapa `anchorPages` colhido na 1ª passada (`:487-533`, `:1950-1988`); MD espelhado (`:585`, `:2053`) com sumário.
- **`scripts/lib/dossieBulletin.mjs` / `dossieBulletinRender.mjs`** — `buildBulletin` (`:42`) separa `defenses` (≤2) do ranking `:18-29`/caps `:13-14`; render da cena 12 (`:118-213` município, `:245-390` subject) list-based, sem `asset-box`/card.
- **`scripts/lib/buildPdf.mjs`** — sem mudança no contrato; os 3 builders alinham `onDossierOverflow`/`onBulletinOverflow` (`emitHtmlPairPdf :224`).
- **Builders** — `build-dossie-solla-cidade.mjs`, `-instituicao.mjs`, `-tema.mjs`: callbacks de fit nos três (hoje só cidade tem dossier-overflow `:289-293` e só tema tem bulletin-shrink `:239-245`).
- **`.agents/skills/dossie-solla-{cidade,instituicao,tema}/SKILL.md`** — "Conteúdo do dossiê" (`cidade:269`, `instituicao:248`, `tema:266`) e "Conteúdo do Boletim modelo" (`:311`/`:291`/`:310`) reescritos; contrato JSON com os ids de defesa.
- **`.opencode/agent/dossie-solla-{cidade,instituicao,tema}-redacao.md`** — `narrative.json` ganha `betweenEras` (MUDANÇA, opcional, fallback determinístico); regras do defende (não é opinião; só registro).
- **Migration:** sem migration — escopo `scripts/` + skills/agentes/specs; nada em `src/`, sem banco.
- **Access / Consent:** não toca. Nenhum write path, nenhum PII novo.
- **UI:** Impeccable C (redesenho de superfície existente) — shape→craft→critique→polish já feitos no gate; o impl **porta o design** e usa os specs de render como prova; o artefato do designer é imutável como registro (mudança material volta ao humano no PR).
### Dados → forma (se aplicável)
- **Forma:** listas e tabelas (`.plain-list`, `.index-list`, `.document-table`, `.action-list`, `.position-list`, boletim `.bulletin-list`/`.period-list`) + prosa curta; **sem** gráficos, cartões ou selos coloridos; fase e esfera/abrangência como texto/coluna; números sempre na tabela/prosa com fonte e data.
- **Por quê:** é a forma aprovada no gate e a que sustenta "leitura antes do inventário" com rastreio item a item; a leitura relativa/local se mantém (contagens por recorte, nunca soma entre esferas).
- **Rejeitadas:** gráficos (rejeitado pelo humano; devolveria o inventário visual), cartões lado a lado e selos (idem), e derivar visual novo além do design (decisão do designer, não da engine).
## Decisões de engenharia
### A) Índice + âncoras no PDF
**Opções:** A) mapa âncora→página colhido na 2ª passada do renderer + índice com número de página real | B) índice textual sem página (precedente C163 `cityReportRender.mjs:361,411`) | C) outline/bookmarks do PDF (`page.pdf({ outline })`).
**Recomendação: A** — o design (cena 02, `ui-design.html:798-910`) mostra o índice com número de página (`index-number`/`index-page`) e o rodapé corrente com a âncora do companion (`#o-essencial`, `:902-908`); a 2ª passada já percorre as partes em ordem com `nextPage()` (`dossieRender.mjs:487-533`/`:1950-1988`), então basta registrar `anchorPages[data-page] = pageNo` na 1ª passada e renderizar o índice na 2ª. Seções omitidas (era vazia) aparecem com o texto do design ("sem evidência nominal suficiente; consulte as lacunas", cena 04 `:1118-1122`), nunca com número inventado.
**Rejeitadas:** B porque o design aprovado e a intenção ("consulta rápida", linha 42) pedem a página, e o custo de A é local; C porque outline não aparece no documento impresso, não controla o layout e não espelha no `.md` — fica fora (não é o mecanismo de consulta).
**Corte declarado:** se estourar o fit da folha `resumo` ou a quota, cai-se para B (índice só com nome de seção) e o rodapé `#âncora` fica de fora; o índice por seção permanece.
### B) "O que defende" — origem do dado
**Opções:** A) item de checklist novo por era (`era_X_defesas`, `kind: 'defense'`) reusando o contrato item→research→report | B) seção nova `defenses[]` no `research.json` com validação própria | C) derivar de `news.summary` + acervo sem campo novo.
**Recomendação: A** — é o mesmo arquivo e o mesmo pipeline (sem segundo pipeline de pesquisa): o item já exige `answer` + `sourceUrl` + `sourceDate` (`dossieResearch.mjs:471-485`), vira gap fail-closed sem fonte, e o `brief` reformulado (≤80/≤120, `:26-27`) garante o fit. O contrato do item de defesa fica: `answer` = frase da posição ("Defende X"), `details` = lastro identificado (ato/proposição, ou "Notícia: veículo · data", ou trecho de fala), `sourceUrl`/`sourceDate` = fonte do lastro, `area` do checklist = rótulo da posição (ex. "Ensino superior", "Saúde regional"). O report segrega por `item.kind === 'defense'` e publica `report.defends`; `news.summary` é **preservado** (corrige a perda `dossieBlocks.mjs:503-508`/`:1140-1145`) para a redação citar o lastro datado. Sem item pesquisado ou sem fonte → gap com label ("Posições e defesas"), e a seção imprime "Sem registro localizado" com a regra "resultado de busca não prova ausência" (cena 11 `:1903-1918`) — nunca uma defesa inferida.
**Rejeitadas:** B porque twina normalizer/receipt/merge para ganho nulo (o item já carrega fonte e data) e amplia a superfície dos specs; C porque posição derivada de keyword/LLM é inferência — anti-goal explícito ("não atribuir a Solla uma defesa que não tem registro", intenção linha 21).
### C) Boletim defende
**Opções:** A) `bulletinFacts` marca `defense: true`; `buildBulletin` separa `defenses` (≤2) do ranking e dos caps de 6+14; render imprime o bloco no lugar dos cards | B) reusar 1–2 destaques existentes como "defesas" | C) o redator escreve as defesas do boletim.
**Recomendação: A** — o ledger continua sendo o único insumo (`dossieBulletin.mjs:1-6`), os fatos de defesa já têm `sourceUrl` (exigido no research) e o bloco é **sem linha de fonte e sem CTA**, como as demais linhas (specs pinam `not.toContain('source-link')`, `dossieInstitution.unit.spec.ts:396`, `dossieTheme.unit.spec.ts:490`). As defesas ficam **fora** do ranking (`:18-29`) e não deslocam fatos de "fez"; entram na contagem `factsPrinted`/`factsRemaining` (`:103-109`) para o "e mais N" não mentir. Bloco vazio (nenhuma defesa com lastro) = bloco omitido, nunca inventado. Shrink: `onBulletinOverflow` passa a existir nos 3 builders; a primeira redução tira destaques e, se ainda estourar, tira a 2ª defesa — sempre contabilizada.
**Rejeitadas:** B porque confunde "fez" com "defende" (a dimensão nova é o ponto do item) e não tem lastro próprio no card; C porque vira opinião editorial sem registro — anti-goal.
### D) Carta/resumo/anatomia — mapeamento design → código
| Design (cena) | Hoje | Decisão |
| -------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 01 Capa (`:743-795`) | `renderCover:116` / `renderSubjectCover:920` | portar classes; **morre** o `assetBox` "NEEDS ASSET" (`:141`, `:952`) e o painel de imagem |
| 02 O essencial + índice (`:797-910`) | `renderSummary:238` / `renderSubjectSummary:1056` (deliveries+hooks+pending capados) | vira **O essencial**: lede (redação) + fatos-chave (contagens/valores/lacunas) + índice; as listas capadas saem da impressão (o ledger do boletim continua); âncora `data-page="resumo"` **mantida** (guard do emit) |
| 03 Leitura entre eras (`:912-1017`) | `renderUnitSynthesisPage:1781` (Síntese) | vira **Leitura entre eras** (âncora `sintese` mantida): bullets de concentração/instrumentos/continuidade/ruptura/lacunas + "Síntese operacional" em tabela + callout; a tabela "Recursos por fase" (`:1815-1834`) permanece (número com fase, sem gráfico) |
| 04 Trajetória completa (`:1019-1129`) | `renderTrajectory:162` + `timelineGrid:83` (grid) | vira **tabela** Período/Papel/Como recuperar (com incertezas na própria linha) |
| 05 Era (`:1131-1253`) | `renderEraSheet:1188` | anatomia constante: leitura (`era-summary`/narrative) + tabela Objeto/Valor/Ano/Fase/Esfera\|Abrangência/Fonte + lista "O que fez · item — alcance — lastro" (`action-list`); **morrem** os cards (`action-grid :1202-1206`) e os selos (`scope`/`phase :400-402`) |
| 06 Região/polo (`:1255-1369`) | `renderRegion:248` + listas `:318` | mesmo fluxo (duas listas separadas, "não somar"), sem `scope-compare` decorativo |
| 07 Lacunas (`:1371-1455`) | `renderGapSheet:1536` | mantém tabela + "continuação N" |
| 08 Notícias/acervo/fontes (`:1457-1597`) | `renderNewsSheet:1601`, `renderAcervoSheet:1657`, `renderUnitSourcesPage:1724` | mantém as seções correntes; notícias ganham a coluna "Uso" (Era, dado real `news.era`) e o acervo perde o `acervo-stat-grid:1671-1682` (números viram texto/meta) |
| 09/10 Deltas instituição/tema (`:1599-1848`) | `renderSubjectScope:1270` (`scope-cards`), `renderSubjectHonorSheet:1436` | abrangência vira **tabela** (morrem `scope-cards :1290-1309`); honrarias/títulos em tabela + identificação em `document-table` (morre `identity-badge :734-738`) |
| 11 O que Solla defende (`:1850-1933`) | **não existe** | seção nova (`data-page="defende"`, `#o-que-solla-defende` no MD): lede + `position-list` (label/leitura/fonte+data) + callout "Como usar" |
| 12 Boletim (`:1935-2051`) | `dossieBulletinRender` cards | list-based: destaques (`bulletin-list`) + defesas (`bulletin-defense-list`) + trajetória 4 períodos (`period-list`) + "E mais" (`plain-list`) + rodapé defeso |
**"Defende" por era?** Não — o design aprovado materializa a seção própria (cena 11) e o essencial a aponta; cada era mantém leitura + tabela + "O que fez". A questão em aberto da intenção (recomendação A, "seção própria indexável") fica confirmada pelo design.
**"O que morre":** Gráficos consolidados (`renderUnitChartsPage:1838-1923`, chamadas `:501`/`:1963`, imports `:8-14`, CSS `:464-470`), cards (`action-grid`), `scope-cards`, `acervo-stat-grid`, selos `scope`/`phase`, `identity-badge`, `asset-box`/NEEDS ASSET e o placeholder de imagem. O `placeholder-bar` de **valor ausente** (`:70-73`) permanece (é dado, não imagem); a célula sem valor pode seguir a nota do design ("Não localizado ≠ zero", cena 05 `:1207-1210`).
### E) CSS
**Opções:** A) um bloco sóbrio novo compartilhado pelas 3 variantes + boletim (substitui `PRINT_CSS`/`INSTITUTION_PRINT_CSS`) | B) manter `PRINT_CSS` e sobrepor um "sober" por cima | C) CSS por variante.
**Recomendação: A** — o design tem um sistema único (tokens `--ink/--accent/--line/--paper`, `ui-design.html:9-30`) e o delta instituição/tema hoje já é pequeno (`INSTITUTION_PRINT_CSS:732-761` é só `PRINT_CSS` + extensões); a substituição encolhe o CSS (morrem `.chart-*`, `.action-grid`, `.scope-cards`, `.highlight-*`, `.asset-box`) e evita duas linguagens. Sem marca de campanha, sem imagens; boletim reescrito no mesmo sistema (cena 12), com bloco próprio porque o layout A4 de 1 página difere do dossiê.
**Rejeitadas:** B porque conviveriam dois estilos e o "sober" venceria por cascata imprevisível; C porque racha a família (anti-goal do design).
### F) Specs / skills / redação
**Opções:** A) atualizar os specs para o **novo** contrato e reescrever as seções das SKILLs/agentes no mesmo PR | B) manter os asserts antigos e adaptar o render para continuar passando | C) deixar specs/skills para depois.
**Recomendação: A** — `dossieRender.unit.spec.ts:94-108,130-132,179-183,215-256`, `dossieInstitution.unit.spec.ts:235-254,256-277,327-346,387-410`, `dossieTheme.unit.spec.ts:247-268,315-335,429-562`, `dossieBlocks.unit.spec.ts:100-263`, `dossieBulletin.unit.spec.ts:23-81` e os 3 specs de lote (`dossieBatchSkill.unit.spec.ts:101-136` e irmãos, que exigem "A contribuição (carta)", "**Síntese**", "Gráficos consolidados") passam a pinar: âncoras preservadas + `defende`; ausência de `graficos`/`chart-grid`/`action-grid`/`scope-cards`/`asset-box`; presença de `index-list`/`document-table`/`position-list`/`bulletin-defense-list`. Nunca afrouxar um assert para "passar". As 3 SKILLs: reescrever "Conteúdo do dossiê"/"Conteúdo do Boletim modelo", o "Contrato dos JSONs" (ids novos de defesa) e o troubleshooting que cita síntese/gráficos (`cidade:281-294`, `:346-355`); os 3 agentes de redação ganham o contrato `betweenEras` e as regras do defende. **C210** toca as mesmas SKILLs (`briefing-capacitacao-solla.md:57`): C209 reescreve conteúdo, C210 adiciona o ponteiro — rebase esperado, sem bloqueio.
**Rejeitadas:** B porque inverteria o dono (o spec passaria a ditar layout morto) e travaria o redesign; C porque deixaria o CI verde mentindo e a skill ensinando o layout antigo.
## Fases verificáveis
1. **Tracer — contrato do defende + `news.summary` ponta a ponta (quota ~1,0 dia).** Checklist `*_defesas` nas 3 variantes com `kind: 'defense'` (`dossieResearch.mjs:30-335`); normalizer propaga `kind` (`:497-515`); `buildDossierReport` publica `report.defends` e `bulletinFacts[].defense`; `news.summary` preservado (`dossieBlocks.mjs:503-508`, `:1140-1145`); uma superfície real: seção `defende` no **companion `.md`** + render mínimo no HTML. **Prova:** units de research (item vira defesa; sem fonte vira gap) + blocks (defends + ledger) + render (seção existe, lacuna aparece).
2. **Dossiê sóbrio — PDF/MD nas 3 variantes (quota ~1,5 dia).** `DOSSIER_PRINT_CSS` novo + folhas do mapeamento D (O essencial, leitura entre eras, trajetória em tabela, eras, defende, região/lacunas/notícias/acervo/fontes), remoção de gráficos/cartões/selos/assets; atualizar os specs de render das 3 variantes. **Prova:** `pnpm test:unit` + build local (snapshot/research existentes de Miguel Calmon; uma instituição e um tema) com guarda A4 verde e inspeção do PDF/MD; nenhuma ocorrência de `chart-grid`/`action-grid`/`scope-cards`/`NEEDS ASSET` no HTML.
3. **Índice + consulta rápida (quota ~0,5 dia).** `anchorPages` na 1ª passada, índice na folha `resumo` (cena 02) e sumário/âncoras no `.md`; era omitida marcada como no design. **Prova:** unit pinando o mapa (`resumo`=2, `trajetoria`=3/4, `era-c`=N) e build real conferindo número×folha no PDF.
4. **Boletim sóbrio + defende + fit (quota ~1,0 dia).** Cena 12 list-based nos 2 renders; `defenses` no `buildBulletin`; `onBulletinOverflow` nos 3 builders + `onDossierOverflow` alinhado; specs do boletim. **Prova:** unit (defesas fora dos caps/ranking, contagem fecha, sparse sem enchimento) + fixture com 2 defesas cabendo em 1 A4.
5. **Skills, redação e gates (quota ~1,0 dia).** 3 SKILLs + 3 agentes (`betweenEras`) + 3 specs de lote + `docs/changelog/2026-09-22-c209.md`; `pnpm gate:fast`; push por `pnpm push`.
**Rabbit hole declarado dentro do tracer:** se `report.defends` exigir mais que `kind` + `area` (ex. vínculo automático defesa×notícia por URL), para na derivação e deixa o vínculo textual no `details` — sem matching especulativo.
## Rabbit holes / Não escopo (engenharia)
- **Reescrever o paginador/emit** para "fazer TOC de verdade" (blocks de página, outline PDF): corte — o mapa da 2ª passada basta; `buildPdf.mjs:200-206` fica sem `outline`.
- **Fork de renderer por variante** ou renderer v2 com feature flag.
- **Gráfico novo / voltar chart** — `chartPrimitives.mjs` só permanece para `graficosInstagramRender.mjs:18`; nenhum consumo novo no dossiê.
- **Inferir defesa por keyword** de notícia ou de acervo (LLM palpite): posição só entra como item de pesquisa com fonte.
- **Migrar `research.json` antigos** (data/ gitignored): sem itens de defesa eles viram lacuna explícita; nenhuma migração/`--force`.
- **Tocar `src/`** (acervo/`Speech`, schema, migration), URL pública, kit de marca, C210 (briefing) e C163 (relatório de cidade).
- **Auditoria de design por variante:** um port, um artefato; sem hi-fi novo.
## Riscos e mitigação
- **Fit A4 com mais seções (índice + defende + leitura entre eras):** índice compacto (7–9 linhas) na folha `resumo`; guarda `assertPageFits` segue fail-closed (`buildPdf.mjs:191`); callbacks `onDossierOverflow`/`onBulletinOverflow` nos 3 builders; o corte é de copy/índice, nunca do layout.
- **Perda de informação ao remover gráficos:** os números permanecem em tabela/prosa (`moneyByPhase`, contagens por era/esfera nas eras e na leitura entre eras); spec novo pina que os valores não evaporam.
- **"Defende" virar opinião editorial:** `kind:'defense'` + `sourceUrl`/`sourceDate` obrigatórios (fail-closed no normalizer) + auditoria de redação por citação (a skill já a descreve); sem registro, lacuna explícita.
- **Boletim estourar 1 página com o bloco novo:** defesas ≤2, `brief` curto, shrink determinístico e contagem `factsRemaining` honesta.
- **CI vermelho por specs antigos:** atualização dos specs no mesmo PR (Decisão F); proibido afrouxar asserção para passar.
- **Conflito com C210 nas SKILLs/specs de lote:** rebase com as duas mudanças (conteúdo C209 + ponteiro C210); ordem de merge pela prioridade.
- **Deriva do design:** o artefato aprovado é fonte de verdade do port; divergência material volta ao humano antes de improvisar.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: 3 variantes + boletim sóbrios/list-based; "O essencial"; leitura entre eras; "O que Solla defende" com lastro; índice+âncoras; nada some; sem gráficos/cartões/selos/imagens; boletim 1 página sem CTA/fonte visível com bloco defende.
- [ ] Invariantes AGENTS/engineering-standards: sem `src/`, sem schema/migration/URL, artefato gitignored, defeso, sem marca de campanha, edita o dono (sem twin de engine/pipeline).
- [ ] Testes de domínio previstos (unit): research (defesa/lacuna), blocks (`defends`, `news.summary`, contagens), render das 3 variantes (âncoras, índice, ausência dos artefatos mortos, MD espelhado), boletim (defesas fora dos caps, sparse, contagem) e skills de lote (novo contrato); gates `pnpm gate:fast` verdes.
- [ ] Guarda A4 fail-closed preservada em dossiê e boletim; nenhum "…" e nenhum descarte silencioso.
## Débitos triados pós-simplify (não reabrir)
Triagem de `capture-review-debts` (2026-09-22, modo autônomo do `--auto`):
- **Registrado:** Issue **#1265** (`C209-DRY`, `depends: [1251]`, plano
`docs/plans/escala-dry-pos-c209.md`) — F1 orquestração compartilhada dos 3 builders
(loop probe→pack→measure→emit), F2 par `layout:'card'` morto no packer, F3 séries
mortas da síntese (`byYear`/`acervoByYear`/`moneyByYear`/`moneyProposals`).
- **Adiado com gatilho:** copy do boletim-município hardcoded no render (gatilho: a copy
mudar 2× ou surgir um 4º shape de boletim); builders instituição/tema sem `resumoOnly`
(gatilho: overflow não-resumo neles ou C189 tocar os builders).
- **Descartado:** `BULLETIN_MORE_LIMIT` (fallback defensivo), mutação de
`report.meta.pageTotal` (decisão travada do paginador), `factsPrintable` (contrato pinado),
reconstrução 2× do documento (Decisão A).
## Critérios de aceite visual (crítica do designer, trigger c)
Certificado pelo `designer` (tier primário) em 2026-09-22 com ajustes residuais aplicados
(célula "Tema canônico" sem token concatenado). `Design tier: designer` registrado no PR.
## Self-score (0–5, gate ≥4)
| Critério | Nota | Justificativa |
| ---------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Decisões caras com rejeitadas | 5 | A–F deliberam índice/defende/boletim/anatomia/CSS/specs com opções e rejeitadas nomeadas; o guarda-chuva (port vs fork) também. |
| Cabe no appetite (~4–5 dias) | 4 | 5 fases somam ~5 dias; cortes declarados (página no índice, `betweenEras`) e tracer cedo; o custo maior é o port do dossiê nas 3 variantes + o contrato do defende. |
| Rabbit holes nomeados | 5 | Paginador/outline, fork por variante, gráfico novo, inferência de defesa, migração de research e C210 fora. |
| Depth check (reusa shells/helpers) | 5 | Reusa `dossieUnit` (seam por variante), normalizer/checklist, pack/medição/emit e o ledger do boletim; não cria módulo pass-through; `chartPrimitives` preservado para o Instagram. |
| Intenção permanece satisfeita | 5 | O outcome de produto não foi reescrito; a engenharia só escolheu forma (mapa de páginas, `kind:'defense'`, bloco do boletim, CSS único). |
**Total 24/25 (média 4,8/5) — passa o gate ≥4/5.**

View file

@ -0,0 +1,64 @@
# Escala/DRY pós-C209 — orquestração repetida nos 3 builders + resíduos mortos
Status: rascunho
Atualizado em: 2026-09-22
Issue: #1251 (débito do C209; a Issue própria nasce `depends: [1251]`)
Priority: P3
Impeccable: A — só-backend (scripts), sem superfície visual
Appetite: ~0,5–1 dia eng (fill-in)
Responsável: —
## Intenção
O C209 (dossiês e boletins sóbrios) entregou o redesenho; a revisão estrutural/qualidade
deixou três resíduos maiores que o cleanup da sessão. Este lote existe para não deixá-los
apodrecendo no diff do C209: (1) o loop probe→pack→measure→emit está triplicado nos três
builders e o C209 acabou de duplicar também os callbacks de fit; (2) o par `layout: 'card'`
do packer/probe ficou inalcançável quando as ações viraram listas; (3) a síntese ainda
calcula e expõe quatro séries que só existiam para os gráficos consolidados, extintos no
redesenho.
## Fases
1. **F1 (S4) — orquestração compartilhada dos builders.** Extrair o loop
`probe → packProbeSections → measureDocumentSheets → adjustPackPlan → emitHtmlPairPdf`
para um helper único (ex. `scripts/lib/dossieBuild.mjs`) parametrizado pelos deltas reais:
fallback de índice (cidade), shrink de boletim (cidade/tema/instituição) e log. Opções:
A) helper com options; B) classe; C) manter os três loops. Recomendação: A — os deltas
são dados, não fluxos; rejeitadas B (cerimônia sem volatilidade) e C (o custo cresce a
cada builder novo). Prova: os 3 builders geram os mesmos PDFs/artefatos dos fixtures,
com o log `pack estável` idêntico.
2. **F2 (S3) — par `card` morto no packer.** Decidir entre aposentar o par `card` de
`measureDocumentPackProbe`/`dossiePack` (atualizar o spec do packer) ou documentá-lo
como contrato do packer (nenhuma unit emite hoje). Recomendação: aposentar e ajustar o
spec — o redesign fixou listas/tabelas; um grid de 2 colunas novo pede decisão de design.
3. **F3 (S1) — séries mortas da síntese.** Remover `byYear`, `acervoByYear` (do retorno),
`moneyByYear` e `moneyProposals` de `buildDossierSynthesis` (mantendo os usos internos de
`topYears`), ou re-consumi-las num consumidor real. Recomendação: remover — nenhum
consumidor após a morte dos gráficos; o spec da síntese pina só o que é lido.
## Já resolvido no simplify/critique (não reabrir)
- Índice do companion com âncoras reais (`mdAnchor`) e rodapés do PDF alinhados ao `.md`.
- Duplicação de honrarias (era + folha de títulos) no dossiê institucional.
- Região/polo e abrangência consolidadas em uma seção corrente (design cenas 06/09/10).
- Atribuição das ações da Câmara (`Alcance: mandato federal`), era omitida sem número de
seção, estado vazio do "E mais" do boletim, célula "Tema canônico" sem token concatenado.
## Explicitamente fora (descartes deste triage)
- **S2 `BULLETIN_MORE_LIMIT`:** fallback defensivo de uma unit futura sem
`bulletinMoreLimit`; remover deixaria `slice(0, NaN)` silencioso. Descartado.
- **S5 mutação de `report.meta.pageTotal` no render:** pré-existente em `main`, decisão
travada do paginador (2ª passada para o índice real). Descartado.
- **S7 `factsPrintable`:** contrato de contagem do boletim pinado por spec (precedente C188).
Descartado.
- **S9 documento reconstruído 2×:** Decisão A do C209 aceita a 2ª passada pelo índice com
página real. Descartado.
## Adiado com gatilho
- **S6 copy do boletim-município hardcoded no render:** gatilho — a copy municipal mudar
duas vezes ou surgir um quarto shape de boletim; aí mover para `MUNICIPALITY_UNIT.copy`.
- **S8 builders instituição/tema sem `resumoOnly`:** gatilho — overflow de página não-resumo
nesses dois builders ou quando o C189 tocar os builders.

View file

@ -27,7 +27,7 @@ import {
} from './lib/buildPdf.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { buildDossierReport } from './lib/dossieBlocks.mjs'
import { buildBulletin } from './lib/dossieBulletin.mjs'
import { buildBulletin, nextBulletinFit } from './lib/dossieBulletin.mjs'
import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
import { CAMARA_DEFAULT_FROM, fetchCamaraActivity } from './lib/dossieCamara.mjs'
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
@ -194,14 +194,23 @@ if (!narrative) {
}
/**
* Builds the dossiê + boletim for a given fit fallback and pack plan. The resumo
* page never cuts with "…": pass 1 prints the researcher's `summary` (or the
* full answer); if the resumo sheet overflows the A4 guard, pass 2 re-renders
* with `textFallback: 'pointer'` so the lines without a `summary` point to the
* era pages (which keep the integral). The other sections flow by measurement
* (`pack`), so they multiply sheets instead of being capped.
* Builds the dossiê + boletim for a given pack plan and fit fallback. The
* flowing sections multiply sheets by measurement (`pack`), so nothing is
* capped; if the resumo sheet overflows the A4 guard, the fallback re-renders
* with the index without page numbers (`indexMode: 'labels'`) before failing
* closed. The one-page boletim shrinks by printed facts (`printLimit`) and then
* by defenses (`defenseLimit`) until it fits — never truncated.
*/
const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } = {}) => {
/** Set by the dossier fit fallback; reused by every later rebuild. */
let dossierIndexMode = 'pages'
const buildArtifacts = ({
indexMode = dossierIndexMode,
pack = null,
probe = false,
printLimit = null,
defenseLimit = null,
} = {}) => {
const report = buildDossierReport({
snapshot,
research,
@ -209,7 +218,6 @@ const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } =
camara,
health,
generatedAt,
textFallback,
narrative,
})
const bulletin = buildBulletin({
@ -217,11 +225,13 @@ const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } =
municipality: snapshot.municipality.name,
region: snapshot.municipality.region ?? null,
generatedAt,
printLimit,
...(defenseLimit === null ? {} : { defenseLimit }),
})
return {
report,
bulletin,
dossierHtml: renderDossierHtml(report, { pack, probe }),
dossierHtml: renderDossierHtml(report, { pack, probe, indexMode }),
dossierMd: renderDossierMd(report),
bulletinHtml: renderBulletinHtml(bulletin),
}
@ -284,12 +294,20 @@ try {
bulletinHtml: artifacts.bulletinHtml,
dossierPdf: resolve(ROOT, dossierPdfFile),
bulletinPdf: resolve(ROOT, bulletinPdfFile),
// Budget-gated 2-pass (C188): if the resumo sheet with the full text
// overflows, rebuild with pointer lines (no "…") before failing closed.
// Budget-gated fallback (C188/C209): if the resumo sheet with the full index
// overflows, rebuild with the index without page numbers before failing
// closed. The boletim shrinks its printed facts and then its defenses.
onDossierOverflow: async () => {
artifacts = buildArtifacts({ textFallback: 'pointer', pack })
dossierIndexMode = 'labels'
artifacts = buildArtifacts({ pack })
return artifacts.dossierHtml
},
onBulletinOverflow: async () => {
const next = nextBulletinFit(artifacts.bulletin)
if (!next) return null
artifacts = buildArtifacts({ pack, ...next })
return artifacts.bulletinHtml
},
resumoOnly: true,
})
} catch (error) {
@ -312,7 +330,7 @@ console.log(`[${LABEL}] companion → ${dossierMdFile}`)
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} pontos=${report.synthesis.totals.items} defesas=${report.defends.positions.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`,
)

View file

@ -29,7 +29,7 @@ import {
} from './lib/buildPdf.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { buildDossierReport } from './lib/dossieBlocks.mjs'
import { buildBulletin } from './lib/dossieBulletin.mjs'
import { buildBulletin, nextBulletinFit } from './lib/dossieBulletin.mjs'
import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
@ -152,21 +152,23 @@ const report = buildDossierReport({
unit: INSTITUTION_UNIT,
narrative,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity: { name: report.meta.subjectName, badges: report.meta.identityBadges },
unit: INSTITUTION_UNIT,
generatedAt,
})
const buildInstitutionBulletin = ({ printLimit = null, defenseLimit = null } = {}) =>
buildBulletin({
facts: report.bulletinFacts,
identity: { name: report.meta.subjectName, badges: report.meta.identityBadges },
unit: INSTITUTION_UNIT,
generatedAt,
printLimit,
...(defenseLimit === null ? {} : { defenseLimit }),
})
let bulletin = buildInstitutionBulletin()
let bulletinHtml = renderBulletinHtml(bulletin)
const dossierMd = renderDossierMd(report)
const bulletinHtml = renderBulletinHtml(bulletin)
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
@ -222,13 +224,28 @@ try {
.join(' ')}`,
)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await emitHtmlPairPdf(browser, {
dossierHtml,
bulletinHtml,
dossierPdf: resolve(ROOT, dossierPdfFile),
bulletinPdf: resolve(ROOT, bulletinPdfFile),
// C209: same fit fallbacks as the other builders — index without page
// numbers for the dossiê, fewer printed facts/defenses for the boletim.
onDossierOverflow: async () => {
dossierHtml = renderDossierHtml(report, { pack, indexMode: 'labels' })
return dossierHtml
},
onBulletinOverflow: async () => {
const next = nextBulletinFit(bulletin)
if (!next) return null
bulletin = buildInstitutionBulletin(next)
bulletinHtml = renderBulletinHtml(bulletin)
return bulletinHtml
},
})
// The cache carries the final documents (after any fit fallback), not the first pass.
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
} finally {
@ -238,7 +255,7 @@ try {
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} pontos=${report.synthesis.totals.items} defesas=${report.defends.positions.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`,
)

View file

@ -29,7 +29,7 @@ import {
} from './lib/buildPdf.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { buildDossierReport } from './lib/dossieBlocks.mjs'
import { buildBulletin } from './lib/dossieBulletin.mjs'
import { buildBulletin, nextBulletinFit } from './lib/dossieBulletin.mjs'
import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
@ -154,13 +154,14 @@ const bulletinIdentity = {
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
}
const buildThemeBulletin = (printLimit = null) =>
const buildThemeBulletin = ({ printLimit = null, defenseLimit = null } = {}) =>
buildBulletin({
facts: report.bulletinFacts,
identity: bulletinIdentity,
unit: THEME_UNIT,
generatedAt,
printLimit,
...(defenseLimit === null ? {} : { defenseLimit }),
})
let bulletin = buildThemeBulletin()
@ -227,23 +228,29 @@ try {
.join(' ')}`,
)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await emitHtmlPairPdf(browser, {
dossierHtml,
bulletinHtml,
dossierPdf: resolve(ROOT, dossierPdfFile),
bulletinPdf: resolve(ROOT, bulletinPdfFile),
// One-page fit by measurement (C188/C190): while the boletim overflows,
// rebuild it with two fewer printed facts — the source panel and the facts
// beyond the cap stay counted in the "e mais N" line, never truncated.
// One-page fit by measurement (C188/C190/C209): while the boletim overflows,
// rebuild it with fewer printed facts and then fewer defenses — the source
// panel and the facts beyond the cap stay counted in the "e mais N" line,
// never truncated.
onDossierOverflow: async () => {
dossierHtml = renderDossierHtml(report, { pack, indexMode: 'labels' })
return dossierHtml
},
onBulletinOverflow: async () => {
const next = bulletin.factsPrinted - 2
if (bulletin.factsPrinted === 0 || next < 0) return null
const next = nextBulletinFit(bulletin)
if (!next) return null
bulletin = buildThemeBulletin(next)
bulletinHtml = renderBulletinHtml(bulletin)
return bulletinHtml
},
})
// The cache carries the final documents (after any fit fallback), not the first pass.
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
@ -254,7 +261,7 @@ try {
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} pontos=${report.synthesis.totals.items} defesas=${report.defends.positions.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`,
)

View file

@ -107,7 +107,7 @@ export const measureDocumentSheets = async (browser, html) => {
return await page.evaluate(() =>
[...document.querySelectorAll('[data-page]')].map((sheet) => {
const children = [...sheet.children].filter(
(child) => !child.classList.contains('report-footer'),
(child) => !child.classList.contains('running-footer'),
)
const used = children.reduce(
(max, child) =>
@ -127,15 +127,15 @@ export const measureDocumentSheets = async (browser, html) => {
}
/**
* Probe pass of the packed institution sections (C187 flowing sheets): each
* `[data-pack-section]` sheet carries every unit (`[data-pack-unit]` with
* `data-pack-layout` = tr|card); returns the fixed overhead and the row costs
* (a card row is a pair, its cost the taller card) the packer consumes.
* Probe pass of the packed flowing sections: each `[data-pack-section]` sheet
* carries every unit (`[data-pack-unit]` with `data-pack-layout`); returns the
* fixed overhead and the row costs (a `card` row is a pair, its cost the taller
* card) the packer consumes.
*
* The probe renders with `INSTITUTION_PROBE_CSS` (auto height, no gaps), so the
* overhead is the true fixed content: headers, section titles, the era method
* (tagged `data-pack-fixed="method"`, absent from continuation sheets) and the
* footer. The packer adds the card-row gap back.
* The probe renders with the renderer's probe CSS (auto height, no margins), so
* the overhead is the true fixed content: headers, section titles and the blocks
* tagged `data-pack-fixed` (absent from continuation sheets). The packer adds
* the row gap back.
*/
const PACK_CARD_ROW_GAP_PX = Math.round(3 * MM_TO_PX)
@ -342,13 +342,13 @@ export const emitHtmlPairPdf = async (
)
}
}
if (!resumoOnly) {
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'Página(s) do dossiê',
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
}
// The whole document is always asserted: the resumo-only fallback shortens
// the resumo, but every other fixed sheet must still fit the A4 (fail-closed).
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'Página(s) do dossiê',
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
await printA4Pdf(page, dossierPdf)
let currentBulletinHtml = bulletinHtml

View file

@ -9,36 +9,16 @@
*/
import { formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
import {
CAREER_TIMELINE,
DOSSIER_ERAS,
DOSSIER_SCOPE,
careerTimelineHighlights,
} from './dossieCareer.mjs'
import { CAREER_TIMELINE, DOSSIER_ERAS, DOSSIER_ERA_IDS, DOSSIER_SCOPE } from './dossieCareer.mjs'
import { dossierChecklistForEra } from './dossieResearch.mjs'
import {
INSTITUTION_UNIT,
MUNICIPALITY_UNIT,
isSubjectUnit,
resolveDossierUnit,
} from './dossieUnit.mjs'
import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs'
import { stripInlineSources } from './reportText.mjs'
/**
* Only the resumo ("one look") caps its lists; the full sections flow across as
* many sheets as they need (the builder packs by measured height) — C186/C187
* revision of 2026-09-18, same rule for both recortes.
*/
const MAX_DELIVERIES_PAGE_ONE = 3
const MAX_HOOKS_PAGE_ONE = 2
const MAX_PENDING_PAGE_ONE = 4
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereLabel = (sphere, unit = MUNICIPALITY_UNIT) =>
resolveDossierUnit(unit).sphereLabels?.[sphere] ?? sphere
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereBadgeClass = (sphere, unit = MUNICIPALITY_UNIT) =>
resolveDossierUnit(unit).sphereBadgeClass?.[sphere] ?? ''
const phaseLabels = {
autorizado: 'autorizado',
empenhado: 'empenhado',
@ -142,9 +122,14 @@ const healthContext = (health) =>
}))
: []
/**
* Câmara activity is national mandate context, not municipal evidence: the rows
* carry an explicit scope label and never claim a municipal sphere.
*/
const camaraItemsAsActions = (camara, era) =>
(camara?.status === 'ok' ? (camara.items ?? []) : []).map((item) => ({
sphere: 'municipio',
sphere: null,
scopeLabel: 'mandato federal',
year: item.year ? String(item.year) : null,
title: item.title,
detail: item.detail,
@ -157,25 +142,22 @@ const camaraItemsAsActions = (camara, era) =>
* @typedef {Object} DossierReport
* @property {any} meta
* @property {any} cover
* @property {any} page1
* @property {any[]} trajectory
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: any, actions: any, honors: any }>} eras
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, omitted?: boolean, method: string, recovery: string, numbers: any, actions: any, honors: any }>} eras
* @property {{ facts: Array<{ label: string, text: string }> }} essentials
* @property {{ bullets: Array<{ label: string|null, text: string }>, table: any[] }} betweenEras
* @property {{ positions: any[], gaps: any[] }} defends
* @property {{
* ruleTitle: string,
* ruleBody: string,
* municipal: { label: string, items: any[], total: number, remaining: number },
* regional: { label: string, items: any[], total: number, remaining: number },
* evidence: { items: any[], total: number, remaining: number },
* municipal: { label: string, items: any[], total: number },
* regional: { label: string, items: any[], total: number },
* context: any[],
* hook: any,
* priorityGap: any,
* }} region
* @property {{
* ruleTitle: string,
* ruleBody: string,
* lists: Array<{ key: string, label: string, sphere: string, items: any[], total: number, omitted: number }>,
* hook: any,
* priorityGap: any,
* }} reach
* @property {{ items: any[], total: number, omitted: number, recorte?: number, topics?: string[] }} acervo
* @property {{
@ -228,7 +210,6 @@ const buildMunicipalityReport = ({
camara = null,
health = null,
generatedAt = new Date(),
textFallback = 'full',
narrative = null,
}) => {
const municipality = snapshot.municipality ?? {}
@ -238,7 +219,10 @@ const buildMunicipalityReport = ({
? `Território de Identidade ${region} · recorte regional não somável ao município`
: 'Recorte regional não somável ao município'
const items = research.items ?? []
const allItems = research.items ?? []
/** C209: defense items are the "O que Solla defende" dimension — they never
* enter the evidence lists, the synthesis or the era tables. */
const items = allItems.filter((item) => item.kind !== 'defense')
const municipalItems = items.filter((item) => item.sphere === 'municipio')
const regionalItems = items.filter((item) => item.sphere !== 'municipio')
@ -247,7 +231,7 @@ const buildMunicipalityReport = ({
const researchActionsByEra = (era) =>
items
.filter((item) => item.era === era)
.filter((item) => item.era === era && item.id !== 'era_c_titulos')
.map((item) => ({
sphere: item.sphere,
year: item.numbers?.[0]?.year ?? null,
@ -275,7 +259,7 @@ const buildMunicipalityReport = ({
}))
: []
const actions = [...ownActions, ...extraActions]
if (numbers.length === 0 && actions.length === 0 && honors.length === 0) return null
const empty = numbers.length === 0 && actions.length === 0 && honors.length === 0
return {
...era,
method: eraMethod[era.id],
@ -294,46 +278,12 @@ const buildMunicipalityReport = ({
numbers,
actions,
honors,
empty,
/** C209: a city era without a single sourced item gets no sheet — it stays
* in the index/leitura with the explicit "sem evidência" note. */
omitted: empty,
}
}).filter(Boolean)
const deliveries = capList(
[...municipalItems]
.sort((left, right) => {
const leftEra = left.era === 'C' ? 0 : 1
const rightEra = right.era === 'C' ? 0 : 1
if (leftEra !== rightEra) return leftEra - rightEra
const leftNumber = left.numbers?.length ? 0 : 1
const rightNumber = right.numbers?.length ? 0 : 1
return leftNumber - rightNumber
})
.map((item) => {
const { text, fromSummary } = summarySurfaceText(item, textFallback)
return {
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: text,
detail: fromSummary ? item.details : textFallback === 'pointer' ? null : item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}
}),
MAX_DELIVERIES_PAGE_ONE,
)
const hooks = capList(
[...municipalItems, ...regionalItems].map((item) => ({
topic: item.area,
angle: summarySurfaceText(item, textFallback).text,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_HOOKS_PAGE_ONE,
)
})
const gaps = (research.gaps ?? []).map((gap) => ({
label: gap.label ?? gap.id,
@ -341,19 +291,7 @@ const buildMunicipalityReport = ({
nextStep: 'Apurar em fonte primária.',
}))
const pending = capList(
(research.gaps ?? []).map((gap) => gap.label ?? gap.id),
MAX_PENDING_PAGE_ONE,
)
const context = healthContext(health)
const regionItems = regionalItems.map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
}))
const speechFacts = speechFactsFromRows(snapshot, 'municipio')
const acervo = {
@ -381,14 +319,15 @@ const buildMunicipalityReport = ({
})
const bulletinFacts = [
...items
...allItems
.filter((item) => Boolean(item.sourceUrl))
.map((item) => ({
id: item.id,
era: item.era,
sphere: item.sphere,
area: item.area,
headline: stripInlineSources(summarySurfaceText(item).text),
defense: item.kind === 'defense',
headline: stripInlineSources(item.answer),
detail: item.details ? stripInlineSources(item.details) : null,
brief: item.brief
? {
@ -466,14 +405,27 @@ const buildMunicipalityReport = ({
scope: DOSSIER_SCOPE,
version: 'Documento de trabalho · versão 01',
},
page1: {
timeline: careerTimelineHighlights(),
deliveries,
hooks,
pending,
},
trajectory: CAREER_TIMELINE,
eras: eraSections,
essentials: buildEssentials({
items,
gaps: research.gaps ?? [],
synthesis,
unit: MUNICIPALITY_UNIT,
eras: eraSections,
}),
betweenEras: buildBetweenEras({
items,
gaps: research.gaps ?? [],
synthesis,
unit: MUNICIPALITY_UNIT,
narrative,
}),
defends: buildDefends({
items: allItems,
gaps: research.gaps ?? [],
defenseIds: defenseChecklistIds(MUNICIPALITY_UNIT),
}),
acervo,
synthesis,
opening,
@ -491,19 +443,15 @@ const buildMunicipalityReport = ({
items: regionalItems,
total: regionalItems.length,
},
items: regionItems,
context,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
acervo,
synthesis,
opening,
news: (research.news ?? []).map((row) => ({
era: row.era ?? null,
date: row.publishedAt,
outlet: row.outlet ?? '—',
title: row.title,
summary: row.summary ?? null,
url: row.url,
})),
limits: {
@ -529,28 +477,6 @@ const buildMunicipalityReport = ({
* the unit descriptor. *
* ------------------------------------------------------------------ */
/**
* The resumo sheet is the "one look": its lists stay capped and the note points
* to the full sections, which are never capped (they flow across sheets). The
* acervo is a sample of the internal base — a source panel, not a finding — so
* its count and year chart carry the whole volume.
*/
const MAX_INSTITUTION_DELIVERIES = 3
const MAX_INSTITUTION_TIMELINE = 3
const MAX_INSTITUTION_HOOKS = 2
const MAX_INSTITUTION_PENDING = 4
/**
* Wraps a capped list so the renderer can declare what stayed out ("e mais N")
* instead of dropping it silently — the C188 no-truncation rule, enforced at
* the owner for both recortes. `omitted` is the C187 renderer's name for the
* same count `capList` calls `remaining`.
*/
const capped = (list, max) => {
const { items, total, remaining } = capList(list, max)
return { items, total, omitted: remaining }
}
/**
* Institution lists are never capped: the dossiê flows across as many sheets
* as the content needs (the builder packs sheets by measured height). Same
@ -748,7 +674,7 @@ const buildDossierSynthesis = ({
.join(' · ')}.`,
)
lines.push(
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. ${unit.synthesisSumGuard ?? 'Setor e rede não são somados à instituição.'}`,
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. ${unit.synthesisSumGuard ?? 'recortes não são somados.'}`,
)
if (topEra) {
lines.push(
@ -878,6 +804,207 @@ const buildEraSummary = ({ era, items, money, scopeLabel, unit = INSTITUTION_UNI
return parts.join(' ')
}
/* ---------------------------------------------------------------- *
* C209 analysis layer: "O essencial", "Leitura entre eras" and the *
* "O que Solla defende" section. Everything below is a deterministic *
* reading of the sourced items/gaps — it introduces no fact. *
* ---------------------------------------------------------------- */
/** Ids of the `kind: 'defense'` checklist items of a unit (all three eras). */
const defenseChecklistIds = (unit) =>
new Set(
DOSSIER_ERA_IDS.flatMap((era) =>
dossierChecklistForEra(era, unit)
.filter((item) => item.kind === 'defense')
.map((item) => item.id),
),
)
/**
* Positions Solla stands for (sourced `kind: 'defense'` items) plus the defense
* checklist gaps — a missing source is an explicit "sem registro localizado",
* never an inferred position.
*/
const buildDefends = ({ items, gaps, defenseIds }) => ({
positions: items
.filter((item) => item.kind === 'defense')
.map((item) => ({
id: item.id,
era: item.era,
label: item.position ?? item.area,
reading: item.answer,
details: item.details,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
})),
gaps: gaps
.filter((gap) => defenseIds.has(gap.id))
.map((gap) => ({
id: gap.id,
label: gap.label ?? gap.id,
reason: gap.reason,
era: /^era_([abc])_/i.exec(gap.id ?? '')?.[1]?.toUpperCase() ?? null,
})),
})
/** The "O essencial" key facts — a short reading of the sourced set. */
const buildEssentials = ({ items, gaps, synthesis, unit, eras }) => {
const facts = []
if (items.length === 0) {
facts.push({
label: 'Nenhum ponto com fonte localizado:',
text: 'a leitura é a das lacunas explícitas — não completar por inferência.',
})
return { facts }
}
const omitted = eras.filter((era) => era.omitted).map((era) => era.id)
facts.push({
label: `${items.length} ${items.length === 1 ? 'ponto com fonte' : 'pontos com fonte'} no recorte:`,
text: `${synthesis.byEra.map((row) => `${row.count} na ${row.label}`).join(' · ')}.${
omitted.length
? ` A Era ${omitted.join('/')} não tem evidência nominal suficiente e é omitida como seção.`
: ''
}`,
})
facts.push({
label: 'Abrangência sem soma:',
text: `${synthesis.bySphere
.map((row) => `${row.count} ${row.label}`)
.join(' · ')}. ${unit.synthesisSumGuard ?? 'recortes não são somados.'}`,
})
if (synthesis.moneyByPhase.length) {
facts.push({
label: 'Valores localizados, sem consolidar fases:',
text: `${synthesis.moneyByPhase
.map((row) => `${formatMoneyCompact(row.amount)} ${row.label}`)
.join(' · ')}. Empenho não é pagamento.`,
})
}
facts.push({
label: `${gaps.length} ${gaps.length === 1 ? 'lacuna declarada' : 'lacunas declaradas'}:`,
text: gaps.length
? `prioridade: ${gaps[0].label ?? gaps[0].id}.`
: 'nenhuma lacuna declarada — confira a cobertura das eras.',
})
return { facts }
}
/**
* "Leitura entre eras": concentration, instruments, continuity, reach and the
* gaps that weigh. The narrative file may carry its own `betweenEras` bullets
* (array of strings); without them the reading is derived from the ledger.
*/
const buildBetweenEras = ({ items, gaps, synthesis, unit, narrative }) => {
const authored = Array.isArray(narrative?.betweenEras)
? narrative.betweenEras
.filter((text) => typeof text === 'string' && text.trim())
.map((text) => text.trim())
: []
const bullets = []
if (authored.length) {
bullets.push(...authored.map((text) => ({ label: null, text })))
} else if (items.length === 0) {
bullets.push({
label: 'Lacunas que pesam.',
text: 'Nenhum ponto com fonte — a leitura é a das lacunas explícitas.',
})
} else {
const top = [...synthesis.byEra].sort((left, right) => right.count - left.count)[0]
if (top) {
bullets.push({
label: 'Concentração.',
text: `A ${top.label} reúne ${top.count} de ${items.length} pontos com fonte (${Math.round(
(top.count / items.length) * 100,
)}%).`,
})
}
const areasByEra = synthesis.byEra
.map((row) => ({
era: row.key,
areas: toRows(
countBy(
items.filter((item) => item.era === row.key),
(item) => item.area,
),
),
}))
.filter((row) => row.areas.length)
if (areasByEra.length) {
bullets.push({
label: 'Instrumentos.',
text: `${areasByEra
.map(
(row) =>
`Na Era ${row.era}, ${row.areas
.slice(0, 3)
.map((area) => `${area.key} (${area.count})`)
.join(', ')}`,
)
.join('; ')}. São registros de natureza distinta; a leitura preserva essa diferença.`,
})
}
const areaEras = new Map()
for (const item of items) {
const set = areaEras.get(item.area) ?? new Set()
set.add(item.era)
areaEras.set(item.area, set)
}
const continuous = [...areaEras].filter(([, eras]) => eras.size > 1)
bullets.push({
label: 'Continuidade documentada.',
text: continuous.length
? `${continuous
.map(([area, eras]) => `${area} (${[...eras].sort().join(', ')})`)
.join(' · ')}.`
: 'Nenhuma área aparece em mais de uma era.',
})
bullets.push({
label: 'Alcance por era.',
text: `${synthesis.byEraSphere
.map(
(row) =>
`Era ${row.key}: ${
row.segments
.filter((segment) => segment.count > 0)
.map((segment) => `${segment.count} ${segment.label}`)
.join(', ') || 'sem registro'
}`,
)
.join(' · ')}.`,
})
if (gaps.length) {
bullets.push({
label: 'Lacunas que pesam.',
text: `${gaps.length} declaradas — prioridade: ${gaps[0].label ?? gaps[0].id}.`,
})
}
}
const table = synthesis.byEraSphere.map((row) => {
const present = row.segments.filter((segment) => segment.count > 0)
const direct = row.segments.find((segment) => segment.key === unit.defaultSphere)?.count ?? 0
const gapCount = synthesis.gapsByEra.find((gap) => gap.key === row.key)?.count ?? 0
return {
era: `${row.key} · ${DOSSIER_ERAS.find((era) => era.id === row.key)?.period ?? ''}`.trim(),
where: present.length
? present.map((segment) => `${segment.count} ${segment.label}`).join(' · ')
: 'Sem registro com fonte localizado',
howToCite: present.length
? 'Nomear objeto, fase e fonte'
: `Não atribuir ação ao ${unit.sphereLabels?.[unit.defaultSphere] ?? 'recorte'}`,
limit:
present.length === 0
? 'Lacuna explícita'
: direct === 0
? `Sem ${unit.sphereLabels?.[unit.defaultSphere] ?? 'recorte'} exclusivo localizado`
: `${gapCount} ${gapCount === 1 ? 'lacuna declarada' : 'lacunas declaradas'}`,
}
})
return { bullets, table }
}
const buildSubjectReport = (params) => {
const { snapshot, research, generatedAt = new Date(), narrative = null } = params
const unit = resolveDossierUnit(params.unit ?? INSTITUTION_UNIT)
@ -885,8 +1012,10 @@ const buildSubjectReport = (params) => {
const subjectName = identity.name ?? identity.label ?? identity.slug ?? '—'
const speechFacts = speechFactsFromRows(snapshot, unit.defaultSphere)
const items = research.items ?? []
const directItems = items.filter((item) => item.sphere === unit.defaultSphere)
const allItems = research.items ?? []
/** C209: defense items are the "O que Solla defende" dimension — they never
* enter the evidence lists, the synthesis or the era tables. */
const items = allItems.filter((item) => item.kind !== 'defense')
/** Honors live in their own sheet; they are not repeated as era action cards. */
const researchActionsByEra = (era) =>
@ -941,37 +1070,6 @@ const buildSubjectReport = (params) => {
}
})
const deliveryList = [...directItems]
.sort((left, right) => {
const leftEra = left.era === 'C' ? 0 : 1
const rightEra = right.era === 'C' ? 0 : 1
if (leftEra !== rightEra) return leftEra - rightEra
const leftNumber = left.numbers?.length ? 0 : 1
const rightNumber = right.numbers?.length ? 0 : 1
return leftNumber - rightNumber
})
.map((item) => ({
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
const deliveries = capped(deliveryList, MAX_INSTITUTION_DELIVERIES)
const hookList = items.map((item) => ({
topic: item.area,
angle: item.answer,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
}))
const hooks = capped(hookList, MAX_INSTITUTION_HOOKS)
const acervo = {
items: speechFacts.map((fact) => ({
id: fact.id,
@ -987,27 +1085,12 @@ const buildSubjectReport = (params) => {
topics: snapshot.speeches?.topics ?? [],
}
const timelineList = items
.filter((item) => item.sourceUrl)
.map((item) => ({
period: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? '—',
role: item.answer,
brief: item.brief ?? null,
source: item.label,
url: item.sourceUrl,
}))
.sort((left, right) => String(left.period).localeCompare(String(right.period)))
const timeline = capped(timelineList, MAX_INSTITUTION_TIMELINE)
const gaps = (research.gaps ?? []).map((gap) => ({
label: gap.label ?? gap.id,
reason: gap.reason,
nextStep: 'Apurar em fonte primária.',
era: gapEraLabel(gap.id),
}))
const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id)
const pending = capped(pendingList, MAX_INSTITUTION_PENDING)
const scopeItem = (sphere, key, label) => {
const sphereItems = items.filter((item) => item.sphere === sphere)
return {
@ -1020,7 +1103,9 @@ const buildSubjectReport = (params) => {
era: item.era,
sphere: item.sphere,
area: item.area,
year: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? null,
year:
item.numbers?.[0]?.year ??
(item.sourceDate ? String(item.sourceDate).slice(0, 4) : null),
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
title: item.answer,
@ -1046,13 +1131,14 @@ const buildSubjectReport = (params) => {
unit,
})
const bulletinFacts = items
const bulletinFacts = allItems
.filter((item) => Boolean(item.sourceUrl))
.map((item) => ({
id: item.id,
era: item.era,
sphere: item.sphere,
area: item.area,
defense: item.kind === 'defense',
headline: stripInlineSources(item.answer),
detail: item.details ? stripInlineSources(item.details) : null,
brief: item.brief
@ -1121,26 +1207,41 @@ const buildSubjectReport = (params) => {
scope: unit.copy.coverScope,
version: 'Documento de trabalho · versão 01',
},
page1: { timeline, deliveries, hooks, pending },
trajectory: CAREER_TIMELINE,
eras: eraSections,
essentials: buildEssentials({
items,
gaps: research.gaps ?? [],
synthesis,
unit,
eras: eraSections,
}),
betweenEras: buildBetweenEras({
items,
gaps: research.gaps ?? [],
synthesis,
unit,
narrative,
}),
defends: buildDefends({
items: allItems,
gaps: research.gaps ?? [],
defenseIds: defenseChecklistIds(unit),
}),
acervo,
synthesis,
reach: {
ruleTitle: unit.copy.reachRuleTitle,
ruleBody: unit.copy.reachRuleBody,
lists: scopeLists,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
acervo,
synthesis,
opening,
news: (research.news ?? []).map((row) => ({
era: row.era ?? null,
date: row.publishedAt,
outlet: row.outlet ?? '—',
title: row.title,
summary: row.summary ?? null,
url: row.url,
})),
limits: {

View file

@ -2,16 +2,20 @@
* Boletim model (owner: C186; unit seam C187): one A4 page, voter-facing. It
* reads ONLY the `bulletinFacts` ledger produced by `buildDossierReport` — every
* fact there already carries a source, so the boletim cannot introduce a new
* fact. No sources are printed (they live in the dossiê).
* fact. No sources are printed (they live in the dossiê). C209: the ledger also
* carries the `defense` facts ("O que Solla defende"), printed outside the
* highlight ranking and counted in the "e mais N" line.
*/
import { formatDateTimeBr } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs'
import { dossierPhaseLabel } from './dossieBlocks.mjs'
import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs'
import { resolveDossierUnit } from './dossieUnit.mjs'
export const BULLETIN_HIGHLIGHT_LIMIT = 6
const BULLETIN_MORE_LIMIT = 14
/** "O que Solla defende" budget of the one-page boletim (design cena 12). */
export const BULLETIN_DEFENSE_LIMIT = 2
const eraRank = { C: 0, B: 1, A: 2 }
@ -30,13 +34,14 @@ const rankFacts = (facts, unit) => {
/**
* @param {{
* facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string, sourcePanel?: boolean }>,
* facts?: Array<{ id: string, era: string, sphere: string, area: string, defense?: boolean, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string, sourcePanel?: boolean }>,
* municipality?: string,
* region?: string | null,
* identity?: { name: string, badges?: string[] } | null,
* unit?: any,
* generatedAt?: Date,
* printLimit?: number | null,
* defenseLimit?: number,
* }} params
*/
export const buildBulletin = ({
@ -47,12 +52,15 @@ export const buildBulletin = ({
unit,
generatedAt = new Date(),
printLimit = null,
defenseLimit = BULLETIN_DEFENSE_LIMIT,
}) => {
const resolvedUnit = resolveDossierUnit(unit)
const ordered = rankFacts(facts, resolvedUnit)
// The acervo sample is a source panel, not a finding (C190): it is counted in
// the one-pager but never takes a printed slot from a sourced finding.
const printable = ordered.filter((fact) => !fact.sourcePanel)
// the one-pager but never takes a printed slot from a sourced finding. The
// defenses (C209) are their own block, outside the highlight ranking.
const printable = ordered.filter((fact) => !fact.sourcePanel && !fact.defense)
const defenseFacts = ordered.filter((fact) => fact.defense)
const target =
printLimit === null
? BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT)
@ -61,17 +69,14 @@ export const buildBulletin = ({
const subjectName = municipality ?? identity?.name ?? '—'
const highlights = printed.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => {
// Theme-only fallback: a fact without money value shows its year; without
// either, the card is textual and prints no number slot at all (never zero).
// either, the line prints no number at all (never a fake zero).
const number = resolvedUnit.bulletinNumberFallback
? (fact.value ?? fact.year ?? null)
: fact.value
return {
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
number,
...(resolvedUnit.bulletinNumberFallback && !number ? { textual: true } : {}),
title: fact.brief?.title ?? fact.headline,
note: fact.brief?.note ?? fact.detail ?? null,
phase: fact.value ? (fact.phase ?? null) : null,
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
}
})
@ -79,34 +84,58 @@ export const buildBulletin = ({
label: fact.brief?.title ?? fact.headline,
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
}))
const shown = highlights.length + moreItems.length
const defenses = defenseFacts.slice(0, Math.max(0, defenseLimit)).map((fact) => ({
label: fact.brief?.title ?? fact.headline,
text: fact.brief?.note ?? fact.detail ?? fact.headline,
}))
const shown = highlights.length + moreItems.length + defenses.length
return {
unit: resolvedUnit,
sparse: printable.length < 3,
meta: {
title: resolvedUnit.bulletinTitle,
kicker: resolvedUnit.bulletinKicker ?? 'Boletim informativo · atuação pública',
kicker:
resolvedUnit.bulletinKicker ??
(municipality
? `Boletim informativo · ${subjectName}`
: 'Boletim informativo · atuação pública'),
subjectName,
municipality: subjectName,
region,
identity,
identityBadges: identity?.badges ?? [],
identityPills:
resolvedUnit.bulletinPills && identity ? resolvedUnit.bulletinPills(identity) : null,
generatedAt,
generatedAtLabel: formatDateTimeBr(generatedAt),
modelLabel: 'Modelo — insumo interno',
},
highlights,
defenses,
moreItems,
// Never a silent drop (C188): the facts beyond the two caps — and the whole
// Never a silent drop (C188): the facts beyond the caps — and the whole
// source panel — are counted so the one-page boletim states how much of the
// sourced set it left out.
factsTotal: ordered.length,
factsPrintable: printable.length,
factsPrintable: printable.length + defenseFacts.length,
factsPrinted: shown,
factsRemaining: ordered.length - shown,
timeline: BULLETIN_TIMELINE_STEPS,
}
}
/**
* One shrink step of the one-page fit (C190/C209): reduce the printed highlights
* first (two at a time), then the defenses (2 → 1 → 0). Returns the options for
* the next `buildBulletin`, or null when there is nothing left to cut — the
* builder then fails closed instead of truncating.
*
* @param {any} bulletin
* @returns {{ printLimit?: number, defenseLimit?: number } | null}
*/
export const nextBulletinFit = (bulletin) => {
const printedFacts = bulletin.highlights.length + bulletin.moreItems.length
if (printedFacts > 0) return { printLimit: Math.max(0, printedFacts - 2) }
if (bulletin.defenses.length > 1) return { printLimit: 0, defenseLimit: 1 }
if (bulletin.defenses.length > 0) return { printLimit: 0, defenseLimit: 0 }
return null
}

View file

@ -1,396 +1,209 @@
/**
* Boletim renderer (C186): one A4 `.sheet` in voter-facing language. Ports the
* approved hi-fi classes (`dossie-solla-cidade-boletim-ui-design.html`). No
* source list — the facts come from the dossiê ledger, and the page is labelled
* as an internal model.
* Boletim renderer (C186; sober redesign C209): one A4 `.sheet` in voter-facing
* language. Ports the approved hi-fi classes
* (`docs/plans/dossies-sobrios-analiticos-ui-design.html`, cena 12) — list-based,
* no cards, badges or image placeholders. No source list: the facts come from
* the dossiê ledger, and the page is labelled as an internal model.
*/
import { isSubjectUnit } from './dossieUnit.mjs'
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
const ASSET = (title, lines) =>
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
.map((line) => htmlEscape(line))
.join('<br />')}</p></div>`
const phaseModifier = (phase) =>
phase === 'pago' || phase === 'liquidado'
? 'phase-paid'
: phase === 'empenhado' || phase === 'autorizado'
? 'phase-pending'
: ''
const renderHighlight = (highlight, { showPhase = false } = {}) => `
<article class="highlight-card${highlight.textual ? ' highlight-card--textual' : ''}">
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
${!highlight.textual && highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
${
showPhase && highlight.number && highlight.phaseLabel
? `<p class="highlight-phase"><span class="phase ${phaseModifier(highlight.phase)}">${htmlEscape(highlight.phaseLabel)}</span></p>`
: ''
}
<h3 class="highlight-title">${htmlEscape(highlight.title)}</h3>
${highlight.note ? `<p class="highlight-note">${htmlEscape(highlight.note)}</p>` : ''}
</article>`
const renderMoreItem = (item) =>
`<p class="more-item"><strong>${htmlEscape(item.label)}</strong></p>`
const PRINT_CSS = `
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: #172033; background: #fff; }
body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: var(--ink); background: #fff; }
:root {
--ink: #18232d;
--ink-soft: #52616d;
--accent: #4d6d7c;
--line: #bdc8ce;
--line-light: #dce3e6;
}
@page { size: A4; margin: 0; }
.sheet {
position: relative;
display: flex;
width: 210mm;
height: 297mm;
min-height: 297mm;
padding: 8mm 10mm 7mm 12mm;
display: flex;
flex-direction: column;
padding: 15mm 17mm 13mm;
overflow: hidden;
flex-direction: column;
background: #fff;
font-size: 10.25pt;
line-height: 1.26;
font-size: 9.4pt;
line-height: 1.42;
}
.sheet::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3.5mm; background: #315c75; }
.sheet p, .sheet h1, .sheet h2, .sheet h3, .sheet ul { margin-top: 0; }
.tabular { font-variant-numeric: tabular-nums; }
.model-label { display: inline-flex; align-items: center; min-height: 6mm; padding: 1mm 2.5mm; border: .35mm solid #8a5a18; border-radius: 99px; color: #68420f; background: #fff7e8; font-size: 7.3pt; line-height: 1; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.kicker, .section-label { color: #315c75; font-size: 7.4pt; line-height: 1.1; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.section-label { margin-bottom: 1.2mm; }
.asset-box { border: .4mm dashed #748894; color: #40535f; background: #f7f9fa; padding: 3mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.asset-box p { margin: 0; }
.asset-box .asset-title { font-size: 7.5pt; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.asset-box p + p { margin-top: 1mm; font-size: 7pt; line-height: 1.25; }
.bulletin-head { display: flex; align-items: center; justify-content: space-between; gap: 6mm; border-bottom: .55mm solid #172033; padding-bottom: 2.5mm; }
.bulletin-head .kicker { margin-bottom: 1mm; }
.bulletin-head .place { margin: 0; font-size: 10pt; font-weight: 700; color: #435264; }
.bulletin-head .note { margin: 0; max-width: 44mm; text-align: right; font-size: 6.8pt; font-weight: 600; line-height: 1.2; color: #8a5a18; }
.bulletin-head .right { display: flex; align-items: center; gap: 3mm; }
.opening { margin-top: 3.5mm; display: grid; grid-template-columns: 1fr 46mm; gap: 5mm; }
.opening h1 { margin: 0; font-size: 23pt; font-weight: 600; line-height: 1; letter-spacing: -.038em; }
.opening h1 .accent { color: #315c75; }
.opening .lead { margin: 2.6mm 0 0; max-width: 125mm; font-size: 10.5pt; line-height: 1.33; color: #435264; }
.opening .asset-box { min-height: 36mm; border-radius: .125rem; }
.highlights { margin-top: 3.8mm; }
.highlights-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; border-bottom: .3mm solid #cbd5dc; padding-bottom: 1.5mm; }
.highlights-head h2 { margin: 0; font-size: 14pt; font-weight: 600; line-height: 1; letter-spacing: -.015em; }
.highlights-head .side { margin: 0; text-align: right; font-size: 7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: #435264; }
.highlight-grid { margin-top: 2.5mm; display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.2mm; }
.highlight-card { min-height: 31mm; padding: 2.8mm 3mm 2.5mm; border: .3mm solid #cbd5dc; border-top: 1.15mm solid #315c75; background: #fff; }
.highlight-eyebrow { margin-bottom: 1.5mm; color: #435264; font-size: 6.7pt; line-height: 1; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.highlight-number { margin: 0; color: #315c75; font-size: 18.5pt; line-height: .96; font-weight: 800; letter-spacing: -.035em; }
.highlight-title { margin: 1.5mm 0 0; font-size: 10.8pt; line-height: 1.08; font-weight: 800; letter-spacing: -.01em; }
.highlight-note { margin: 1.3mm 0 0; color: #435264; font-size: 8.15pt; line-height: 1.22; }
.bulletin-timeline { margin-top: 3.5mm; border-radius: .125rem; background: #e8f0f4; padding: 2.8mm 3mm 2.6mm; }
.bulletin-timeline .timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 4mm; }
.bulletin-timeline h2 { margin: 0; font-size: 11pt; font-weight: 600; }
.bulletin-timeline .timeline-side { margin: 0; font-size: 6.7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
.timeline-grid { margin-top: 3mm; display: grid; grid-template-columns: repeat(4, 1fr); }
.timeline-step { position: relative; padding: 4.5mm 2.5mm 1.5mm; border-top: .4mm solid #97aab6; }
.timeline-step::before { content: ''; position: absolute; top: -1.6mm; left: 2.5mm; width: 3mm; height: 3mm; border: .65mm solid #e8f0f4; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .3mm #315c75; }
.timeline-period { margin: 0 0 .7mm; font-size: 7.2pt; font-weight: 800; color: #315c75; }
.timeline-text { margin: 0; font-size: 8.4pt; font-weight: 600; line-height: 1.18; }
.more { margin-top: 3.5mm; display: grid; grid-template-columns: 1fr 43mm; gap: 4mm; }
.more-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; border-bottom: .3mm solid #cbd5dc; padding-bottom: 1.4mm; }
.more-head h2 { margin: 0; font-size: 12.5pt; font-weight: 600; line-height: 1; }
.more-head .side { margin: 0; font-size: 6.7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: #435264; }
.more-grid { margin-top: 2.3mm; display: grid; grid-template-columns: 1fr 1fr; gap: 1.7mm 4mm; }
.more-item { position: relative; margin: 0; min-height: 11mm; padding: 0 0 2mm 5mm; border-bottom: .25mm solid #cbd5dc; font-size: 9pt; line-height: 1.22; }
.more-item::before { content: '✓'; position: absolute; left: 0; top: 0; color: #315c75; font-weight: 900; }
.more-counter { margin-top: 1.8mm; color: #435264; font-size: 7.6pt; font-weight: 600; }
.more-aside { display: grid; grid-template-rows: 1fr 1fr; gap: 2.3mm; }
.more-aside .asset-box { border-radius: .125rem; }
.empty { margin-top: 3mm; color: #435264; font-size: 9.5pt; }
.empty-box { margin-top: 2.3mm; min-height: 40mm; border: .4mm dashed #748894; background: #f7f9fa; color: #40535f; padding: 4mm; display: flex; flex-direction: column; justify-content: center; gap: 1.5mm; }
.empty-box p { margin: 0; font-size: 9.5pt; line-height: 1.3; }
.empty-box .uppercase { text-transform: uppercase; letter-spacing: .06em; }
.bulletin-foot { margin-top: auto; border-top: .35mm solid #cbd5dc; padding-top: 2.2mm; }
.bulletin-foot .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 5mm; }
.bulletin-foot .orig { margin: 0 0 .8mm; font-size: 7.2pt; font-weight: 600; line-height: 1.25; color: #315c75; }
.bulletin-foot .defeso { margin: 0; max-width: 142mm; font-size: 7.4pt; line-height: 1.28; color: #435264; }
.bulletin-foot .defeso strong { color: #8a5a18; }
.bulletin-foot .stamp { margin: 0; text-align: right; font-size: 7.2pt; font-weight: 600; line-height: 1.3; color: #435264; }
.sheet p, .sheet ul, .sheet ol, .sheet h1, .sheet h2 { margin-top: 0; }
.running-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10mm; padding-bottom: 3mm; border-bottom: .35mm solid var(--ink); }
.running-kicker { margin: 0; color: var(--accent); font-size: 7.5pt; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; }
.running-subtitle { margin: 1mm 0 0; color: var(--ink-soft); font-size: 8.7pt; }
.running-meta { min-width: 41mm; margin: 0; color: var(--ink-soft); font-size: 7.3pt; line-height: 1.45; text-align: right; }
.running-meta p { margin: 0; }
.running-footer { display: flex; margin-top: auto; padding-top: 2.6mm; border-top: .25mm solid var(--line); align-items: flex-end; justify-content: space-between; gap: 8mm; color: var(--ink-soft); font-size: 7.2pt; line-height: 1.3; }
.running-footer strong { color: var(--ink); }
.folio { color: var(--ink); font-weight: 700; white-space: nowrap; }
.section { margin-top: 5mm; }
.section + .section { margin-top: 6mm; }
.eyebrow { margin: 0 0 1.3mm; color: var(--accent); font-size: 7.2pt; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.bulletin-title { max-width: 162mm; margin: 12mm 0 0; font-size: 28pt; font-weight: 650; line-height: 1.02; letter-spacing: -.035em; }
.bulletin-title span { color: var(--accent); }
.bulletin-lede { max-width: 155mm; margin: 4mm 0 0; color: var(--ink-soft); font-size: 10.2pt; line-height: 1.45; }
.bulletin-list { margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); list-style: none; }
.bulletin-list li { display: grid; padding: 2.6mm 0; border-bottom: .2mm solid var(--line-light); grid-template-columns: 38mm 1fr; gap: 4mm; }
.bulletin-list strong { font-size: 8.6pt; }
.bulletin-list span { color: var(--ink-soft); font-size: 8.6pt; }
.bulletin-defense-list { margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); list-style: none; }
.bulletin-defense-list li { display: grid; padding: 2.4mm 0; border-bottom: .2mm solid var(--line-light); grid-template-columns: 52mm 1fr; gap: 4mm; }
.bulletin-defense-list strong { font-size: 8.7pt; }
.bulletin-defense-list span { color: var(--ink-soft); font-size: 8.5pt; line-height: 1.35; }
.period-list { display: grid; margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); grid-template-columns: 1fr 1fr; list-style: none; }
.period-list li { min-height: 18mm; padding: 2.6mm 4mm 2.6mm 0; border-bottom: .2mm solid var(--line-light); }
.period-list li:nth-child(odd) { border-right: .2mm solid var(--line-light); }
.period-list li:nth-child(even) { padding-left: 4mm; }
.period-list strong { display: block; color: var(--accent); font-size: 7.6pt; }
.period-list span { display: block; margin-top: 1mm; font-size: 8.4pt; line-height: 1.33; }
.plain-list { margin: 3mm 0 0; padding: 0; list-style: none; }
.plain-list li { position: relative; padding: 2.2mm 0 2.2mm 6mm; border-top: .2mm solid var(--line-light); }
.plain-list li:last-child { border-bottom: .2mm solid var(--line-light); }
.plain-list li::before { position: absolute; top: 3.3mm; left: 0; width: 2.2mm; height: .45mm; background: var(--accent); content: ''; }
.table-note { margin: 1.7mm 0 0; color: var(--ink-soft); font-size: 7.6pt; }
.note-line { margin-top: 3mm; padding: 2mm 0; border-top: .25mm solid var(--line); border-bottom: .25mm solid var(--line); color: var(--ink-soft); font-size: 8.2pt; }
.note-line strong { color: var(--ink); }
`
const renderMunicipalityBulletinHtml = (bulletin) => `
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)}</title><style>${PRINT_CSS}</style></head>
<body>
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo de uma página">
<header class="bulletin-head">
<div>
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
<p class="place">Município de ${htmlEscape(bulletin.meta.municipality)}${bulletin.meta.region ? ` · ${htmlEscape(bulletin.meta.region)}` : ' · Bahia'}</p>
</div>
<div class="right">
<p class="note">Síntese do dossiê da cidade — conferir antes de publicar.</p>
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
</div>
</header>
<section class="opening" aria-label="Abertura do boletim">
<div>
<p class="section-label">Trabalho que chega à cidade e à região</p>
<h1>O que Jorge Solla fez por <span class="accent">${htmlEscape(bulletin.meta.municipality)}</span></h1>
<p class="lead">Da gestão da saúde ao trabalho na Câmara: recursos, serviços, obras e equipamentos que melhoram a vida de quem mora aqui.</p>
</div>
${ASSET('NEEDS ASSET · foto real', ['Solla na cidade ou no serviço citado', 'origem, autoria e licença'])}
</section>
<section class="highlights" aria-label="Destaques">
<div class="highlights-head">
<div>
<p class="section-label">Destaques</p>
<h2>Entregas que fazem diferença</h2>
</div>
<p class="side">cidade ≠ região · cada alcance vem identificado</p>
</div>
${
bulletin.highlights.length
? `<div class="highlight-grid">${bulletin.highlights.map(renderHighlight).join('')}</div>`
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
}
</section>
<section class="bulletin-timeline" aria-label="Trajetória">
<div class="timeline-head">
<h2>Desde 1999, uma trajetória na saúde pública</h2>
<p class="timeline-side">experiência que virou trabalho pela Bahia</p>
</div>
<div class="timeline-grid">
${bulletin.timeline
.map(
(step) => `<div class="timeline-step">
<p class="timeline-period">${htmlEscape(step.period)}</p>
<p class="timeline-text">${htmlEscape(step.text)}</p>
</div>`,
)
.join('')}
</div>
</section>
<section class="more" aria-label="Outras ações">
<div>
<div class="more-head">
<div>
<p class="section-label">E mais</p>
<h2>Outras ações que entram no boletim</h2>
</div>
<p class="side">frases curtas · fatos confirmados</p>
</div>
${
bulletin.moreItems.length
? `<div class="more-grid">${bulletin.moreItems.map(renderMoreItem).join('')}</div>`
: '<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>Conferir as lacunas explícitas no dossiê da cidade antes de ampliar o boletim.</p></div>'
}
${
bulletin.factsRemaining > 0
? `<p class="more-counter">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} no dossiê da cidade — o boletim de uma página não os exibe.</p>`
: ''
}
</div>
<aside class="more-aside" aria-label="Ativos visuais pendentes">
${ASSET('NEEDS ASSET · selo', ['marca institucional aprovada', 'arquivo vetorial'])}
${ASSET('NEEDS ASSET · recorte', ['imagem real de cobertura', 'origem e licença'])}
</aside>
</section>
<footer class="bulletin-foot">
<div class="row">
<div>
<p class="orig">Fatos selecionados do dossiê da cidade.</p>
<p class="defeso"><strong>Defeso eleitoral:</strong> material informativo em preparação. Não publicar sem revisão editorial e jurídica.</p>
</div>
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
</div>
</footer>
</article>
</body>
</html>`
/* Institution boletim (C187) — ports the institutional boletim hi-fi. */
const SUBJECT_BULLETIN_CSS = `
${PRINT_CSS}
.defeso-band { margin-inline: -10mm; padding: 1.4mm 10mm 1.35mm; display: flex; align-items: center; justify-content: space-between; gap: 5mm; color: #68420f; background: #fff7e8; border-top: .25mm solid #e7cc9d; border-bottom: .25mm solid #e7cc9d; font-size: 7.15pt; line-height: 1.2; font-weight: 700; }
.identity-pill { display: inline-flex; align-items: center; min-height: 5mm; padding: .55mm 1.7mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 6.9pt; line-height: 1; font-weight: 700; letter-spacing: .045em; text-transform: uppercase; }
.identity-pill--code { font-family: ui-monospace, 'DejaVu Sans Mono', monospace; letter-spacing: .02em; }
.bulletin-head .subject { margin: 0; font-size: 11pt; font-weight: 800; }
.bulletin-head .pills { margin-top: 1.3mm; display: flex; flex-wrap: wrap; gap: 1.2mm; }
.lacuna-panel { border: .4mm dashed #b18549; color: #684819; background: #fff7e8; }
.lacuna-panel .lacuna-rule { margin-top: 2mm; border-top: .25mm solid #d8bb8d; padding-top: 2mm; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; line-height: 1.35; letter-spacing: .055em; }
.production-note { border: .25mm solid #cbd5dc; background: #f3f6f8; border-radius: .125rem; padding: 3mm; }
.opening { margin-top: 2.5mm; }
.highlights { margin-top: 3mm; }
.bulletin-timeline { margin-top: 2.5mm; }
.more { margin-top: 2.5mm; }
.empty-box { min-height: 30mm; }
.more-counter { margin-top: 1.8mm; color: #435264; font-size: 7.6pt; font-weight: 600; }
.lacuna-panel .warn-icon { width: 5mm; height: 5mm; flex-shrink: 0; margin-top: .4mm; color: #8a5a18; }
.highlight-phase { margin: 1.2mm 0 0; }
.highlight-card--textual { border-top-color: #435264; }
.highlight-card--textual .highlight-title { margin-top: 0; font-size: 11.6pt; }
.phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.3pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; }
.phase-paid { color: #285338; background: #e6f2e9; }
.phase-pending { color: #6b4918; background: #fff2d9; }
`
const INST_WARNING_ICON =
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
const renderSubjectBulletinHtml = (bulletin) => {
const sparse = Boolean(bulletin.sparse)
const copy = bulletin.unit.copy.bulletin
// Theme pills come from the descriptor (`Área` + canonical token + taxonomy);
// institution keeps the identity badges untouched.
const pillList = bulletin.meta.identityPills
? bulletin.meta.identityPills.filter((pill) => Boolean(pill.label))
: (bulletin.meta.identityBadges ?? []).map((badge) => ({ label: badge }))
const pills = pillList
.map(
(pill) =>
`<span class="identity-pill${pill.code ? ' identity-pill--code' : ''}">${htmlEscape(pill.label)}</span>`,
const highlightText = (highlight) => {
const parts = []
if (highlight.note) parts.push(highlight.note)
if (highlight.number) {
parts.push(
highlight.phaseLabel ? `${highlight.number} · ${highlight.phaseLabel}` : highlight.number,
)
.join('')
const highlights = bulletin.highlights
const more = bulletin.moreItems
}
return parts.join(' — ')
}
/** One sober composition for both shapes; the copy deltas come from the unit. */
export const renderBulletinHtml = (bulletin) => {
const sparse = Boolean(bulletin.sparse)
const subject = isSubjectUnit(bulletin.unit)
const copy = bulletin.unit.copy?.bulletin ?? {}
const subjectName = bulletin.meta.subjectName
const title = subject
? (() => {
const escaped = htmlEscape(copy.openingTitle(subjectName))
const name = htmlEscape(subjectName)
return escaped.includes(name) ? escaped.replace(name, `<span>${name}</span>`) : escaped
})()
: `O que Jorge Solla fez por <span>${htmlEscape(subjectName)}</span>`
const lede = subject
? sparse
? copy.leadSparse
: copy.lead
: 'Da gestão da saúde ao mandato federal: ações no município e iniciativas para a região, apresentadas com o alcance de cada uma.'
const remaining = subject
? copy.remaining
: 'no dossiê da cidade — o boletim de uma página não os exibe.'
const sparseRule = subject
? copy.sparseRule
: 'Não repetir item, não ampliar efeito e não preencher espaço com fato sem lastro.'
return `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${SUBJECT_BULLETIN_CSS}</style></head>
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(subjectName)}</title><style>${PRINT_CSS}</style></head>
<body>
<article class="sheet" data-page="boletim" aria-label="${htmlEscape(copy.aria ?? 'Modelo de boletim informativo institucional de uma página')}">
<header class="bulletin-head">
<article class="sheet" data-page="boletim" aria-label="${htmlEscape(copy.aria ?? 'Modelo de boletim informativo de uma página')}">
<header class="running-head">
<div>
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
<p class="subject">${htmlEscape(bulletin.meta.subjectName)}</p>
${pills ? `<div class="pills">${pills}</div>` : ''}
<p class="running-kicker">${htmlEscape(bulletin.meta.kicker)}</p>
<p class="running-subtitle">${htmlEscape(
subject ? subjectName : `${bulletin.meta.region ?? 'Bahia'} · Bahia`,
)}</p>
</div>
<div class="running-meta">
<p><strong class="ink-strong">${htmlEscape(bulletin.meta.modelLabel)}</strong></p>
<p>folha 1/1</p>
</div>
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
</header>
<div class="defeso-band" role="note" aria-label="Nota de defeso eleitoral">
<span>DEFESO 2026 · material em preparação · não publicar sem revisão editorial e jurídica</span>
<span style="text-transform: uppercase; letter-spacing: .07em">sem CTA · sem propaganda</span>
</div>
<section class="opening" aria-label="Abertura do boletim">
<div>
<p class="section-label">${sparse ? 'Somente o que está documentado' : htmlEscape(copy.openingLabel)}</p>
<h1>${(() => {
const title = copy.openingTitle(bulletin.meta.subjectName)
const name = htmlEscape(bulletin.meta.subjectName)
const escaped = htmlEscape(title)
return escaped.includes(name)
? escaped.replace(name, `<span class="accent">${name}</span>`)
: escaped
})()}</h1>
<p class="lead">${sparse ? htmlEscape(copy.leadSparse) : htmlEscape(copy.lead)}</p>
</div>
${ASSET(sparse ? 'NEEDS ASSET · foto ou clipping' : 'NEEDS ASSET · foto real', [
sparse ? copy.assetSparse : copy.asset,
'origem, autoria e licença',
])}
<section>
<h2 class="bulletin-title">${title}</h2>
<p class="bulletin-lede">${htmlEscape(lede)}</p>
</section>
<section class="highlights" aria-label="Destaques">
<div class="highlights-head">
<div>
<p class="section-label">Destaques</p>
<h2>${sparse ? htmlEscape(copy.highlightsTitleSparse) : htmlEscape(copy.highlightsTitle)}</h2>
</div>
<p class="side">${sparse ? 'não criar cards vazios para chegar a seis' : 'cada número mantém sua fase e seu alcance'}</p>
</div>
<section class="section" aria-label="Destaques">
<p class="eyebrow">Destaques</p>
${
highlights.length
? `<div class="highlight-grid" style="${sparse ? 'grid-template-columns: repeat(2, 1fr)' : ''}">${highlights.map((highlight) => renderHighlight(highlight, { showPhase: true })).join('')}</div>`
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
}
</section>
<section class="bulletin-timeline" aria-label="Trajetória">
<div class="timeline-head">
<h2>${htmlEscape(copy.title)}</h2>
<p class="timeline-side">${sparse ? htmlEscape(copy.titleSparse) : 'quatro momentos · linguagem direta'}</p>
</div>
<div class="timeline-grid">
${bulletin.timeline
.map(
(step) => `<div class="timeline-step">
<p class="timeline-period">${htmlEscape(step.period)}</p>
<p class="timeline-text">${htmlEscape(step.text)}</p>
</div>`,
)
.join('')}
</div>
</section>
<section class="more" aria-label="Outras ações">
<div>
<div class="more-head">
<div>
<p class="section-label">E mais</p>
<h2>${sparse ? htmlEscape(copy.moreTitleSparse) : htmlEscape(copy.moreTitle)}</h2>
</div>
<p class="side">${sparse ? 'sem completar por inferência' : 'até 14 · sem completar por inferência'}</p>
</div>
${
more.length
? `<div class="more-grid">${more.map(renderMoreItem).join('')}</div>`
: `<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>${htmlEscape(copy.moreEmpty)}</p></div>`
}
${
bulletin.factsRemaining > 0
? `<p class="more-counter">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(copy.remaining)}</p>`
: ''
}
</div>
${
sparse
? `<aside class="lacuna-panel" style="border-radius: .125rem; padding: 2.5mm" aria-label="Regra editorial para poucos fatos">
${INST_WARNING_ICON}
<p style="margin: 0; font-size: 10.5pt; font-weight: 800">A página termina com espaço — de propósito.</p>
<p style="font-size: 8.7pt; line-height: 1.35">${htmlEscape(copy.sparseRule)}</p>
<p class="lacuna-rule">Poucos fatos com lastro &gt; página cheia sem lastro</p>
</aside>`
: `<aside class="more-aside" aria-label="Ativos visuais pendentes">
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
${ASSET('NEEDS ASSET · clipping', copy.assetClipping ?? [])}
</aside>`
bulletin.highlights.length
? `<ul class="bulletin-list">${bulletin.highlights
.map(
(highlight) => `<li>
<strong>${htmlEscape(highlight.title)}</strong>
<span>${htmlEscape(highlightText(highlight))}</span>
</li>`,
)
.join('')}</ul>`
: `<div class="note-line">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</div>`
}
</section>
${
sparse
? `<section style="margin-top: 3mm; display: grid; grid-template-columns: 1fr 48mm; gap: 4mm">
<div class="production-note">
<p class="section-label">Nota de produção</p>
<p style="margin: 0; font-size: 9.2pt; line-height: 1.38; color: #435264">Se novos fatos forem documentados, eles entram por relevância — até seis destaques e até quatorze itens. A ausência nunca vira número zero, percentual ou afirmação genérica.</p>
</div>
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
</section>`
bulletin.defenses.length
? `<section class="section" aria-label="O que Solla defende">
<p class="eyebrow">O que Solla defende</p>
<ul class="bulletin-defense-list">${bulletin.defenses
.map(
(defense) => `<li>
<strong>${htmlEscape(defense.label)}</strong>
<span>${htmlEscape(defense.text)}</span>
</li>`,
)
.join('')}</ul>
</section>`
: ''
}
<footer class="bulletin-foot">
<div class="row">
<div>
<p class="orig">${htmlEscape(copy.orig)}</p>
<p class="defeso"><strong>Controle editorial:</strong> ${htmlEscape(copy.defeso)}</p>
</div>
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
</div>
<section class="section" aria-label="Trajetória">
<p class="eyebrow">Trajetória em quatro períodos</p>
<ol class="period-list">${bulletin.timeline
.map(
(step) =>
`<li><strong>${htmlEscape(step.period)}</strong><span>${htmlEscape(step.text)}</span></li>`,
)
.join('')}</ol>
</section>
<section class="section" aria-label="Outras ações">
<p class="eyebrow">E mais</p>
${
bulletin.moreItems.length
? `<ul class="plain-list">${bulletin.moreItems
.map((item) => `<li>${htmlEscape(item.label)}</li>`)
.join('')}</ul>`
: bulletin.factsRemaining === 0
? `<div class="note-line">Nenhum item adicional além dos destaques nesta página.</div>`
: ''
}
${
bulletin.factsRemaining > 0
? `<p class="table-note">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(remaining)}</p>`
: ''
}
${
sparse
? `<div class="note-line"><strong>A página termina com espaço — de propósito.</strong> ${htmlEscape(sparseRule)}</div>`
: ''
}
</section>
<footer class="running-footer">
<span>${
subject
? htmlEscape(copy.orig ?? 'Conteúdo selecionado do dossiê.')
: 'Fatos selecionados do dossiê da cidade.'
}${subject && copy.defeso ? ` ${htmlEscape(copy.defeso)}.` : ''} <strong>Defeso eleitoral:</strong> material informativo em preparação. Não publicar sem revisão editorial e jurídica.</span>
<span class="folio">MODELO · INSUMO INTERNO · 1/1</span>
</footer>
</article>
</body>
</html>`
}
/** Dispatch: subject-shaped units (institution/theme) render the shared subject composition. */
export const renderBulletinHtml = (bulletin) =>
isSubjectUnit(bulletin?.unit)
? renderSubjectBulletinHtml(bulletin)
: renderMunicipalityBulletinHtml(bulletin)

View file

@ -118,23 +118,6 @@ export const BULLETIN_TIMELINE_STEPS = [
{ period: 'DESDE 2015', text: 'Atuação na Câmara Federal' },
]
/**
* The six cells the hi-fi page-1 "trajetória" grid shows; the complete timeline
* (with every period and recovery trail) lives on the dedicated página de
* trajetória — the page-1 grid must stay a one-glance read.
*/
const CAREER_HIGHLIGHT_PERIODS = [
'1999–2002',
'2003–2005',
'01/01/2007–18/01/2014',
'2015–2019',
'2019–2023',
'2023–2027',
]
export const careerTimelineHighlights = () =>
CAREER_TIMELINE.filter((row) => CAREER_HIGHLIGHT_PERIODS.includes(row.period))
/**
* Non-negotiable career facts the renderers may print without a per-item source
* (they are the identity of the document). Anything beyond this needs a source.

View file

@ -15,7 +15,7 @@
/**
* Per-sheet reserve for the block margins the tight probe zeroes out
* (`.block` 3.4mm, `.action-grid`/`.document-table` top margins). The A4 fit
* (section/block margins, `.document-table`/list top margins). The A4 fit
* guard still catches any residue; the reserve keeps a first pack from
* overflowing.
*/

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,22 @@ const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos'
export const DOSSIER_BRIEF_TITLE_MAX = 80
export const DOSSIER_BRIEF_NOTE_MAX = 120
/**
* C209: a checklist item of `kind: 'defense'` is a position/priority Solla
* stands for in the recorte. It enters through the same item→research→report
* contract (answer + sourceUrl + sourceDate) and feeds the "O que Solla
* defende" section; without a dated source it degrades to an explicit gap.
*/
const DEFENSE_AREA = 'Posições e defesas'
const defenseItem = (era, label) => ({
id: `era_${era.toLowerCase()}_defesas`,
era,
area: DEFENSE_AREA,
label,
kind: 'defense',
})
/** Municipality checklist (C186). `area` labels the boletim card. */
const MUNICIPALITY_RESEARCH_CHECKLIST = [
{
@ -58,6 +74,7 @@ const MUNICIPALITY_RESEARCH_CHECKLIST = [
area: 'Ministério da Saúde',
label: 'Secretaria de Atenção à Saúde do Ministério da Saúde (2003–2005)',
},
defenseItem('A', 'Posições e defesas sobre o município e a região (até 2006)'),
{ id: 'era_b_sesab', era: 'B', area: 'Saúde estadual', label: 'Gestão da SESAB (2007–2014)' },
{
id: 'era_b_equipamentos',
@ -77,6 +94,7 @@ const MUNICIPALITY_RESEARCH_CHECKLIST = [
area: 'Obras',
label: 'Obras e investimentos em saúde no município/região',
},
defenseItem('B', 'Posições e defesas sobre o município e a região (2007–2014)'),
{
id: 'era_c_discursos',
era: 'C',
@ -107,6 +125,7 @@ const MUNICIPALITY_RESEARCH_CHECKLIST = [
area: 'Atuação regional',
label: 'Atuação e articulação regional',
},
defenseItem('C', 'Posições e defesas sobre o município e a região (2015–2027)'),
]
/** Institution checklist (C187) — same eras, institution-oriented. */
@ -141,6 +160,7 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
area: 'Ministério da Saúde',
label: 'Secretaria de Atenção à Saúde do MS com interface institucional',
},
defenseItem('A', 'Posições e defesas sobre a instituição (até 2006)'),
{
id: 'era_a_vinculo',
era: 'A',
@ -177,6 +197,7 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
area: 'Convênios',
label: 'Convênios, termos e parcerias formais com a instituição',
},
defenseItem('B', 'Posições e defesas sobre a instituição (2007–2014)'),
{
id: 'era_c_discursos',
era: 'C',
@ -213,6 +234,7 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
area: 'Parcerias',
label: 'Parcerias, audiências e articulação formal com a instituição',
},
defenseItem('C', 'Posições e defesas sobre a instituição (2015–2027)'),
]
/** Theme/area checklist (C190) — same eras, area-oriented. */
@ -253,6 +275,7 @@ const THEME_RESEARCH_CHECKLIST = [
area: 'Vínculo',
label: 'Atuação documentada na área (pesquisa, conselho, docência)',
},
defenseItem('A', 'Posições e defesas sobre a área (até 2006)'),
{
id: 'era_b_sesab',
era: 'B',
@ -283,6 +306,7 @@ const THEME_RESEARCH_CHECKLIST = [
area: 'Articulação',
label: 'Articulação regional e setorial da área',
},
defenseItem('B', 'Posições e defesas sobre a área (2007–2014)'),
{
id: 'era_c_discursos',
era: 'C',
@ -325,9 +349,10 @@ const THEME_RESEARCH_CHECKLIST = [
area: 'Reconhecimento',
label: 'Títulos, honrarias e homenagens ligadas à área',
},
defenseItem('C', 'Posições e defesas sobre a área (2015–2027)'),
]
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string }>>} */
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string, kind?: string }>>} */
const CHECKLIST_BY_UNIT = {
municipality: MUNICIPALITY_RESEARCH_CHECKLIST,
institution: INSTITUTION_RESEARCH_CHECKLIST,
@ -342,7 +367,7 @@ export const DOSSIER_RESEARCH_CHECKLIST_IDS = MUNICIPALITY_RESEARCH_CHECKLIST.ma
/**
* @param {string} era
* @param {any} [unit]
* @returns {Array<{ id: string, era: string, area: string, label: string }>}
* @returns {Array<{ id: string, era: string, area: string, label: string, kind?: string }>}
*/
export const dossierChecklistForEra = (era, unit = MUNICIPALITY_UNIT) =>
checklistFor(resolveDossierUnit(unit)).filter((item) => item.era === era)
@ -499,13 +524,17 @@ export const normalizeDossierResearchInput = (raw, { unit } = {}) => {
era,
label: checklistItem.label,
area: checklistItem.area,
// C209: `defense` items feed the "O que Solla defende" section; every other
// item is evidence for the era/recorte sections.
kind: checklistItem.kind ?? 'evidence',
answer,
// Optional short, complete, self-contained rewrite of `answer` (C188): the
// resumo/boletim print it as-is; the era pages keep `answer`/`details`
// integral. Inherits the item's source — not a new fact.
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
// C209: `brief` is the reformulated, shorter copy the fixed A4 surfaces
// print; the integral `answer`/`details` stay in the record and the .md.
brief,
// Optional short label of the position/priority (defense items); the
// checklist `area` is the fallback when the researcher does not name it.
position: isNonEmptyString(entry.position) ? entry.position.trim() : null,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
sphere,
numbers: normalizeNumbers(entry),
sourceUrl,

View file

@ -55,7 +55,6 @@ export const MUNICIPALITY_UNIT = {
spheres: ['municipio', 'regiao', 'polo'],
defaultSphere: 'municipio',
sphereLabels: { municipio: 'município', regiao: 'região', polo: 'polo' },
sphereBadgeClass: { municipio: '', regiao: 'scope-region', polo: 'scope-region' },
/** Bulletin ranking: a direct item outranks a shared-scope one. */
breadthRank: { municipio: 0, regiao: 1, polo: 2 },
researchDir: 'data/dossie-solla-cidade',
@ -74,9 +73,8 @@ export const MUNICIPALITY_UNIT = {
acervoNote: 'recorte municipal (menção confirmada); região e polo não entram nesta conta.',
eraNumbersTitle: 'Recursos e entregas quantificáveis',
eraActionsTitle: 'O que fez — item, alcance e lastro',
honorsTitle: 'Títulos, honrarias e vínculos locais',
/** C186 literal kept verbatim (byte-parity); subject units override it. */
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
/** Guard printed in "O essencial"/synthesis for the city recorte (C209). */
synthesisSumGuard: 'Região e polo não são somados ao município.',
}
export const INSTITUTION_UNIT = {
@ -88,7 +86,6 @@ export const INSTITUTION_UNIT = {
spheres: ['instituicao', 'setor', 'rede'],
defaultSphere: 'instituicao',
sphereLabels: { instituicao: 'instituição', setor: 'setor', rede: 'rede' },
sphereBadgeClass: { instituicao: '', setor: 'scope-sector', rede: 'scope-network' },
breadthRank: { instituicao: 0, setor: 1, rede: 2 },
researchDir: 'data/dossie-solla-instituicao',
outDir: 'docs/research/dossie-solla-instituicao',
@ -103,21 +100,15 @@ export const INSTITUTION_UNIT = {
acervoNote: 'recorte por tema, não nominal; não somar à instituição.',
eraNumbersTitle: 'Objeto, valor, fase e alcance',
eraActionsTitle: 'Papéis com evidência visível',
honorsTitle: 'Títulos, honrarias e vínculos institucionais',
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
aria: {
synthesis: 'Síntese da atuação institucional',
charts: 'Gráficos consolidados da atuação institucional',
sources: 'Fontes e limites do dossiê institucional',
scope: 'Painel de abrangência institucional',
},
/** Subject-shape fields (shared with THEME_UNIT). */
nounPlural: 'instituições',
subjectNoun: 'instituição',
docLabel: 'Dossiê institucional',
contributionLabel: 'instituição',
scopeLabel: 'no recorte institucional',
summaryLinkEyebrow: '01 · vínculo com a instituição',
honorsSheet: true,
notSummedTo: 'à instituição',
scopeLists: [
@ -126,7 +117,6 @@ export const INSTITUTION_UNIT = {
{ key: 'network', sphere: 'rede' },
],
scopeListWords: { institution: 'instituicao', sector: 'setor', network: 'rede' },
scopeCardClass: { instituicao: '', setor: 'card-sector', rede: 'card-network' },
eraMethod: {
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
@ -149,22 +139,16 @@ export const INSTITUTION_UNIT = {
['Alcance', identity.scopeLabel],
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
].filter(([, value]) => Boolean(value)),
coverRows: (report) => [{ label: 'Identificação', badges: report.meta.identityBadges }],
copy: {
coverSubtitle: () => 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
coverHowToUse:
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
coverScope:
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.',
coverScopeWords: 'instituição, setor e rede separados',
coverAsset: ['selo da instituição', 'uso autorizado e origem'],
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
reachRuleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
reachRuleBody:
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
synthesisGuard: 'setor e rede nunca somados à instituição.',
coverageNote: 'Instituição, setor e rede separados; sem fonte, não entra como entrega.',
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
eraEmpty: {
body: 'A pesquisa não encontrou documento que sustenta uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.',
work: [
@ -196,32 +180,18 @@ export const INSTITUTION_UNIT = {
'Valor sempre informa a fase de execução.',
],
},
mdAcervoNote: 'Recorte por tema, não nominal.',
bulletin: {
aria: 'Modelo de boletim informativo institucional de uma página',
openingLabel: 'Uma trajetória de trabalho junto à instituição',
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.',
leadSparse:
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
asset: 'Solla na instituição ou na ação citada',
assetSparse: 'somente registro relacionado ao fato',
assetSelo: ['marca autorizada da instituição', 'arquivo vetorial'],
assetClipping: ['registro real da ação', 'origem e licença'],
moreEmpty:
'Conferir as lacunas explícitas no dossiê institucional antes de ampliar o boletim.',
defeso:
'fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.',
orig: 'Conteúdo selecionado do dossiê institucional.',
remaining: 'no dossiê da instituição — o boletim de uma página não os exibe.',
sparseRule:
'Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.',
title: 'Uma trajetória de compromisso com o serviço público',
titleSparse: 'contexto de carreira · não substitui fato institucional',
highlightsTitle: 'Resultados em leitura rápida',
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
moreTitle: 'Outras ações confirmadas',
moreTitleSparse: 'Outros registros confirmados',
},
},
}
@ -234,7 +204,6 @@ export const THEME_UNIT = {
spheres: ['area', 'segmento', 'rede'],
defaultSphere: 'area',
sphereLabels: { area: 'área', segmento: 'segmento', rede: 'rede' },
sphereBadgeClass: { area: '', segmento: 'scope-sector', rede: 'scope-network' },
breadthRank: { area: 0, segmento: 1, rede: 2 },
researchDir: 'data/dossie-solla-tema',
outDir: 'docs/research/dossie-solla-tema',
@ -250,20 +219,14 @@ export const THEME_UNIT = {
acervoNote: 'recorte pela tag do acervo (Speech.topics); segmento e rede não entram nesta conta.',
eraNumbersTitle: 'Objeto, valor, ano, fase e abrangência',
eraActionsTitle: 'Papéis e iniciativas com evidência visível',
honorsTitle: 'Títulos, honrarias e vínculos',
synthesisSumGuard: 'Segmento e rede não são somados à área.',
aria: {
synthesis: 'Síntese da atuação por área',
charts: 'Gráficos consolidados da atuação por área',
sources: 'Fontes e limites do dossiê temático',
scope: 'Painel de abrangência por área',
},
nounPlural: 'temas',
subjectNoun: 'área',
docLabel: 'Dossiê temático',
contributionLabel: 'área',
scopeLabel: 'no recorte da área',
summaryLinkEyebrow: '01 · vínculo com a área',
honorsSheet: false,
notSummedTo: 'à área',
scopeLists: [
@ -272,20 +235,16 @@ export const THEME_UNIT = {
{ key: 'network', sphere: 'rede' },
],
scopeListWords: { area: 'area', segment: 'segmento', network: 'rede' },
scopeCardClass: { area: '', segmento: 'card-sector', rede: 'card-network' },
/** Theme-only scene overrides (the institution keeps the shared defaults). */
acervoScene: {
stats: [
{ key: 'universe', label: 'Universo recuperado' },
{ key: 'sample', label: 'Amostra exibida' },
],
columns: ['Data', 'Tema canônico', 'Trecho/contexto', 'Abrangência', 'Link'],
alert:
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega, autoria material ou execução financeira.',
},
/** Theme money lines keep every phase on its own line (never fused). */
moneyPhaseStyle: 'separate',
reachScene: {
columns: ['Item', 'Era / ano', 'Valor / fase', 'Evidência temática', 'Fonte'],
tableClass: 'document-table--reach',
},
gapsScene: {
columns: ['Era', 'Lacuna', 'Já consultado', 'Próxima busca', 'Status'],
@ -314,12 +273,6 @@ export const THEME_UNIT = {
limit: 'Resultado não prova ausência',
},
],
/** Síntese/rótulos temáticos (institution/município keep the shared literals). */
byAreaTitle: 'Tipos de atuação com mais registros',
byYearTitle: 'Pontos com fonte por ano',
fullYearAxis: true,
eraCoverageFromLedger: true,
moneyPhaseStyle: 'separate',
/**
* Theme bulletins read the year when a fact has no money value, and mark the
* card textual when neither exists (never a fake zero).
@ -336,12 +289,6 @@ export const THEME_UNIT = {
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
},
identityBadges: (identity) => [identity.label].filter(Boolean),
/** Theme bulletin pills: recorte label, canonical token (mono) and taxonomy origin. */
bulletinPills: (identity) => [
{ label: 'Área' },
{ label: identity.value, code: true },
{ label: identity.taxonomyNote ?? 'Taxonomia do acervo' },
],
identityRows: (identity) =>
[
['Área', identity.label],
@ -349,14 +296,6 @@ export const THEME_UNIT = {
['Origem', identity.taxonomyNote ?? 'Taxonomia do acervo'],
['Filtro', 'Speech.topics'],
].filter(([, value]) => Boolean(value)),
coverRows: (report) => [
{ label: 'Área', badges: report.meta.identityBadges },
{
label: 'Valor canônico',
code: report.meta.identity?.value,
note: 'taxonomia do acervo / Speech.topics',
},
],
copy: {
coverSubtitle: (subjectName) =>
`O que Jorge Solla fez ${themeSubjectPhrase(subjectName)} ao longo da carreira`,
@ -364,15 +303,10 @@ export const THEME_UNIT = {
'Localize a entrega, confira fonte, data, fase e abrangência. A área vem da taxonomia canônica; segmento e rede não são somados a ela.',
coverScope:
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara 2015–2027. Área, segmento e rede separados; sem fonte, não entra como entrega.',
coverScopeWords: 'área, segmento e rede separados',
coverAsset: ['selo autorizado', 'e origem'],
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
reachRuleTitle: 'Segmento/rede não é a área. Não some os recortes.',
reachRuleBody:
'Uma ação dirigida a um segmento ou a uma rede pode alcançar a política de área sem constituir entrega exclusiva dela.',
synthesisGuard: 'segmento e rede nunca somados à área.',
coverageNote: 'Área, segmento e rede separados; sem fonte, não entra como entrega.',
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
eraEmpty: {
body: 'A pesquisa não encontrou documento que sustente uma afirmação sobre a área. Isso não prova ausência de atuação; prova apenas que o dossiê ainda não pode afirmá-la.',
work: [
@ -405,31 +339,18 @@ export const THEME_UNIT = {
'Atribuição temática a emenda ou proposição só entra com fonte; sem fonte, vira lacuna.',
],
},
mdAcervoNote: 'Recorte pela tag do acervo, não nominal.',
bulletin: {
aria: 'Modelo de boletim informativo temático de uma página',
openingLabel: 'Uma trajetória de trabalho pela área',
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê da área.',
leadSparse:
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
asset: 'Solla na ação citada',
assetSparse: 'somente registro relacionado ao fato',
assetSelo: ['selo autorizado', 'arquivo vetorial'],
assetClipping: ['registro real da ação', 'origem e licença'],
moreEmpty: 'Conferir as lacunas explícitas no dossiê da área antes de ampliar o boletim.',
defeso:
'fase acompanha cada valor · área, segmento e rede não são somados · sem percentual estadual absoluto.',
orig: 'Conteúdo selecionado do dossiê da área.',
remaining: 'no dossiê da área — o boletim de uma página não os exibe.',
sparseRule:
'Não repetir item, não ampliar efeito e não preencher com ação de segmento ou rede como se fosse entrega exclusiva da área.',
title: 'Uma trajetória de compromisso com o serviço público',
titleSparse: 'contexto de carreira · não substitui fato da área',
highlightsTitle: 'Resultados em leitura rápida',
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
moreTitle: 'Outras ações confirmadas',
moreTitleSparse: 'Outros registros confirmados',
},
},
}

View file

@ -106,19 +106,20 @@ describe('dossiê sem caps + redação (revisão 2026-09-18)', () => {
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
})
it('documents the opening letter, the synthesis and the charts', () => {
expect(skill).toContain('A contribuição (carta)')
expect(skill).toContain('**Síntese**')
expect(skill).toContain('Gráficos consolidados')
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
/O que esta era\s+entrega/,
)
it('documents the analysis-first sections and the sober design', () => {
expect(skill).toContain('O essencial')
expect(skill).toContain('Leitura entre eras')
expect(skill).toContain('O que Solla defende')
expect(skill, 'the era opens with its reading').toContain('Leitura da era')
expect(skill, 'the charts were retired').not.toContain('Gráficos consolidados')
})
it('pins the narrative contract and the writer receipt', () => {
expect(skill).toContain('## Recibo do redator')
expect(skill).toContain('"municipalitySlug": "ilheus"')
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
expect(skill, 'the narrative may carry the between-eras bullets').toContain('"betweenEras"')
expect(skill, 'the defense checklist item is documented').toContain('era_c_defesas')
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
})
})

View file

@ -6,7 +6,6 @@ import {
mergeDossierResearch,
normalizeDossierResearchInput,
} from '../../scripts/lib/dossieResearch.mjs'
import { SUMMARY_POINTER_COPY } from '../../scripts/lib/reportText.mjs'
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
@ -48,6 +47,11 @@ const research = mergeDossierResearch([
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
}),
item('era_c_titulos', { answer: 'Cidadão Ilheense' }),
item('era_c_defesas', {
position: 'Ensino superior',
answer: 'Defende a implantação de campus da UFBA na região',
details: 'INC 1497/2023 · Câmara dos Deputados',
}),
]),
),
])
@ -138,7 +142,14 @@ describe('buildDossierReport', () => {
expect(report.bulletinFacts.some((fact) => fact.area === 'Emendas')).toBe(true)
})
it('omits an era that has no number and no action', () => {
it('marks the defense items on the bulletin ledger', () => {
const report = build()
const defense = report.bulletinFacts.find((fact) => fact.id === 'era_c_defesas')
expect(defense).toMatchObject({ defense: true })
expect(report.bulletinFacts.find((fact) => fact.id === 'era_c_emendas')?.defense).toBe(false)
})
it('keeps an era without evidence in the report, omitted as a section (C209)', () => {
const empty = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [])),
normalizeDossierResearchInput(eraFile('B', [])),
@ -152,114 +163,114 @@ describe('buildDossierReport', () => {
health: { status: 'gap', items: [] },
generatedAt,
})
expect(report.eras).toHaveLength(0)
expect(report.eras).toHaveLength(3)
expect(report.eras.every((era) => era.omitted)).toBe(true)
})
it('caps the page-1 deliveries and keeps the full timeline on its own page', () => {
const report = build()
expect(report.page1.deliveries.items.length).toBeLessThanOrEqual(3)
expect(report.page1.deliveries.total).toBeGreaterThanOrEqual(
report.page1.deliveries.items.length,
)
expect(report.page1.timeline.length).toBe(6)
expect(report.trajectory.length).toBeGreaterThan(report.page1.timeline.length)
})
it('prefers the researcher summary on the resumo and keeps the integral in the era (C188)', () => {
const summarized = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
normalizeDossierResearchInput(
eraFile('C', [
item('era_c_emendas', {
summary: 'Emenda de R$ 1,2 mi empenhada em 2024.',
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
}),
]),
),
])
const report = build({ research: summarized })
const delivery = report.page1.deliveries.items.find((row: { era: string }) => row.era === 'C')!
expect(delivery.title).toBe('Emenda de R$ 1,2 mi empenhada em 2024.')
const eraC = report.eras.find((era) => era?.id === 'C')!
expect(
eraC.actions.some(
(action: { title: string }) => action.title === 'Resposta de era_c_emendas',
),
).toBe(true)
})
it('falls back to a pointer line (never "…") without losing the era integral (C188)', () => {
const report = build({ textFallback: 'pointer' })
const delivery = report.page1.deliveries.items[0]
expect(delivery.title).toBe(SUMMARY_POINTER_COPY)
expect(delivery.detail).toBeNull()
expect(delivery.title.includes('…')).toBe(false)
const eraC = report.eras.find((era) => era?.id === 'C')!
expect(
eraC.actions.some(
(action: { title: string }) => action.title === 'Resposta de era_c_emendas',
),
).toBe(true)
})
it('keeps the researcher summary even on the pointer pass (C188)', () => {
const summarized = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
normalizeDossierResearchInput(
eraFile('C', [
item('era_c_emendas', {
summary: 'Emenda de R$ 1,2 mi empenhada em 2024.',
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
}),
]),
),
])
const report = build({ research: summarized, textFallback: 'pointer' })
const summaryDelivery = report.page1.deliveries.items.find(
(row: { era: string }) => row.era === 'C',
)!
expect(summaryDelivery.title).toBe('Emenda de R$ 1,2 mi empenhada em 2024.')
const hook = report.page1.hooks.items.find(
(row: { angle: string }) => row.angle === SUMMARY_POINTER_COPY,
)
expect(Boolean(hook)).toBe(true)
})
it('counts every capped list so nothing disappears in silence (C188)', () => {
const manyRegional = mergeDossierResearch([
normalizeDossierResearchInput(
eraFile(
'A',
[
'era_a_formacao',
'era_a_sesab',
'era_a_consultor_ms',
'era_a_conquista',
'era_a_sas_ms',
].map((id) => item(id, { sphere: 'regiao' })),
),
),
normalizeDossierResearchInput(eraFile('B', [])),
normalizeDossierResearchInput(eraFile('C', [])),
])
const report = build({ research: manyRegional })
const eraA = report.eras.find((era) => era?.id === 'A')!
expect(eraA.actions).toHaveLength(5)
// Uncapped (2026-09-18): the full lists flow across sheets, so there is no
// remaining/omitted count on the era or region lists.
expect(eraA).not.toHaveProperty('camaraActionsRemaining')
expect(report.region.regional.items).toHaveLength(5)
expect(report.region.regional.total).toBe(5)
expect(report.region.regional).not.toHaveProperty('remaining')
})
it('carries the defeso limits and the editorial rules', () => {
it('keeps the trajectory and the defeso limits', () => {
const report = build()
expect(report.trajectory.length).toBeGreaterThan(6)
expect(report.limits.editorial).toContain('Sem fonte, não publica.')
expect(report.limits.coverage.join(' ')).toContain('2011')
})
it('reads the sourced set into O essencial facts (never a new fact)', () => {
const report = build()
expect(report.essentials.facts.length).toBeGreaterThanOrEqual(3)
const labels = report.essentials.facts.map((fact: { label: string }) => fact.label).join(' ')
expect(labels).toContain('pontos com fonte')
expect(labels).toContain('Abrangência sem soma')
expect(labels).toContain('lacunas declaradas')
expect(report.essentials.facts.map((fact: { text: string }) => fact.text).join(' ')).toContain(
'Empenho não é pagamento.',
)
})
it('derives the between-eras reading from the ledger', () => {
const report = build()
expect(
report.betweenEras.bullets.map((bullet: { label: string | null }) => bullet.label),
).toContain('Concentração.')
expect(report.betweenEras.table).toHaveLength(3)
expect(report.betweenEras.table[0]).toMatchObject({
where: expect.any(String),
howToCite: expect.any(String),
limit: expect.any(String),
})
})
it('prefers the narrative betweenEras bullets when the writer provides them', () => {
const report = build({
narrative: { betweenEras: ['Leitura autoral do conjunto.', 'Segunda linha.'] },
})
expect(report.betweenEras.bullets).toEqual([
{ label: null, text: 'Leitura autoral do conjunto.' },
{ label: null, text: 'Segunda linha.' },
])
})
it('separates the positions with lastro from the defense gaps (fail-closed)', () => {
const report = build()
expect(report.defends.positions).toContainEqual(
expect.objectContaining({
id: 'era_c_defesas',
label: 'Ensino superior',
reading: 'Defende a implantação de campus da UFBA na região',
sourceUrl: 'https://exemplo.test/era_c_defesas',
}),
)
// Era A/B defenses were not researched: explicit gaps, never an inference.
expect(report.defends.gaps.map((gap: { id: string }) => gap.id)).toEqual([
'era_a_defesas',
'era_b_defesas',
])
})
it('degrades a defense without a source to an explicit gap', () => {
const unsourced = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
normalizeDossierResearchInput(
eraFile('C', [
item('era_c_defesas', {
position: 'Saúde regional',
answer: 'Defende o SAMU regional',
sourceUrl: null,
}),
]),
),
])
const report = build({ research: unsourced })
expect(report.defends.positions).toHaveLength(0)
expect(report.defends.gaps).toContainEqual(
expect.objectContaining({ id: 'era_c_defesas', reason: expect.stringMatching(/Sem fonte/) }),
)
})
it('keeps the news summary and the era for the sources table', () => {
const withNews = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
normalizeDossierResearchInput({
...eraFile('C', [item('era_c_emendas')]),
news: [
{
title: 'Matéria local',
url: 'https://jornal.test/materia',
publishedAt: '2024-06-01',
outlet: 'Jornal Local',
summary: 'Resumo datado da matéria.',
},
],
}),
])
const report = build({ research: withNews })
expect(report.news[0]).toMatchObject({
era: 'C',
summary: 'Resumo datado da matéria.',
outlet: 'Jornal Local',
})
})
})
describe('dossier checklist coverage', () => {
@ -268,4 +279,12 @@ describe('dossier checklist coverage', () => {
expect(dossierChecklistForEra(era).every((row) => row.era === era)).toBe(true)
}
})
it('carries one defense item per era (kind: defense)', () => {
for (const era of ['A', 'B', 'C'] as const) {
const defenses = dossierChecklistForEra(era).filter((row) => row.kind === 'defense')
expect(defenses).toHaveLength(1)
expect(defenses[0].id).toBe(`era_${era.toLowerCase()}_defesas`)
}
})
})

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { BULLETIN_HIGHLIGHT_LIMIT, buildBulletin } from '../../scripts/lib/dossieBulletin.mjs'
import {
BULLETIN_DEFENSE_LIMIT,
BULLETIN_HIGHLIGHT_LIMIT,
buildBulletin,
nextBulletinFit,
} from '../../scripts/lib/dossieBulletin.mjs'
import { MUNICIPALITY_UNIT } from '../../scripts/lib/dossieUnit.mjs'
const fact = (index: number, overrides: Record<string, unknown> = {}) => ({
@ -18,6 +23,9 @@ const fact = (index: number, overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const defense = (index: number, overrides: Record<string, unknown> = {}) =>
fact(index, { defense: true, headline: `Defesa ${index}`, ...overrides })
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
describe('buildBulletin', () => {
@ -33,9 +41,12 @@ describe('buildBulletin', () => {
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
expect(bulletin.factsTotal).toBe(25)
expect(bulletin.highlights.length + bulletin.moreItems.length + bulletin.factsRemaining).toBe(
bulletin.factsTotal,
)
expect(
bulletin.highlights.length +
bulletin.moreItems.length +
bulletin.defenses.length +
bulletin.factsRemaining,
).toBe(bulletin.factsTotal)
})
it('has no remainder when everything fits', () => {
@ -53,30 +64,81 @@ describe('buildBulletin', () => {
expect(bulletin.highlights[0].title).toBe('Entrega 3')
})
it('composes the card eyebrow with area and sphere', () => {
const bulletin = buildBulletin({
facts: [fact(1, { sphere: 'regiao' })],
municipality: 'Ilhéus',
generatedAt,
})
expect(bulletin.highlights[0].eyebrow).toBe('Saúde · região')
})
it('renders an empty state without inventing facts', () => {
const bulletin = buildBulletin({ facts: [], municipality: 'Ilhéus', generatedAt })
expect(bulletin.highlights).toEqual([])
expect(bulletin.moreItems).toEqual([])
expect(bulletin.defenses).toEqual([])
expect(bulletin.timeline.length).toBe(4)
})
it('maps every highlight and "e mais" item to a ledger fact (never invents)', () => {
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
it('prints the defenses outside the highlight ranking and counts them', () => {
const facts = [
defense(1, { headline: 'Defende o SAMU regional' }),
defense(2, { headline: 'Defende campus da UFBA' }),
defense(3, { headline: 'Defende a Policlínica' }),
...Array.from({ length: 25 }, (_value, index) => fact(index)),
]
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
expect(bulletin.defenses).toHaveLength(BULLETIN_DEFENSE_LIMIT)
expect(bulletin.defenses[0]).toMatchObject({
label: 'Defende o SAMU regional',
text: 'Detalhe 1',
})
// The defenses never displace a sourced finding from the printed slots.
expect(bulletin.highlights[0].title).toBe('Entrega 0')
expect(
bulletin.highlights.length +
bulletin.moreItems.length +
bulletin.defenses.length +
bulletin.factsRemaining,
).toBe(bulletin.factsTotal)
})
it('maps every highlight, "e mais" item and defense to a ledger fact (never invents)', () => {
const facts = [defense(99), ...Array.from({ length: 25 }, (_value, index) => fact(index))]
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
const headlines = new Set(facts.map((row) => row.headline))
expect(bulletin.highlights.length + bulletin.moreItems.length).toBe(
BULLETIN_HIGHLIGHT_LIMIT + MUNICIPALITY_UNIT.bulletinMoreLimit,
)
expect(bulletin.highlights.every((row) => headlines.has(row.title))).toBe(true)
expect(bulletin.moreItems.every((row) => headlines.has(row.label))).toBe(true)
expect(bulletin.defenses.every((row) => headlines.has(row.label))).toBe(true)
})
})
describe('nextBulletinFit', () => {
it('reduces the printed facts first and then the defenses', () => {
const facts = [
defense(1),
defense(2),
...Array.from({ length: 10 }, (_value, index) => fact(index)),
]
let bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
let next = nextBulletinFit(bulletin)
expect(next).toEqual({ printLimit: 8 })
bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt, ...next })
next = nextBulletinFit(bulletin)
expect(next).toEqual({ printLimit: 6 })
// Down to zero printed facts, the defenses are the next budget.
const zeroed = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt, printLimit: 0 })
expect(nextBulletinFit(zeroed)).toEqual({ printLimit: 0, defenseLimit: 1 })
const oneDefense = buildBulletin({
facts,
municipality: 'Ilhéus',
generatedAt,
printLimit: 0,
defenseLimit: 1,
})
expect(nextBulletinFit(oneDefense)).toEqual({ printLimit: 0, defenseLimit: 0 })
})
it('returns null when there is nothing left to cut (fail-closed)', () => {
const bulletin = buildBulletin({
facts: [fact(1)],
municipality: 'Ilhéus',
generatedAt,
printLimit: 0,
defenseLimit: 0,
})
expect(nextBulletinFit(bulletin)).toBeNull()
})
})

View file

@ -11,9 +11,10 @@ import {
import { INSTITUTION_UNIT } from '../../scripts/lib/dossieUnit.mjs'
// C187: institution content model + render. Grounds the institutional recorte:
// identity badges, `instituicao|setor|rede` never summed, per-row phase/source,
// identity rows, `instituicao|setor|rede` never summed, per-row phase/source,
// empty-era state instead of silent omission, and a one-page boletim that
// inherits only sourced facts.
// inherits only sourced facts. C209: sober redesign (analysis first, lists and
// tables, "O que Solla defende").
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
@ -94,6 +95,10 @@ const research = mergeDossierResearch(
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
}),
item('era_c_titulos', 'C', { answer: 'Título de cidadão honorário' }),
item('era_c_defesas', 'C', {
position: 'Financiamento',
answer: 'Defende o financiamento das universidades federais',
}),
]),
{ unit: INSTITUTION_UNIT },
),
@ -150,13 +155,6 @@ describe('buildDossierReport (institution unit)', () => {
expect(listFor('network')?.total).toBe(1)
})
it('declares overflow instead of truncating silently (C188 rule)', () => {
for (const list of report.reach.lists) {
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
}
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
})
it('shows every sourced item — institution lists are never capped', () => {
const manyItems = [
item('era_c_emendas', 'C', { sphere: 'setor' }),
@ -212,7 +210,7 @@ describe('buildDossierReport (institution unit)', () => {
expect(paid).toMatchObject({ count: 1, amount: 1_200_000 })
const html = renderDossierHtml(moneyReport)
expect(html).toContain('R$ 1,2 mi')
expect(html).toContain('Recursos com execução por ano')
expect(html).toContain('Valores localizados, sem consolidar fases')
})
it('derives bulletinFacts only from sourced items', () => {
@ -226,6 +224,16 @@ describe('buildDossierReport (institution unit)', () => {
expect(report.bulletinFacts.some((fact) => fact.id === 'fala-11')).toBe(true)
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
})
it('reads the positions with lastro and the missing defenses', () => {
expect(report.defends.positions).toContainEqual(
expect.objectContaining({ label: 'Financiamento', era: 'C' }),
)
expect(report.defends.gaps.map((gap: { id: string }) => gap.id)).toEqual([
'era_a_defesas',
'era_b_defesas',
])
})
})
describe('renderDossierHtml/Md (institution unit)', () => {
@ -237,23 +245,23 @@ describe('renderDossierHtml/Md (institution unit)', () => {
'capa',
'resumo',
'sintese',
'graficos',
'trajetoria',
'era-a',
'era-b',
'era-c',
'abrangencia',
'abrangencia-instituicao',
'abrangencia-setor',
'abrangencia-rede',
'titulos',
'defende',
'lacunas',
'noticias',
'acervo',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
}
})
it('prints the synthesis lines and the consolidated charts', () => {
it('prints the synthesis lines and the between-eras reading, never the old charts', () => {
const synthesis = report.synthesis
expect(synthesis).toBeTruthy()
if (!synthesis) return
@ -269,11 +277,11 @@ describe('renderDossierHtml/Md (institution unit)', () => {
)
expect(sphereTotal).toBe(synthesis.totals.items)
expect(html).toContain('data-page="sintese"')
expect(html).toContain('data-page="graficos"')
expect(html).toContain('Síntese do que foi localizado')
expect(html).toContain('Gráficos consolidados')
expect(html).toContain('chart-grid')
expect(md).toContain('## Síntese do que foi localizado')
expect(html).toContain('Leitura entre eras')
expect(html).not.toContain('data-page="graficos"')
expect(html).not.toContain('Gráficos consolidados')
expect(html).not.toContain('chart-grid')
expect(md).toContain('## Leitura entre eras')
})
it('flows across continuation sheets when the pack plan splits a section', () => {
@ -310,12 +318,12 @@ describe('renderDossierHtml/Md (institution unit)', () => {
unit: INSTITUTION_UNIT,
})
const bigHtml = renderDossierHtml(bigReport, {
pack: { 'era:C': [1], 'scope:setor': [1], news: [1] },
pack: { 'era:C': [1], scope: [1], news: [1] },
})
expect(bigHtml).toContain('data-page="era-c"')
expect(bigHtml).toContain('data-page="era-c-2"')
expect(bigHtml).toContain('data-page="abrangencia-setor"')
expect(bigHtml).toContain('data-page="abrangencia-setor-2"')
expect(bigHtml).toContain('data-page="abrangencia"')
expect(bigHtml).toContain('data-page="abrangencia-2"')
expect(bigHtml).toContain('data-page="noticias"')
expect(bigHtml).toContain('data-page="noticias-2"')
expect(bigHtml).toContain('continuação')
@ -324,24 +332,27 @@ describe('renderDossierHtml/Md (institution unit)', () => {
expect(anchors.length).toBe(bigReport.meta.pageTotal)
})
it('ports the institutional classes and identity badges', () => {
expect(html).toContain('identity-badge')
expect(html).toContain('scope-sector')
expect(html).toContain('scope-network')
expect(html).toContain('phase-paid')
expect(html).toContain('phase-pending')
it('ports the sober classes: index, position list, tables and phase as text', () => {
expect(html).toContain('index-list')
expect(html).toContain('position-list')
expect(html).toContain('plain-list')
expect(html).toContain('document-table')
expect(html).toContain('phase-text')
expect(html).not.toContain('identity-badge')
expect(html).not.toContain('scope-cards')
expect(html).not.toContain('asset-box')
})
it('prints the non-summable rule and the defeso note', () => {
expect(html).toContain('Setor/rede não é a instituição')
expect(html).toContain('Não somar.')
expect(html).toContain('Nota de defeso eleitoral 2026')
})
it('renders the honors scene with date, nature and the reading panels', () => {
expect(html).toContain('Data, natureza e fonte primária')
expect(html).toContain('Como ler')
expect(html).toContain('Vínculo documentado')
expect(html).toContain('Honraria documentada')
it('renders the honors scene with date, nature and the reading rule', () => {
expect(html).toContain('Títulos, honrarias e vínculos')
expect(html).toContain('Data')
expect(html).toContain('Natureza')
expect(html).toContain('Como ler:')
})
it('escapes untrusted research text', () => {
@ -367,9 +378,16 @@ describe('renderDossierHtml/Md (institution unit)', () => {
it('mirrors the institution sections in markdown', () => {
expect(md).toContain('# Dossiê Solla por instituição — UFBA')
expect(md).toContain('## Abrangência: instituição × setor × rede')
expect(md).toContain('## Títulos, honrarias e vínculos')
expect(md).toContain('## Lacunas explícitas')
expect(md).toContain('[fonte](https://ufba.test/era_a_formacao)')
})
it('links every index entry to an anchor that exists in the companion', () => {
const links = [...md.matchAll(/\]\(#([^)]+)\)/g)].map((match) => match[1])
expect(links.length).toBeGreaterThan(0)
for (const anchor of links) expect(md).toContain(`<a id="${anchor}"></a>`)
})
})
describe('buildBulletin (institution unit)', () => {
@ -384,30 +402,27 @@ describe('buildBulletin (institution unit)', () => {
})
const html = renderBulletinHtml(bulletin)
it('is a single A4 page with the model label and identity pills', () => {
it('is a single A4 page with the model label and the institution name', () => {
expect(html).toContain('data-page="boletim"')
expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1)
expect(html).toContain('Modelo — insumo interno')
expect(html).toContain('identity-pill')
expect(html).toContain('UFBA')
})
it('declares no sources and carries the defeso band', () => {
it('declares no sources and carries the defeso footer', () => {
expect(html).not.toContain('source-link')
expect(html).not.toContain('Fonte:')
expect(html).toContain('defeso-band')
expect(html).toContain('sem CTA')
expect(html).toContain('Defeso eleitoral')
expect(html).toContain('Não publicar sem revisão')
})
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
expect(html).toContain('highlight-phase')
expect(html).toContain('phase-pending')
expect(html).toContain('empenhado')
expect(html).toContain('phase-paid')
expect(html).toContain('pago')
expect(html).toContain('bulletin-list')
})
it('renders the sparse variant when there are fewer than three facts', () => {
it('renders the sparse variant without filling the page', () => {
const sparse = buildBulletin({
facts: [report.bulletinFacts[0]],
identity: { name: 'UFBA', badges: ['Universidade'] },
@ -416,7 +431,7 @@ describe('buildBulletin (institution unit)', () => {
})
const sparseHtml = renderBulletinHtml(sparse)
expect(sparse.sparse).toBe(true)
expect(sparseHtml).toContain('lacuna-panel')
expect(sparseHtml).toContain('A página termina com espaço')
expect(sparseHtml).toContain('note-line')
})
})

View file

@ -110,19 +110,20 @@ describe('dossiê institucional sem caps + redação (revisão 2026-09-18)', ()
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
})
it('documents the opening letter, the synthesis and the charts', () => {
expect(skill).toContain('A contribuição (carta)')
expect(skill).toContain('**Síntese**')
expect(skill).toContain('Gráficos consolidados')
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
/O que esta era\s+entrega/,
)
it('documents the analysis-first sections and the sober design', () => {
expect(skill).toContain('O essencial')
expect(skill).toContain('Leitura entre eras')
expect(skill).toContain('O que Solla defende')
expect(skill, 'the era opens with its reading').toContain('Leitura da era')
expect(skill, 'the charts were retired').not.toContain('Gráficos consolidados')
})
it('pins the narrative contract and the writer receipt', () => {
expect(skill).toContain('## Recibo do redator')
expect(skill).toContain('"institutionSlug": "ufba"')
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
expect(skill, 'the narrative may carry the between-eras bullets').toContain('"betweenEras"')
expect(skill, 'the defense checklist item is documented').toContain('era_c_defesas')
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
})
})

View file

@ -13,6 +13,19 @@ const generatedAt = new Date('2026-09-17T12:00:00.000Z')
const snapshot = {
meta: { readAt: '2026-09-16T10:00:00.000Z', codeSha: 'abc1234', database: 'teqo_test' },
municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' },
speeches: {
topics: { themes: ['saude'] },
totalCount: 1,
rows: [
{
id: 31,
speechAt: '2024-05-10T00:00:00.000Z',
summary: 'Pronunciamento sobre saúde regional.',
officialTextUrl: 'https://camara.test/discurso-ilheus',
mentionExcerpt: 'a saúde da região',
},
],
},
}
const research = mergeDossierResearch([
@ -66,6 +79,14 @@ const research = mergeDossierResearch([
sourceUrl: 'https://camara.test/titulo',
sourceDate: '2026-09-10',
},
{
id: 'era_c_defesas',
position: 'Ensino superior',
answer: 'Defende campus da UFBA na região',
details: 'INC 1497/2023',
sourceUrl: 'https://camara.test/inc-1497',
sourceDate: '2023-09-22',
},
],
news: [
{
@ -73,6 +94,7 @@ const research = mergeDossierResearch([
url: 'https://jornal.test/materia',
publishedAt: '2024-06-01',
outlet: 'Jornal Local',
summary: 'Resumo datado da matéria.',
},
],
gaps: [],
@ -91,22 +113,56 @@ const report = buildDossierReport({
describe('renderDossierHtml', () => {
const html = renderDossierHtml(report)
it('anchors every page for the overflow guard and navigability', () => {
it('anchors every sheet for the overflow guard and navigability', () => {
for (const anchor of [
'capa',
'trajetoria',
'resumo',
'sintese',
'trajetoria',
'era-a',
'era-b',
'era-c',
'regiao',
'defende',
'lacunas',
'noticias',
'acervo',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
}
})
it('opens the document with O essencial, the facts and the index with page numbers', () => {
expect(html).toContain('O essencial')
expect(html).toContain('Fatos-chave')
expect(html).toContain('index-list')
expect(html).toContain('index-page')
expect(html).toContain('<span class="index-page tabular">2</span>')
})
it('reads the eras between themselves and prints the operational synthesis', () => {
expect(html).toContain('Leitura entre eras')
expect(html).toContain('Síntese operacional')
expect(html).toContain('Concentração.')
})
it('prints the position list of O que Solla defende and the missing-register line', () => {
expect(html).toContain('O que Solla defende')
expect(html).toContain('position-list')
expect(html).toContain('Ensino superior')
expect(html).toContain('Sem registro localizado.')
})
it('is a sober document: no charts, cards, colored badges or image placeholders', () => {
expect(html).not.toContain('chart-grid')
expect(html).not.toContain('action-grid')
expect(html).not.toContain('scope-cards')
expect(html).not.toContain('asset-box')
expect(html).not.toContain('NEEDS ASSET')
expect(html).not.toContain('data-page="graficos"')
})
it('escapes untrusted research text', () => {
expect(html).not.toContain('<script>alert(1)</script>')
expect(html).toContain('&lt;script&gt;')
@ -118,17 +174,20 @@ describe('renderDossierHtml', () => {
expect(html).toContain('https://exemplo.test/conquista')
})
it('keeps the region rule and the region/polo badge', () => {
expect(html).toContain('Região não é cidade')
expect(html).toContain('scope-region')
})
it('prints the defeso note', () => {
it('keeps the non-summable rule and the defeso note', () => {
expect(html).toContain('Não somar.')
expect(html).toContain('Nota de defeso eleitoral 2026')
})
it('renders the local honors only on their own era sheet (never on another era)', () => {
expect((html.match(/Títulos, honrarias e vínculos locais/g) ?? []).length).toBe(1)
it('renders the local honors once on the era-C sheet', () => {
const eraC = html.slice(html.indexOf('data-page="era-c"'), html.indexOf('data-page="regiao"'))
expect((eraC.match(/Título de cidadão honorário/g) ?? []).length).toBe(1)
})
it('prints the news with the summary and the era usage column', () => {
expect(html).toContain('Resumo datado da matéria.')
expect(html).toContain('Uso no dossiê')
expect(html).toContain('Era C')
})
})
@ -203,7 +262,7 @@ describe('renderDossierHtml — era continuation sheets', () => {
const report = buildDossierReport({ snapshot, research: manyActions, generatedAt })
const html = renderDossierHtml(report, { pack: { 'era:C': [1] } })
it('paginates era action cards into continuation sheets instead of dropping them', () => {
it('paginates era list items into continuation sheets instead of dropping them', () => {
expect(html).toContain('data-page="era-c"')
expect(html).toContain('data-page="era-c-2"')
for (let index = 0; index < 5; index += 1) {
@ -212,12 +271,59 @@ describe('renderDossierHtml — era continuation sheets', () => {
})
})
describe('renderDossierHtml — index fallback', () => {
it('drops the page numbers when the resumo sheet overflows (indexMode labels)', () => {
const html = renderDossierHtml(report, { indexMode: 'labels' })
expect(html).toContain('index-list')
expect(html).not.toContain('<span class="index-page tabular">2</span>')
expect(html).toContain('<span class="index-page tabular">—</span>')
})
it('keeps an omitted era in the index without a number or a page', () => {
const withoutEraA = mergeDossierResearch([
normalizeDossierResearchInput({
municipalitySlug: 'ilheus',
era: 'A',
researchedAt: '2026-09-16T10:00:00.000Z',
items: [],
news: [],
gaps: [],
}),
normalizeDossierResearchInput({
municipalitySlug: 'ilheus',
era: 'B',
researchedAt: '2026-09-16T10:00:00.000Z',
items: [
{
id: 'era_b_equipamentos',
answer: '1 hospital regional',
sourceUrl: 'https://saude.test/hospital',
sourceDate: '2026-09-10',
},
],
news: [],
gaps: [],
}),
])
const omittedReport = buildDossierReport({ snapshot, research: withoutEraA, generatedAt })
const omittedHtml = renderDossierHtml(omittedReport)
expect(omittedHtml).toContain('sem evidência nominal suficiente; consulte as lacunas')
expect(omittedHtml).not.toContain('data-page="era-a"')
const index = omittedHtml.slice(omittedHtml.indexOf('index-list'), omittedHtml.indexOf('</ol>'))
expect(index).toContain('<span class="index-number"></span>')
})
})
describe('renderDossierMd', () => {
const md = renderDossierMd(report)
it('mirrors the sections and keeps the sources clickable', () => {
expect(md).toContain('# Dossiê Solla por cidade — Ilhéus')
expect(md).toContain('## Linha do tempo da carreira')
expect(md).toContain('## Índice')
expect(md).toContain('## O essencial')
expect(md).toContain('## Leitura entre eras')
expect(md).toContain('## Trajetória completa')
expect(md).toContain('## O que Solla defende')
expect(md).toContain('## Região / polo')
expect(md).toContain('## Lacunas explícitas')
expect(md).toContain('[fonte](https://exemplo.test/conquista)')
@ -226,6 +332,12 @@ describe('renderDossierMd', () => {
it('carries the defeso note', () => {
expect(md).toContain('defeso eleitoral')
})
it('links every index entry to an anchor that exists in the companion', () => {
const links = [...md.matchAll(/\]\(#([^)]+)\)/g)].map((match) => match[1])
expect(links.length).toBeGreaterThan(0)
for (const anchor of links) expect(md).toContain(`<a id="${anchor}"></a>`)
})
})
describe('renderBulletinHtml', () => {
@ -249,8 +361,14 @@ describe('renderBulletinHtml', () => {
expect(html).not.toContain('portaldatransparencia.test')
})
it('prints the trajectory and the defeso note', () => {
expect(html).toContain('Desde 1999')
it('prints the highlights as a list and the four-period trajectory', () => {
expect(html).toContain('bulletin-list')
expect(html).toContain('period-list')
expect(html).toContain('Defeso eleitoral')
})
it('prints the defense block from the ledger', () => {
expect(html).toContain('bulletin-defense-list')
expect(html).toContain('Defende campus da UFBA na região')
})
})

View file

@ -43,15 +43,23 @@ describe('normalizeDossierResearchInput', () => {
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
})
it('normalizes the optional per-item summary without changing the source contract (C188)', () => {
const items = dossierChecklistForEra('C').map((item) => validItem(item.id))
items[0] = validItem(items[0].id, { summary: ' Recurso de R$ 1 mi empenhado em 2024. ' })
items[1] = validItem(items[1].id, { summary: ' ' })
const research = normalizeDossierResearchInput(eraResearch('C', { items }))
expect(research.items[0].summary).toBe('Recurso de R$ 1 mi empenhado em 2024.')
expect(research.items[0].answer).toBe(`Resposta de ${items[0].id}`)
expect(research.items[1].summary).toBeNull()
expect(research.gaps).toEqual([])
it('marks the defense checklist items with kind and normalizes the position label (C209)', () => {
const research = normalizeDossierResearchInput(
eraResearch('C', {
items: [
validItem('era_c_emendas'),
validItem('era_c_defesas', {
position: ' Ensino superior ',
answer: 'Defende campus da UFBA na região',
}),
],
}),
)
const defense = research.items.find((item) => item.id === 'era_c_defesas')
const evidence = research.items.find((item) => item.id === 'era_c_emendas')
expect(defense).toMatchObject({ kind: 'defense', position: 'Ensino superior' })
expect(evidence?.kind).toBe('evidence')
expect(evidence?.position).toBeNull()
})
it('normalizes the reformulated brief and drops it when it has no title', () => {

View file

@ -15,7 +15,7 @@ import { THEME_UNIT, themeSubjectPhrase } from '../../scripts/lib/dossieUnit.mjs
// C190: theme/area content model + render. Grounds the third recorte: area
// identity (label + canonical value), `area|segmento|rede` never summed, per-row
// phase/source, empty-era state instead of silent omission, and a one-page
// boletim that inherits only sourced facts.
// boletim that inherits only sourced facts. C209: sober redesign.
const generatedAt = new Date('2026-09-18T12:00:00.000Z')
@ -91,6 +91,10 @@ const research = mergeDossierResearch(
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
}),
item('era_c_titulos', 'C', { answer: 'Homenagem da área' }),
item('era_c_defesas', 'C', {
position: 'Educação básica',
answer: 'Defende o financiamento da educação básica',
}),
]),
{ unit: THEME_UNIT },
),
@ -165,13 +169,6 @@ describe('buildDossierReport (theme unit)', () => {
expect(listFor('network')?.total).toBe(1)
})
it('declares overflow instead of truncating silently (C188 rule)', () => {
for (const list of report.reach.lists) {
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
}
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
})
it('shows every sourced item — theme lists are never capped', () => {
const manyItems = [
item('era_c_emendas', 'C', { sphere: 'segmento' }),
@ -224,7 +221,7 @@ describe('buildDossierReport (theme unit)', () => {
expect(moneySynthesis.lines.join(' ')).toContain('R$ 1,2 mi pago')
const html = renderDossierHtml(moneyReport)
expect(html).toContain('R$ 1,2 mi')
expect(html).toContain('Recursos com execução por ano')
expect(html).toContain('Valores localizados, sem consolidar fases')
})
it('derives bulletinFacts only from sourced items', () => {
@ -249,15 +246,15 @@ describe('renderDossierHtml/Md (theme unit)', () => {
'capa',
'resumo',
'sintese',
'graficos',
'trajetoria',
'era-a',
'era-b',
'era-c',
'abrangencia',
'abrangencia-area',
'abrangencia-segmento',
'abrangencia-rede',
'defende',
'lacunas',
'noticias',
'acervo',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
@ -267,20 +264,21 @@ describe('renderDossierHtml/Md (theme unit)', () => {
expect(html).toContain('Homenagem da área')
})
it('prints the theme acervo scene with stats, canonical columns and the scope alert', () => {
expect(html).toContain('acervo-stat-grid')
expect(html).toContain('Universo recuperado')
expect(html).toContain('Amostra exibida')
it('prints the theme acervo scene with the canonical columns and the scope alert', () => {
expect(html).toContain('Tema canônico')
expect(html).toContain('Trecho/contexto')
expect(html).toContain('<code>educacao</code>')
// The editorial label carries the column; the canonical token lives in the
// identification table, never concatenated to the label.
expect(html).toContain('>Educação<')
expect(html).toContain('<dt>Token</dt><dd>educacao</dd>')
expect(html).not.toContain('Educação educacao')
expect(html).toContain(
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega',
)
expect(html).not.toContain('acervo-stat-grid')
})
it('prints the theme reach scene with the five columns and the ledger year', () => {
expect(html).toContain('document-table--reach')
expect(html).toContain('Era / ano')
expect(html).toContain('Valor / fase')
expect(html).toContain('Evidência temática')
@ -291,7 +289,7 @@ describe('renderDossierHtml/Md (theme unit)', () => {
expect(html).toContain('Já consultado')
expect(html).toContain('Próxima busca')
expect(html).toContain('prioridade')
expect(html).toContain('<span class="phase">aberta</span>')
expect(html).toContain('aberta')
expect(html).toContain(
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
)
@ -305,14 +303,12 @@ describe('renderDossierHtml/Md (theme unit)', () => {
expect(html).toContain('Não filtram por área')
})
it('uses the theme synthesis labels and the pela/pelo subject phrase', () => {
expect(html).toContain('Tipos de atuação com mais registros')
expect(html).toContain('Pontos com fonte por ano')
it('uses the theme subject phrase on the cover and the bulletin', () => {
expect(html).toContain('O que Jorge Solla fez pela Educação')
expect(html).toContain('pela Educação ao longo da carreira')
})
it('prints the synthesis lines and the consolidated charts', () => {
it('prints the between-eras reading, never the old charts', () => {
const synthesis = report.synthesis
expect(synthesis).toBeTruthy()
if (!synthesis) return
@ -328,11 +324,11 @@ describe('renderDossierHtml/Md (theme unit)', () => {
)
expect(sphereTotal).toBe(synthesis.totals.items)
expect(html).toContain('data-page="sintese"')
expect(html).toContain('data-page="graficos"')
expect(html).toContain('Síntese do que foi localizado')
expect(html).toContain('Gráficos consolidados')
expect(html).toContain('chart-grid')
expect(md).toContain('## Síntese do que foi localizado')
expect(html).toContain('Leitura entre eras')
expect(html).not.toContain('data-page="graficos"')
expect(html).not.toContain('Gráficos consolidados')
expect(html).not.toContain('chart-grid')
expect(md).toContain('## Leitura entre eras')
})
it('flows across continuation sheets when the pack plan splits a section', () => {
@ -369,12 +365,12 @@ describe('renderDossierHtml/Md (theme unit)', () => {
unit: THEME_UNIT,
})
const bigHtml = renderDossierHtml(bigReport, {
pack: { 'era:C': [1], 'scope:segmento': [1], news: [1] },
pack: { 'era:C': [1], scope: [1], news: [1] },
})
expect(bigHtml).toContain('data-page="era-c"')
expect(bigHtml).toContain('data-page="era-c-2"')
expect(bigHtml).toContain('data-page="abrangencia-segmento"')
expect(bigHtml).toContain('data-page="abrangencia-segmento-2"')
expect(bigHtml).toContain('data-page="abrangencia"')
expect(bigHtml).toContain('data-page="abrangencia-2"')
expect(bigHtml).toContain('data-page="noticias"')
expect(bigHtml).toContain('data-page="noticias-2"')
expect(bigHtml).toContain('continuação')
@ -383,15 +379,15 @@ describe('renderDossierHtml/Md (theme unit)', () => {
expect(anchors.length).toBe(bigReport.meta.pageTotal)
})
it('ports the theme vocabulary and identity badges', () => {
it('ports the theme vocabulary and the sober classes', () => {
expect(html).toContain('Dossiê temático')
expect(html).toContain('identity-badge')
expect(html).toContain('scope-sector')
expect(html).toContain('scope-network')
expect(html).toContain('phase-paid')
expect(html).toContain('phase-pending')
expect(html).toContain('Segmento/rede não é a área')
expect(html).toContain('index-list')
expect(html).toContain('position-list')
expect(html).toContain('phase-text')
expect(html).toContain('Uma ação dirigida a um segmento ou a uma rede')
expect(html).toContain('Segmento e rede não são somados à área')
expect(html).not.toContain('identity-badge')
expect(html).not.toContain('scope-cards')
})
it('prints the defeso note', () => {
@ -441,24 +437,20 @@ describe('buildBulletin (theme unit)', () => {
})
const html = renderBulletinHtml(bulletin)
it('is a single A4 page with the model label and identity pills', () => {
it('is a single A4 page with the model label and the subject phrase', () => {
expect(html).toContain('data-page="boletim"')
expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1)
expect(html).toContain('aria-label="Modelo de boletim informativo temático de uma página"')
expect(html).toContain('Modelo — insumo interno')
expect(html).toContain('identity-pill')
expect(html).toContain('Educação')
expect(html).toContain('O que Jorge Solla fez pela <span class="accent">Educação</span>')
expect(html).toContain('O que Jorge Solla fez pela <span>Educação</span>')
})
it('renders the theme pills with the canonical token in mono and the taxonomy note', () => {
expect(html).toContain('<span class="identity-pill">Área</span>')
expect(html).toContain('<span class="identity-pill identity-pill--code">educacao</span>')
expect(html).toContain('<span class="identity-pill">Taxonomia do acervo</span>')
expect(html).toContain('.identity-pill--code')
it('prints the defense block and the period list', () => {
expect(html).toContain('bulletin-defense-list')
expect(html).toContain('Defende o financiamento da educação básica')
expect(html).toContain('period-list')
})
it('falls back to the year and then to a textual card (never a fake zero)', () => {
it('falls back to the year and never prints a fake zero', () => {
const fact = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
era: 'C',
@ -482,27 +474,24 @@ describe('buildBulletin (theme unit)', () => {
})
const fallbackHtml = renderBulletinHtml(fallback)
expect(fallback.highlights[0]).toMatchObject({ number: '2019' })
expect(fallback.highlights[1]).toMatchObject({ number: null, textual: true })
expect(fallbackHtml).toContain('highlight-card--textual')
expect(fallback.highlights[1]).toMatchObject({ number: null })
expect(fallbackHtml).toContain('2019')
expect(fallbackHtml).not.toContain('>0<')
})
it('declares no sources and carries the defeso band', () => {
it('declares no sources and carries the defeso footer', () => {
expect(html).not.toContain('source-link')
expect(html).not.toContain('Fonte:')
expect(html).toContain('defeso-band')
expect(html).toContain('sem CTA')
expect(html).toContain('Defeso eleitoral')
expect(html).toContain('área, segmento e rede não são somados')
})
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
expect(html).toContain('highlight-phase')
expect(html).toContain('phase-pending')
expect(html).toContain('empenhado')
expect(html).toContain('phase-paid')
expect(html).toContain('pago')
})
it('renders the sparse variant when there are fewer than three facts', () => {
it('renders the sparse variant without filling the page', () => {
const sparse = buildBulletin({
facts: [report.bulletinFacts[0]],
identity,
@ -511,7 +500,6 @@ describe('buildBulletin (theme unit)', () => {
})
const sparseHtml = renderBulletinHtml(sparse)
expect(sparse.sparse).toBe(true)
expect(sparseHtml).toContain('lacuna-panel')
expect(sparseHtml).toContain('A página termina com espaço')
expect(sparseHtml).toContain('entrega exclusiva da área')
})
@ -553,7 +541,8 @@ describe('buildBulletin (theme unit, one-page fit)', () => {
expect(full.moreItems).toHaveLength(0)
expect(fitted.highlights).toHaveLength(2)
expect(fitted.moreItems).toHaveLength(0)
expect(fitted.factsPrinted).toBe(2)
// Two printed highlights plus the one defense (never dropped by the cap).
expect(fitted.factsPrinted).toBe(3)
expect(fitted.factsPrintable).toBe(facts.length)
expect(fitted.factsRemaining).toBeGreaterThan(full.factsRemaining)
expect(renderBulletinHtml(fitted)).toContain(

View file

@ -106,19 +106,20 @@ describe('dossiê temático sem caps + redação (revisão 2026-09-18)', () => {
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
})
it('documents the opening letter, the synthesis and the charts', () => {
expect(skill).toContain('A contribuição (carta)')
expect(skill).toContain('**Síntese**')
expect(skill).toContain('Gráficos consolidados')
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
/O que esta era\s+entrega/,
)
it('documents the analysis-first sections and the sober design', () => {
expect(skill).toContain('O essencial')
expect(skill).toContain('Leitura entre eras')
expect(skill).toContain('O que Solla defende')
expect(skill, 'the era opens with its reading').toContain('Leitura da era')
expect(skill, 'the charts were retired').not.toContain('Gráficos consolidados')
})
it('pins the narrative contract and the writer receipt', () => {
expect(skill).toContain('## Recibo do redator')
expect(skill).toContain('"themeSlug": "educacao"')
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
expect(skill, 'the narrative may carry the between-eras bullets').toContain('"betweenEras"')
expect(skill, 'the defense checklist item is documented').toContain('era_c_defesas')
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
})
})