feat(C191): gráficos de dados para Instagram — skill + builder local (Closes #1145)

This commit is contained in:
Francisco Solla 2026-09-18 16:32:15 -03:00
parent e5e1ae7ee8
commit e78088a8a7
19 changed files with 1868 additions and 112 deletions

View file

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

4
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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