Compare commits

...

11 commits

Author SHA1 Message Date
Francisco Solla
c5ccd8e0e7 style(potencial): formata o int spec após o rename do query param
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
2026-10-07 19:47:19 -03:00
Francisco Solla
c8badc8763 docs(potencial): impl plan e changelog da entrega S46
Impl plan com as decisões de engenharia (artefato vs collection, API,
cálculo compartilhado, ganho ≥ 0), as premissas assumidas (13 candidaturas
no bulk, X₁ não seletivo) e os débitos deferidos da triagem do simplify.
2026-10-07 19:47:19 -03:00
Francisco Solla
67d889661a feat(potencial): página pública, formulário e story 1080×1920
A rota estática /potencial informa UF/município/zona/seção do título e
mostra as três leituras da seção com o ganho em p.p., rotulado como cenário
hipotético, com a prévia do story (canvas real) e baixar/compartilhar. A
ilha trata carregando, resultado, não encontrada e sem dado; o combobox de
município busca a partir de 3 letras pelo normalizador dos pickers; foco
amarelo+separador escuro e alvos ≥44px. Arimo/Exo 2 passam a viver em
fonts.ts (owner único) para o canvas receber a família. E2E novo
frontendPotencial (projeto + manifest + prewarm no setup).
2026-10-07 19:47:19 -03:00
Francisco Solla
62b674cfba feat(potencial): cálculo puro, loader e rotas públicas da seção
sectionPotential.ts parseia/valida o shard (ordem fail-closed), busca por
seção e computa o cenário aprovado — válidos = nominais, X₁ = comparecimento
− Lula − Flávio (todos os terceiros; o bulk tem 13 candidaturas) e X₂ =
aptos − Lula − Flávio — com formatadores pt-BR e o nome do PNG. O loader
server-only lê os shards por fs com cache por processo; as rotas públicas
/api/potencial/{municipios,secao} aplicam same-origin, rate-limit e zod
strict, com 404 honesto e cache de um dia. O story 1080×1920 é desenhado
por um renderer puro com contexto estrutural. 'potencial' entra nos slugs
reservados e o módulo novo no pin do top-level de utilities.
2026-10-07 19:47:19 -03:00
Francisco Solla
6aad284e11 feat(potencial): artefato TSE 2026 por seção + ingestão nacional validada
O bulk oficial do TSE (votacao_secao_2026_BR + detalhe_votacao_secao_2026)
é ingerido uma vez em 27 shards por UF (497.897 seções, 13,6 MB) sob
public/dados/potencial-secao-2026/, com manifest de hashes. A ingestão
valida totais nacionais e do recorte BA pinados (aborta em divergência),
conta os votáveis Lula/Flávio vistos e faz merge do recibo em runs --only.
Cache gitignored em data/tse-2026/.
2026-10-07 19:47:19 -03:00
Francisco Solla
3e6afeb58f refactor(briefing-geral): usa dieWithLabel do cli compartilhado
Convenção do repo: o 'die' é importado de scripts/lib/cli.mjs, nunca reescrito no script (guard do codebaseConventions).
2026-10-07 13:01:31 -03:00
Francisco Solla
d6bdd9ff2d fix(reels): pina colinhaReel/colinhaReelTemplate no SCRIPTS_SPEC_PINNED
O invariante do ciSkipInvariants exige todo módulo de scripts/ alcançado por spec no mapa literal de blast radius — o unit novo da colinha puxa os dois libs.
2026-10-07 13:01:31 -03:00
Francisco Solla
c6d9a7009d docs(campanha): prompt do redesign do site público
Mission operacional para o designer/agentes: repensar as superfícies públicas de jorgesolla1313.com.br (campanha + editorial) como PR mergeável, com as leis da casa, escopo por rota e gate de design.
2026-10-07 13:01:31 -03:00
Francisco Solla
6c81769bb9 feat(briefing-geral): livretos de 1 folha e pesca mínima (BRIEFING-GERAL)
Impressor do Briefing Geral de Militância: lê os JSONs autorais do corpus em data/briefing-geral-militancia/ com falha fechada quando âncora ou lastro de proposta não resolve, e imprime cada recorte como livreto A5 de 4 páginas imposto em 1 folha A4 paisagem (frentes/versos), a pesca mínima de 8 páginas em 2 folhas, a edição completa e o guia de impressão. Paginação medida de verdade no Chromium (Playwright) com poda na ordem declarada, nunca cortando página no meio.

Corpus e saídas seguem gitignored (material interno num repo público); o script é o artefato versionado do fluxo.
2026-10-07 13:01:31 -03:00
Francisco Solla
9d6edd44c1 feat(analise): expansão por seção eleitoral 2022 — o voto que falta (EXPANSAO-SECOES)
Análise TSE 2022 na menor unidade publicada (seção): sobra do bloco Lula+Jerônimo por cargo (teto de transferência, max(0, min(Lula,Jer) − válidos)), piso/teto da pergunta literal, LQ e quebras por município, zona de Salvador, bairro do local de votação, RMS e Território de Identidade. Builder em duas fases: --compute faz a passagem pesada sobre os seis CSVs no cache gitignored data/tse-2022/ e grava o JSON; sem a flag só renderiza MD + PDF via Chromium (HTML intermediário também no cache).

Validação unidade a unidade contra o baseline commitado src/lib/electionAggregates/bahia-federal-baseline.json (435 municípios/zonas, sem divergência; resíduo de −0,023% nos nominais federais explicado por candidaturas invalidadas). Documento interno de análise (listas de ataque não viram material público); referenciado pelo plano S46.
2026-10-07 13:01:31 -03:00
Francisco Solla
a662c77072 feat(reels): colinha 1313 determinística e lote por dobradinha (REEL1–REEL3)
Builder do reel de conversa com roteiro/timeline puros em scripts/lib/colinhaReel.mjs (25 beats na ordem da urna) e template HTML/CSS 540×960@2x screenshotado quadro a quadro (sem animação CSS, sem drift); três finais no mesmo pacote (motion, estático e o card EXATO do site dirigido em produção) e lote por dobradinha ativa — 42 curadas da plataforma em scripts/reels/dobradinhas.json, com paralelismo e falha isolada por dobradinha. Trilha de 'confirma' da urna por card + cama do jingle desde o início do vídeo, montada num passe e mixada com -c:v copy.

Derivativos oficiais commitados em scripts/reels/assets/colinha-time-1313/ (proveniência e receitas no README da pasta): fotos/webp, logo da rádio, subset da Inter e os dois áudios do reel, que ganham exceção à regra global de áudio no .gitignore. package.json ganha reels:colinha e reels:dobradinhas.

Verificação: unit colinhaReel 6/6 (ordem da urna, números, timeline, template e ativos), eslint/typecheck/format:check/knip verdes.
2026-10-07 13:01:31 -03:00
88 changed files with 11319 additions and 6 deletions

6
.gitignore vendored
View file

@ -148,6 +148,11 @@ next-env.d.ts
# commits; every real jingle lives in the media collection.
!tests/fixtures/*.mp3
# C-reel — derivativos dos reels commitados em scripts/reels/assets/<slug>/
# (proveniência e receitas no README da pasta): o efeito "confirma" do TSE e a
# cama do jingle são derivados reprodutíveis e acompanham os demais ativos.
!/scripts/reels/assets/**/*.mp3
# opencode secrets (Penpot MCP token, etc.)
.opencode/secrets/
@ -167,6 +172,7 @@ next-env.d.ts
# Changelog aggregate is a regenerable artifact (OPS85) — entries live in docs/changelog/
/docs/CHANGELOG-AGENTS.md
/data/tse-2022/
/data/tse-2026/
# Briefing Geral de Militância (interno; repo público).
/data/briefing-geral-militancia/

View file

@ -0,0 +1,300 @@
# Mission: rethink and rebuild the public surfaces of jorgesolla1313.com.br
You are a senior art director, interaction designer, and frontend engineer working **inside the Teqo repository**. This is not a greenfield build: the codebase already contains the public campaign site, the legacy editorial site, the media/speech archives, the personalized-card studio, and approved design gates. Your job is to rethink the public experience end to end and deliver it as mergeable, deployable work — not a disconnected mockup.
All visitor-facing content must be in Brazilian Portuguese. All code identifiers stay in English (repo convention). Read `AGENTS.md` and `AGENTS-public.md` before touching anything.
---
## 0. Laws of the house (non-negotiable)
1. **Edit the owner, don't twin.** New behavior lands in the module that already owns the concern. A parallel file that reimplements an existing machine is a defect. Delete what your change supersedes in the same delivery (`.agents/rules/engineering-standards.mdc`).
2. **Visual structure belongs to the `designer`.** Any UI change requires the hi-fi HTML artifact (`docs/plans/<slug>-ui-design.html`) produced by the `designer` agent, per `.agents/skills/plan-issue/ui-design-html.md`. Implementation ports the artifact class-by-class. Do not improvise layout in code.
3. **Sem fonte, não publica.** Never invent facts, figures, quotes, achievements, testimonials, photos, or endorsements. Proposals, ongoing work, and completed outcomes must be distinguishable and dated. Flag source conflicts and outdated claims.
4. **LGPD fail-closed.** Public flows that capture or process personal data resolve `Consent` by stable key (`src/lib/campaignConsentKeys.ts`, `src/utilities/campaignConsent.ts`) and refuse to run while the approved row is missing. Biometric processing is a hard-gated special case (see §8).
5. **Public URL contracts are frozen** unless the delivery explicitly owns the change. Same for the media proxy, cache tags, consent keys, form behavior, and card outputs.
6. **Electoral visibility is fail-closed.** `hidden` tag + `isPostVisible` (`src/utilities/posts.ts`) must pull campaign content immediately, with no deploy. The mandate site and the campaign site do not mix without guidance (`AGENTS-public.md`, `docs/campanha/plano-site-campanha-2026.md` §6).
7. **Local databases only.** Never point anything at production `teqo_1313`. Dev uses `pnpm db:start` + `pnpm dev`; tests run on `_test` databases (`tests/helpers/assertTestDatabase.ts`). The repo is public: never commit secrets, dossier/research dumps, or PII.
8. **Delivery is a PR, not a branch dump.** `pnpm push` (never bare `git push`) → PR Ready via `node scripts/github-pr.mjs` (no `gh` installed) → required check `checks` → auto-merge → `main` triggers deploy (staging auto, production with human approval). One changelog entry per delivery in `docs/changelog/<date>-<id>.md`; never edit the aggregate.
9. **Design-provider policy.** Models under `openai/*` are reserved for the `designer` / `designer-campanha-solla` agents and only for creating/critiquing design. Everything else runs on the session default. One `designer` session for the whole batch of UI items (`.agents/skills/plan-issue/ui-design-html.md` §Escopo de dispatch).
10. **Gate commands:** `pnpm gate:fast` while iterating; `pnpm push` before pushing; `pnpm test:e2e:affected` for touched surfaces; `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm check:cycles`; migrations via `pnpm migrate:create <name>` + `pnpm migrate` + `pnpm generate:types` (never edit shipped migrations, `push:false` always).
---
## 1. Mission and scope
Redesign the public surfaces of `jorgesolla1313.com.br` — **both verticals**:
**Campaign / public voice** (theme `campaign-site`, layout in `src/app/(frontend)/(home)/layout.tsx`):
- `/` — campaign home (hero, provas, problema, bandeiras, história, jingles, cards, board de conteúdo, newsletter, rodapé)
- `/cards` — personalized card studio (must be **extended**, see §7)
- `/conteudos` + `/conteudos/[slug]` + `/midia` + `/frame` — content center
- `/corte/[id]` — published speech cuts
- `/jingles` — jingles + radio
- `/mandato-no-whatsapp` — WhatsApp community capture
- `/abaixo-assinado/[id]` — petitions + signature counter
- `/privacidade` — privacy policy global
**Legacy editorial** (theme `editorial`):
- `/artigos`, `/[type]`, `/[type]/[category]`, `/[type]/[category]/[slug]`, ShareLink routes and `/[type]/evento.ics`
Produces a staged program (see §15), not a big-bang rewrite. The homepage stays curated; deeper material lives in dedicated surfaces.
---
## 2. Source map (read before anything else)
**Primary source — Briefing Geral de Militância (the factual spine)**
- `/home/fsolla/Code/teqo/data/briefing-geral-militancia/` is the **main source** for Jorge Solla's work, positions, and propositions. Generated 2026-09-26; workspace-only (untracked; the repo is public — never commit or paste the ledgers wholesale). In a worktree, locate the same corpus or read it from the main checkout.
- Read-friendly full document: `docs/research/briefing-geral-militancia/jorge-solla-2026-09-26-briefing-geral.md` (+ `.pdf`, `livretos/`); generated HTML at `data/briefing-geral-militancia/jorge-solla-2026-09-26-briefing-geral.html`.
- Coverage: 15 themed recortes — saúde, cultura, educação, Salvador, UFBA, trabalhadores da saúde, agricultura familiar, ciência e tecnologia, soberania nacional, democracia, LGBTQIA+, anti-racista, feminista, interior, transversais — plus `biografia.research.json` and `geral.json` (consolidated `essay` "Quem é Jorge Solla", `closing` "O pedido", `pitch.speeches`).
- Per recorte: `<slug>.research.json` / `<slug>.research2.json` are the **fact ledgers** — `items[]` with `id`, `era` (A: carreira/formação/gestão até 2006 · B: SESAB/governo Wagner 2007–2014 · C: deputado federal 2015–2027), `answer` (year/phase explicit), `brief.title`/`brief.note` (approved short-copy seeds), `numbers[]` (with `phase`), mandatory `sourceUrl` + `sourceDate`, `extraSources`, and `gaps[]`. `<slug>.briefing.v2.json` + `<slug>.intro.v2.md` are the current writing round — **prefer v2 over v1** when both exist; `defenses[]`/`essential[]` anchor to `factId` + `sourceUrl`; `qa[]` is internal objection-handling. `falas.*.json` are militant scripts (placeholders like `[seu nome]`), not website copy.
- **Traceability rule:** every factual claim that goes on the site traces to a ledger item and carries its `sourceUrl`/`sourceDate`. No ledger item = `gaps[]` = do not publish.
- **Discipline baked into the corpus — enforce it:**
- Attribution with the right force: autoria, coautoria, relatoria, voto, articulação, and gestão are distinct; never credit collective or government results to Solla alone. The verifier contract is explicit: never "criou o SAMU" — the verified framing is that, as secretary, he took SAMU from 14 to 233 municipalities.
- Money always with year + phase (autorizado/empenhado/liquidado/pago); never sum phases or spheres; empenho ≠ pagamento.
- Status: projeto ≠ lei; "aprovado na Câmara" ≠ "aprovado"; re-check tramitação before go-live.
- No electoral scenario, vote estimates, or polls in public copy (prohibited keys in the corpus). Support is support, never a vote.
- `qa` side framing, pitches, and objections are capacitação material, not public copy — extract facts and positions, write the public text with `solla-comunicacao`.
- Contracts/provenance: `RESEARCH-CONTRACT.md` (eras, method, JSON schema), `AUTHOR-CONTRACT.md`, `DEEPEN-CONTRACT.md`, `VERIFY-CONTRACT.md`.
- The corpus is dated (2026-09-26): carry source dates and re-verify anything time-sensitive before publishing.
**Doctrine, approved copy, and site plans (reconcile against the briefing)**
- `docs/campanha/plano-site-campanha-2026.md` — narrative doctrine (§4.1.15: avanço → obstáculo → por que a eleição importa; the villain is never the current government), approved copies (§4.1.14/16), irrevocable rules (§6), pendências incl. NEEDS ASSET (§5), photo licensing map (annex).
- `docs/campanha/prompt-montagem-home.md` — approved home composition and copy. **Conflict rule:** any copy approved before the briefing that conflicts with it must be flagged and reconciled with `solla-comunicacao`/human, never silently kept — e.g. the hero sub "Criou o SAMU 192 e o Brasil Sorridente" vs the briefing's verified "levou o SAMU de 14 para 233 municípios".
- `docs/campanha/wireframe-solla-1313.html`, `AGENTS-public.md`, `docs/GUARDRAILS.md`.
**Voice and positions**
- Skill `solla-comunicacao` — tone, principles, positions. Load it before writing any copy. Apply `humanize-ai-writing` as the final pass (mandated in the plan §4.1.16).
- Dossiers (workspace-only, gitignored because the repo is public): `docs/research/dossie-solla-tema/`, `dossie-solla-instituicao/`, `dossie-solla-cidade/`, `docs/research/briefing-geral-militancia/`, generated outputs in `data/`. Read freely; **never commit their contents**. Produced by skills `.agents/skills/dossie-solla-*` and `briefing-capacitacao-solla`.
- Taxonomy for themes/topics: `src/lib/speechFacets.ts`, `src/lib/speechThemeTerms.ts`, `src/lib/speechGazetteer.ts`, `src/lib/institutionCatalog.ts`, `src/lib/publicFigureCatalog.ts`.
**Speeches / video / audio**
- `Speech` + `SpeechSegment` — ~997 Câmara speeches backfilled (54ª=1, 55ª=348, 56ª=414, 57ª=234), 947 with VOD, 924 with video, 920 with timestamped ASR; official transcript + `officialTextUrl`; YouTube/VOD URLs; curation fields `topics`, `scopes`, `mentionedMunicipalities/People/Programs/Projects`. Web speeches via `pnpm falas-web:import`; uploaded recordings in `Recording`/`RecordingSegment`.
- Public publication paths only: `SpeechCut` → `/corte/<id>`; `ContentPiece` (video/áudio/texto/foto/card) → `/conteudos`; `Jingle` → `/jingles`. Never expose `internetSpeechMedia`, `recordingMedia`, `contentMedia`, or raw `Speech` rows publicly.
- Browse/search: `/admin` (group "Comunicação"), `/campanha/comunicacao/acervo`, `pnpm acervo:index` for semantic search.
**Photos**
- `ArchivePhoto` — 6,577 Flickr originals (C231), catalogued by C232: `takenOn`, `catalog.scene`, `catalog.themes`, `catalog.people` (curated text only, never face-inferred), `catalog.municipality`, `hasPeople`, `sourceUrl`, licensing. Private — campaign access only.
- Public today: `media` via same-origin proxy `/api/media/file/...`; legacy assets in `public/`; official kit in `public/campaign-kit/`.
- Planned public surfaces (read and follow their plans, do not re-spec): `/fotos` album + facets (C233, issue #1368, `docs/plans/album-fotos-publico-busca.md`) and selfie match (C234, issue #1369, `docs/plans/busca-fotos-por-selfie.md`), each with an approved UI design HTML.
- Local photo bank cited by the plan: `/home/fsolla/Documentos/Solla/MATERIAL SOLLA/FOTOS/SELEÇÃO/`. Licensing: assets from jorgesolla.com.br are safe; third-party photos (Agência Brasil, BNews, A TARDE) need authorization.
**Design**
- `DESIGN.md` — tokens and standing visual rules; §7 Evolution is the living owner. Update it (with rationale) when a better pattern is decided.
- Approved gates: `docs/plans/*-ui-design.html` (home, cards, conteúdos, jingles, corte, álbum, selfie). Read them before proposing a new direction; if you supersede one, produce a new designer artifact and update `DESIGN.md` §7.
- `public/campaign-kit/README.md` (official palette and asset quirks) and `docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf`. The kit is an archive to use, not a straitjacket.
- `docs/design-refs/`, `.impeccable/design.json`, skill `awesome-design-md` (reference library), Penpot via the `penpot` subagent (the current home geometry is Penpot-derived).
**Existing behavior to preserve** (verify live at 390px and 1280px before believing this list)
- Card studio: `src/components/cards/*`, `src/lib/cardModels.ts` (6 models), `cardRender.ts`, `stateDeputyCatalog.ts` (53), on-device cutout via MediaPipe, anonymous download telemetry (`sendCardDownloadEvent` → `/api/content-events`), `?model=` deep links.
- Forms/actions: `submitCampaignNewsletter` (`campanha-novidades`), `submitWhatsapp` (`whatsapp-inscricao`), `submitPetitionSignature`, `getSignatureCount`; transactional multi-collection writes.
- Feeds: `CampaignContentSection` merges published `Post` + YouTube + Instagram snapshots (`SocialFeedSettings` global, fail-closed with last snapshot).
- E2E to keep green: `tests/e2e/frontend*.spec.ts`, `frontendConteudos`, `frontendJingles`, `frontendShareLink`, `campaignNewsletter`, `campaignHomePixel`.
**Process**
- `.agents/rules/engineering-standards.mdc`, `.agents/rules/agent-pr-workflow.mdc`, `docs/AGENT-OPS.md`, `docs/ops/teqo-1313-deploy.md`, skills `payload-migrations` and `local-database`.
---
## 3. How to work
Use sub-agents to keep the main context lean. The main agent owns creative direction, decisions, integration, and final verification.
- **`designer`** — creates/critiques the hi-fi artifacts. One session for the batch of UI items. `designer-campanha-solla` for campaign-site design questions; `designer-degraded` only as declared fallback (marked `DEGRADED`, human sign-off required). Never skip design silently.
- **`explore`** — codebase mapping. **`general`** — bounded research. **`penpot`** — geometry from Penpot. **`postgres`** — read-only local DB queries. **`solla-comunicacao`** — voice review of copy. Dossier researcher agents for bounded dossier extraction.
- Give each sub-agent a narrow objective, the exact source paths, constraints, and an output format. **Artifacts on disk, summaries in chat:** findings go to `docs/plans/<program-slug>-*.md` (committed) or a scratch directory (uncommitted); the return message is concise with file paths, exact filenames, and video timestamps.
- Parallelize independent work; keep dependent decisions sequential. Assign file ownership to avoid concurrent edits to shared styles, routing, or dependencies. Main agent reconciles and verifies the integrated result.
- Maintain a running program record: plan, design decisions, selected content/media with sources, preserved functionality, verification results. It must be enough to resume after compaction.
---
## 4. Phase 0 — audit, program plan, design direction (the gate)
Before changing anything:
1. **Audit** the live site and the repo. Produce an inventory of routes, navigation, deep links, content sources, integrations, forms/consent, media controls, downloads, generated-card outputs, metadata/social previews, and the exact contracts to preserve. Record it. Also cross-check every current claim and approved copy (live site, `plano-site-campanha`, `prompt-montagem-home`, gated designs) against the briefing ledgers. Produce a reconciliation list: claim → ledger `factId`/`sourceUrl`, or unsourced/outdated/conflicting (with the suggested correction). This list is a deliverable and the input for copy revision.
2. **Program plan**: break the mission into shaped slices (campaign home/shell, editorial surfaces, kit wizard, `/fotos` album, selfie match) with acceptance criteria, appetite, and dependencies. Write it to `docs/plans/`.
3. **Design direction**: have the `designer` produce the hi-fi artifacts for the first slices — real tokens, real copy, scenes at 390/1280, critical states (empty, loading, fail-closed). These artifacts are the source of truth for implementation.
4. **Present the audit, plan, and artifacts for explicit approval** before porting. Routine engineering decisions are yours; material visual changes wait for the gate.
Then execute slice by slice (§15).
---
## 5. Content and narrative rules
- **Curation over volume.** Choose material for relevance, clarity, visual quality, and narrative contribution. Let strong discoveries influence design. If you cannot review everything, prioritize a representative selection and record coverage.
- **The briefing is the factual spine.** Public copy states facts traceable to a ledger item, with the source's own force: attribution level correct, money with year + phase, status current. Internal Q&A framing and pitches never become public copy. Where the briefing and older approved copy disagree, the briefing wins and the conflict is flagged.
- **Source-grounded case studies.** For public work: issue, Solla's role, action, date, documented status/result, link to source. Keep proposal vs. approved vs. delivered visibly distinct.
- **Approved copy is preserved in meaning.** You may improve structure/readability and write new source-grounded summaries; revisions to approved texts go through `solla-comunicacao`.
- **Dated history.** Historical statements stay dated; past positions are not presented as current commitments.
- **No invented assets.** Testimonials stay `NEEDS ASSET` until real ones (name + city + photo) exist. Missing photos are flagged, never replaced with stock imagery.
- **Conversion doctrine:** one primary CTA per page; WhatsApp (`wa.me`) is the main conversion channel; donations only via `apoiar.me/jorgesolla` (never processed in-app); minimum form fields; never CPF/address in the first interaction; mobile-first with inputs ≥16px and touch targets ≥44px.
- **Electoral footer identification** stays (name, 1313, federation, CNPJ, election date).
---
## 6. Page narrative and per-surface requirements
### Campaign home
- **Opening:** memorable composition around Solla's portrait or documentary footage, name/office/number/approved message. Recompose mobile deliberately. No intro sequence that delays access.
- **Principles and priorities:** editorial sequence pairing positions with photos or speech excerpts; sticky heading/image on desktop, natural vertical reading on mobile.
- **Public work:** source-grounded case studies.
- **Biography/trajectory:** dossier + photo archive; integrate the existing biography video; timeline only where dates and material are accurate.
- **Speeches and video:** small selection of substantive excerpts with titles, dates, captions, and full-source links; browsable collection via existing `/conteudos` (never hundreds of players on one page).
- **Updates:** restyle posts + YouTube + Instagram into a coherent board; preserve sources, snapshots, destinations, and electoral pull-down.
- **Campaign media and participation:** radio, jingles (+downloads), card studio entry, WhatsApp CTA, newsletter.
- **Footer:** identification, privacy, official links.
### Editorial surfaces
Restyle `/artigos`, type/category listings, article detail, and ShareLink pages into the same editorial language while preserving: canonical URL redirects, JSON-LD (`Article`), OG metadata, ISR caching and `posts` revalidation, and the hidden-tag pull-down. Never translate the `type` enum values or SEO slugs — they are data.
### Deep links and metadata
Preserve every public URL and anchor behavior (`#cards`, `?model=`, ShareLinks, `.ics`). Update metadata/social previews where the redesign requires, keeping canonical and OG correctness.
---
## 7. New flow — Support Kit (guided wizard over `/cards`)
Build a **guided, step-by-step support-kit flow** that extends the existing card studio (`src/components/cards/*`, `src/lib/cardModels.ts`, `src/lib/cardRender.ts`). Do not create a second renderer or a parallel tool: the wizard is orchestration over the existing models, rendering, cutout, and download paths.
**Steps (progressive disclosure — each step previews only the pieces whose inputs are already satisfied):**
1. **Nome** (required for name pieces) — validates like the current name flow (60 chars, fitted two-line/banner logic), live preview of `eu-sou-solla`.
2. **Foto de perfil** (optional; enables photo pieces) — local file only; on-device cutout + tone harmony (reuse `useCardCutout`/MediaPipe); previews `perfil-quadrado` and `perfil-retangular`. **The photo never leaves the browser.** Skip is allowed.
3. **Dobradinha** (optional) — "Você já tem candidato(a) a deputado(a) estadual?" → picker over `stateDeputyCatalog.ts` (53). Chosen: preview `time-do-estadual`; not chosen: preview `time-de-voce`. `minha-colinha` follows the existing input rules in `cardColinha.ts` (verify, don't assume).
4. **Kit completo** — grid of every generated piece with thumbnails; download individually and a "baixar todo o kit" action. Keep the existing file-naming contract and the anonymous download telemetry (`sendCardDownloadEvent`). Decide the batch-download mechanism at implementation; justify any new dependency (a zip via `CompressionStream` or sequential downloads are acceptable).
5. **Fotos com o Jorge Solla** (only when the C234 gate is open — §8) — after the selfie step, show the matches found in the public album, a few thumbnails, and "ver todas" leading to the full result in `/fotos`; honest empty state when there are none. No score, no third-party names.
**Acceptance:**
- Skipped inputs never render unfinished art.
- Back/forward keeps entered state; the flow is resumable without losing work.
- Deep links (`?model=`, home `#cards` embed) keep working; the wizard is an _additional_ path, not a replacement.
- Accessible: labels, focus order, keyboard, error recovery (bad file type/decoder failure), reduced motion.
- Mobile drawing parity with desktop.
- If the flow ever captures name/WhatsApp for follow-up, it uses the Consent-by-key + transactional `Contact`/`Subscription` pattern — otherwise nothing is persisted.
---
## 8. `/fotos` album and selfie match — C233/C234 behind their gate
Both features are already shaped with owner plans and approved UI artifacts. **Read and follow them** — they are the spec; do not re-invent the flows or their guardrails:
- `docs/plans/album-fotos-publico-busca.md` (C233, #1368) — public `/fotos`: browse/search by date, municipality, activity, public person (curated catalog only), and term; only curation-approved photos; removal channel ("é você nesta foto? peça a remoção"); honest empty state; no vanity metrics; same-origin media proxy; cache tag + revalidation allowlist.
- `docs/plans/busca-fotos-por-selfie.md` (C234, #1369) — selfie match: consent fail-closed by stable key (without the approved row the flow does not open, not even in the UI); selfie processing on-device by default (server receives at most the vector, never the image); **no score/percentage**; **no third-party names**; antiabuse rate limit; removal/opt-out with a registered human queue; result is only the approved public album.
**Hard dependencies and gates:**
- C231/C232 must have produced catalogue data; C233 must exist before C234 adds the selfie step. Implement in that order.
- The consent row for the selfie flow is created only with **recorded legal review + DPIA**. If that approval is not registered, do not seed the row — the existing fail-closed mechanism keeps the surface dark, and the human gate decides when it opens.
- Election calendar (04/10/2026): the C233 plan recommends **not opening new public surfaces in the final days of the campaign**. Surface this to the human gate; default to closed. Never fake an approval.
- Never cross the facial index with `Contact`/leadership, never identify third parties, never build a face directory or social features.
---
## 9. Video and photographic storytelling
- Every selected speech excerpt records the original source, date, and exact time range (use `SpeechSegment` timestamps; verify captions against audio). Choose excerpts that complete a thought; never splice remarks into a new statement.
- Two treatments: **decorative footage** (short muted loop, immediate poster, visible pause) and **substantive speech** (user-initiated, captions, title, full-source link).
- Decorative media never autoplay sound; radio and audio are always user-initiated. Respect reduced motion.
- Never present unrelated photos as documentation of an achievement. Missing production assets are identified, never faked. If editing tools are available, produce optimized clips/posters; otherwise deliver an exact edit list.
---
## 10. Creative direction
Create a modern editorial/documentary aesthetic: confident typography, authentic photography, deliberate color, generous spacing, purposeful motion. Visitors must quickly grasp who Solla is, what he stands for, what he has done, what he intends to pursue, and where the evidence lives.
- Preserve the campaign identity: official logos, number 1313, brand colors (extract from `public/campaign-kit/`, refine application using `DESIGN.md`).
- References to study (when browsing is available): `zohranfornyc.com` (campaign identity), `patagonia.com/films` (documentary storytelling), `lusion.co` (intentional motion). Also use `docs/design-refs/` and `awesome-design-md` locally. Borrow craft, develop an original composition.
- Strong display typeface paired with readable body type; consistent tokens for type, spacing, color, interaction. The existing fonts are Inter/Exo 2/Arimo (+ Brexter for card canvas).
- Build visual rhythm through varied compositions (expansive photography, color fields, asymmetry, editorial passages, compact factual summaries, framed video). Avoid repeating one card grid everywhere.
- Use authentic archival assets; preserve faces and context when cropping; deliberate mobile crops; identify missing assets.
---
## 11. Motion
One signature interaction, supported by a restrained system.
- Suggested signature: as visitors scroll principles/public work, successive text chapters pair with changing photographs — sticky image area on desktop, inline imagery on mobile.
- Supporting motion: brief hero stagger; section reveals with opacity + ~16–24px movement; occasional photo reveals; crisp hover/focus/pressed states; compact sticky navigation; smooth expanded-content transitions.
- Timing: ~450–650ms for larger reveals, 150–220ms for control feedback; consistent and interruptible.
- Avoid scroll hijacking, long loaders, custom cursors, excessive parallax, content-obscuring effects.
- Respect `prefers-reduced-motion`; provide static equivalents and pause controls for decorative movement. Essential content must survive animation failure.
---
## 12. Performance, accessibility, maintainability
- Mobile layouts deliberate: readable type, comfortable targets, sensible section lengths, careful crops. Loading budget from the plan: <3s.
- Semantic headings, accessible names, keyboard navigation, visible focus, readable contrast. Sticky elements must not obscure focused controls or anchor destinations.
- Responsive images with reserved dimensions, immediate video posters, lazy-loaded nonessential media, no layout shift. Same-origin media proxy and Next image allowlist respected.
- Keep content and tokens easy to maintain; reuse existing CMS patterns; no unnecessary dependencies.
---
## 13. Preserve working behavior
Maintain existing URLs/deep links, integrations, content sources, form validation, consent choices, media controls, download behavior, and generated-card outputs. Preserve metadata/sharing previews, updating them only where the redesign requires.
Do not replace genuine integrations with mock success states. Every visible action must work or be explicitly identified as incomplete. Do not submit real supporter data or trigger external communications during verification — use local/test facilities.
---
## 14. Verification and proof
For each slice:
1. Inspect the rendered result at 390px and 1280px (element screenshots; the campaign home has a nested scroll container, so full-page captures truncate).
2. Run the touched e2e specs plus `pnpm test:e2e:affected`.
3. Run `pnpm gate:fast`, then `pnpm push` (which runs `gate:ci`). Never pipe gate commands.
4. Have the `designer` critique the rendered implementation against the approved artifact (`web-design-guidelines` findings with `file:line` where it is code).
5. Verify critical interactions manually: signup, consent refusal states, audio playback, downloads, kit generation (name/photo/dobradinha paths), `/fotos` facets, selfie refusal when the consent row is absent.
6. Record evidence: what changed, what was verified, what remains limited, what needs production (assets, legal, captions).
Never verify against production data or write to production.
---
## 15. Delivery
Execute as staged PRs; each UI slice gets its design artifact before porting:
- **Phase 1** — audit + program plan + design direction (gate).
- **Phase 2** — campaign shell + home.
- **Phase 3** — editorial surfaces.
- **Phase 4** — support-kit wizard (independent of C234).
- **Phase 5** — `/fotos` album (C233).
- **Phase 6** — selfie match (C234), gated and dark until approval.
**Definition of done per PR:** artifact approved; implementation ported; gates green; touched e2e green; preserved contracts re-verified; changelog entry added; PR description states design tier (if applicable), what changed, evidence, and remaining limitations; staging deploy green. Production publication is a separate, human-approved step.
**Deliverables:** mergeable PRs with a working staging preview; a concise record of visual/structural changes; the selected content/media map with sources; verified functionality and limitations; missing assets/production work.
---
## 16. When to ask
Decide routine design and engineering matters autonomously. Ask only when genuinely blocked, and be specific:
- Legal/DPIA status for the selfie surface (and the election-timing decision).
- Real testimonial assets and third-party photo licenses.
- Whether the redesign may supersede an approved design gate (if yes, it gets a new artifact).
If repository access is missing, explain what is needed — never build a disconnected replacement and present it as a modification of this app.

View file

@ -0,0 +1 @@
**BRIEFING-GERAL — livretos de 1 folha e pesca mínima do Briefing Geral de Militância (2026-09-28):** o corpus interno do briefing ganha o impressor `scripts/build-briefing-militancia-livretos.mjs`: lê os JSONs autorais já existentes em `data/briefing-geral-militancia/` (`<slug>.briefing.v2.json` quando houver, senão `<slug>.briefing.json`; idem intros e o ledger `research*.json`) e as propostas do próximo mandato em `propostas.json` — a linha "Propostas" é a proposta de Solla para a área, nunca o plano de conversa do militante —, e imprime cada recorte como livreto A5 de 4 páginas imposto em 1 folha A4 paisagem (frentes/versos, dobra no meio), mais a "pesca mínima" que compila os recortes em 8 páginas (2 folhas), a edição completa num documento só e o guia de impressão em 1 página A4. A paginação é medida de verdade no Chromium (Playwright): um probe mede a altura de cada unidade e o packer distribui por página, podando na ordem declarada (qa → defesas → intro no recorte; fez/defesas no compilado) quando não cabe — nunca cortando uma página no meio; o build falha fechado quando uma âncora ou o lastro de uma proposta não resolve (nada é inventado). Saídas em `docs/research/briefing-geral-militancia/livretos-1folha/` (`<nn>-<slug>.pdf` + frentes/versos, `00-pesca-minima*`, `00-edicao-completa*`, `00-guia-de-impressao.pdf`, `livretos.json` e `LEIA-ME.txt`) — diretório gitignored pelo mesmo motivo do corpus: material interno num repo público; o script é o artefato versionado deste fluxo.

View file

@ -0,0 +1 @@
**EXPANSAO-SECOES — "Onde está o voto que falta": análise por seção eleitoral de 2022 (2026-09-30):** a reta final ganha a leitura por seção eleitoral (a menor unidade publicada pelo TSE) do eleitor que votou Lula e Jerônimo em 2022 e não escolheu deputado, cruzada com a força de Solla (1313) e do campo PT 13xxx por cargo. O builder `scripts/build-expansao-secoes-2022.mjs` roda em duas fases — `--compute` faz a passagem pesada sobre os seis CSVs públicos do TSE (votação por seção BA/BR, detalhe por seção, detalhe por munzona, consulta de candidaturas e cadastro de locais de votação) direto do cache gitignored `data/tse-2022/` e grava o JSON de dados; sem a flag, só renderiza MD + PDF (Chromium) a partir dele, com o HTML intermediário no mesmo cache. A métrica central é a "sobra do bloco Lula+Jerônimo" — `max(0, min(Lula, Jerônimo) − válidos do cargo)` —, um TETO de transferência (ninguém observa voto individual), com piso/teto explícitos para a pergunta literal, LQ por território e quebras por município, zona de Salvador, bairro do local de votação, RMS e Território de Identidade. A validação confere unidade a unidade contra o baseline TSE já commitado (`src/lib/electionAggregates/bahia-federal-baseline.json`, 435 municípios/zonas, sem divergência para Solla, Lula, Jerônimo, comparecimento, brancos e nulos; resíduo de −0,023% nos nominais federais explicado por candidaturas que o TSE invalidou e o baseline de válidos não conta); cobertura de 417 municípios, 34.424 seções e 150 bairros de Salvador. Entregas commitadas: `docs/research/analise-expansao-secoes-solla-julio-2022.md` (1.315 linhas) e o PDF; dados e HTML ficam no cache gitignored. O plano S46 (`potencial-secao-story`) referencia o recorte como precedente de fonte. Regras de uso no próprio documento: as listas de seções de ataque não viram material público nem prioridade declarada — o dado indica onde, nunca como.

View file

@ -0,0 +1 @@
**REEL1 — Reel "Sua colinha para 2026" (Time 1313): o chat que monta a colinha na ordem da urna (2026-09-30):** a comunicação ganha o reel criativo em formato de conversa de WhatsApp, inspirado no reel do `dep_padrejoao` (PT-MG) e refeito com a identidade da campanha. O builder é determinístico e local (`pnpm reels:colinha` → `scripts/build-colinha-reel.mjs`): o roteiro/timeline vivem em `scripts/lib/colinhaReel.mjs` (25 beats, ordem da urna — `1313` Jorge Solla federal, `13999` Julio Pinheiro estadual, `130` Jaques Wagner 1ª vaga e `133` Rui Costa 2ª vaga de senador, `13` Jerônimo governador, `13` Lula presidente), o chat é um template HTML/CSS 540×960@2x (`colinhaReelTemplate.mjs`, fontes Inter/Brexter/Arimo e imagens em data URI) e cada quadro é um screenshot com o estado da página dirigido só por `window.colinhaRender(t)` — sem animação CSS, sem drift. O desenho porta a linguagem do reel de referência com o kit 1313: cabeçalho `⭐ Time 1313` com a logo "O" da rádio (kit oficial) no avatar, balões roxos do eleitor e cinza do time, card de urna por candidato — foto oficial recortada com enquadramento por candidato (`photoZoom`/`photoShiftY`, sempre cabeça inteira), `PT ★`, `BA · ELEIÇÕES 2026`, caixas de dígito centralizadas e o nome em Inter Black (a fonte do site; a primeira passada usou a Brexter e o gate humano pediu a troca) —, bolha "anotei:" com barra de progresso vermelho→azul (1/6 a 6/6), "Visto" e indicador de digitação animados por relógio, e o fecho em flip 3D para a colinha oficial (`public/cards/modelo-colinha.jpeg`), em **três versões alternativas** no mesmo pacote: `reel.mp4` (a colinha se monta sozinha), `reel-estatico.mp4` (a colinha pronta entra de uma vez, só com a transição do flip) e `reel-card.mp4` (a mesma transição, mas com a colinha EXATAMENTE como o card personalizado do site a gera — o builder dirige o estúdio de cards em produção, escolhe o estadual e levanta o canvas 1080×1920 que o próprio renderer do site pintou, guardando o PNG cru em `colinha-site.png`; `--finale=motion,static,card`, `--base-url=` para staging/local). Na versão em motion a cena é composta em camadas — a foto oficial do grupo da família "Time de você"/dobradinha (`julio-fotos.webp`, um só clique, todo mundo na mesma escala) preenche a faixa de foto da arte, recortada pelo polígono da onda (medido coluna a coluna) para o corpo sumir atrás da faixa vermelha; cada candidato entra por foco, com o patch desfocado da própria foto sumindo no seu beat (Solla, Julio, Wagner, Rui, Jerônimo e Lula — Rui e Wagner atrás de Julio, como na foto), cada linha da colinha é mascarada e revelada junto (a do estadual é a linha desenhada pela geometria S31/S34: máscara + `DEPUTADO ESTADUAL` + `JULIO PINHEIRO` + os cinco dígitos nas caixas impressas, na Arimo que espelha o render do site) e cada logo entra junto na faixa vermelha, cuja crista amarela/verde é preservada pelo recorte pelo topo da própria faixa — a logo quadrada do Solla (nome, `DEPUTADO FEDERAL` e `1313` empilhados, do overlay oficial da dobradinha) à esquerda, a do `JULIO PINHEIRO 13999` ao lado e as quatro marcas (LULA, JERO, RUI, WAGNER) menores num grid 2×2 à direita, todas com o vermelho da faixa virando alfa; por último aparece o `MAIS SAÚDE MAIS FUTURO`, com a colinha completa. A tela final do santinho da dobradinha saiu a pedido do gate humano (o reel termina na colinha). O pacote sai em `data/reels/colinha-time-1313/` (gitignored): `reel.mp4` 1080×1920 · 30 fps · H.264 · 30,1 s · 904 quadros · **sem áudio** (a trilha entra no app, como na referência), `capa.png` (corte 4:5 do quadro t=7800 ms, com o card do Solla e o número do estadual) e `metadata.json` (hash do roteiro, números do ticket, `coverAtMs`). As fotos oficiais foram derivadas para `scripts/reels/assets/colinha-time-1313/` (Solla da `FOTO SOLLA CAMISA BRANCA` do kit, Julio da extração oficial em `DOBRADINHAS-ORGANIZADAS`, os demais do site, a logo "O" da rádio e a Inter subset latim — proveniência no README da pasta); nenhum ativo do reel de referência (Meta) entra na peça. Verificação: unit novo (`colinhaReel`: ordem da urna, números, ask→número→card, timeline cumulativa com o `hideAtMs` do typing, template com todos os beats/cards/telas e os ativos commitados), `--dry-run`, `--preview-frames`/`--cover-at` para o gate visual e o pacote real conferido quadro a quadro.

View file

@ -0,0 +1 @@
**REEL2 — Um reel-card por dobradinha ativa (2026-10-01):** o reel de conversa que termina na colinha EXATA do card personalizado do site (`--finale=card`) passa a ser gerado em lote, um por dobradinha registrada na plataforma de gestão. A lista é a da produção (`state_deputy` × `contact`) cujo nome NÃO contém o marcador temporário `(apagar)` da assessoria — 53 registros, 7 marcados, 46 ativos; 4 ficam fora por não terem par no catálogo do site (Gildo Baleia, Juvenilson, Rafael Meireles) e 1 por duplicidade (Geane Vasconcelo ACS), resultando em **42 dobradinhas** curadas em `scripts/reels/dobradinhas.json` (slug, nome do catálogo, número, pasta). As fotos são as dos cards da Plenária da Vitória (`DOBRADINHAS-ORGANIZADAS/<pasta>/foto.png`, recortes oficiais com alfa), normalizadas em memória pelo builder (trim + 1000 px + webp) — nada de foto nova é commitada. O builder ganhou `--estadual-name/--estadual-number/--estadual-photo/--site-deputy/--out-slug` (troca o 2º candidato do ticket e o estadual escolhido no estúdio em produção) e o nome longo do card encolhe em uma linha (medição por canvas) em vez de quebrar. O driver `pnpm reels:dobradinhas` (`scripts/build-dobradinha-reels.mjs`) roda os 42 reels com paralelismo (`--jobs`, padrão 3), falha por dobradinha sem derrubar o lote (uma nova tentativa resolveu 3 falhas transitórias do estúdio) e aceita `--only=slug,slug`, `--photos-dir=`, `--skip-existing` e `--dry-run`. Pacotes em `data/reels/colinha-<slug>/` (gitignored, copiados para o repo principal): `reel-card.mp4` 1080×1920 · 30 fps · 29,1 s · sem áudio, o `colinha-site.png` cru do site, `roteiro.md` e `metadata.json` (título com a dobradinha, hash do roteiro, artefatos reais). Verificação: dry-run do lote, unit do `colinhaReel` (o finale `card` já pinado), checks verdes e conferência visual por amostragem (Coletivo de Enfermagem 13763, Rowenna 13456, Yulo 13234, Osni 13013 e Arthur Lisboa 55255 — este também exercitou o encolhimento do nome longo). Em 2026-10-01 o pacote ganhou o **"confirma" da urna**: o efeito (som do TSE extraído de gravação pública, `scripts/reels/assets/colinha-time-1313/sfx-confirma.mp3`, receita no README da pasta) entra com `--beeps` no beat de cada card via uma trilha AAC montada por `adelay`+`amix` e mixada com `-c:v copy` (o vídeo nunca é re-encodado); `--audio-only` aplica a trilha em vídeos já renderizados (foi assim que os 43 pacotes receberam o som, ~2 s cada). O `metadata.json` passa a registrar `audio: true`.

View file

@ -0,0 +1 @@
**REEL3 — A trilha dos reels: "confirma" por card + jingle de fundo, e a versão sem estadual (2026-10-01):** três ajustes sobre o lote REEL2. (1) **Correção**: quando a trilha dos bipes entrou (REEL2), o `-shortest` do mux cortava os vídeos em ~20,6 s (a trilha terminava no último bipe e levava o vídeo junto — o `-t` só limita, não estende); a trilha agora passa por `apad` antes do corte, então o áudio cobre exatamente a duração do vídeo e o mux não trunca mais nada (todos os 43 pacotes foram re-renderizados). (2) **Jingle**: o axé da campanha (`Jorge solla play.wav`, 2:11) entra como cama sonora desde o **início do vídeo**, tocando a partir dos seus **0:04** (o trecho pedido), um pouco mais presente (`volume=0.22`, fade-in de 0,6 s e fade-out de 2,5 s no fim, `alimiter` a 0,9 para não estourar), mixado com os bipes de "confirma" de cada card — o áudio é montado num único passe (`adelay`+`amix`+`afade`+`alimiter`+`apad`) e mixado com `-c:v copy` (o vídeo nunca é re-encodado); derivado mono do jingle em `scripts/reels/assets/colinha-time-1313/jingle-play.mp3` (receita no README da pasta). (3) **Versão sem estadual** (`--estadual=livre`): o segundo passo do chat vira "e quem é do time do Lula?" e o card mantém o MESMO layout dos candidatos (marca oficial de Lula recortada na janela de foto à esquerda, sobre fundo bege claro — legível e maior; à direita o rótulo `DEPUTADO ESTADUAL`, a chamada `DO TIME DO LULA` e `ESCOLHA O SEU` na linha do nome) — **sem citar partidos**: por decisão de produto no mesmo dia, os chips de partido (PT 13 · PCdoB 65 · PV 43 · PSB 40 · PSOL 50 · REDE 18 · PDT 12) e o rótulo `PARTIDOS DO TIME` foram removidos do card, restando só o sinal "deputado estadual do time de Lula" (o catálogo `COLINHA_PARTIES` morreu junto); o card final é a própria arte oficial da colinha com a linha do estadual aberta (sem drive do site, que ali não teria o que preencher) — pacote em `data/reels/colinha-sem-estadual/`. Verificação: vídeos conferidos com `ffprobe` (durações 29,1 s/30,9 s, stream de áudio AAC presente), spectrograma do mix (bipes limpos no beat de cada card + cama contínua), prévias do card genérico e do fecho da colinha aberta, e os checks de sempre.

View file

@ -0,0 +1 @@
**S46 — Página pública "story da sua seção" (potencial de Lula) (2026-10-07):** a rota estática `/potencial` (novo slug reservado em `SHARE_LINK_RESERVED_SLUGS`) deixa qualquer visitante, sem login e sem cadastro, informar UF/município/zona/seção do título e ver as três leituras da SUA seção no 1º turno presidencial de 2026 — Lula no 1º turno, o cenário de 2º turno só com os votos imediatos (X₁) e o cenário total (X₂) — com o ganho em pontos percentuais, sempre rotulado como cenário hipotético, e sair com um story 1080×1920 (PNG baixável/compartilhável) com os MESMOS números da tela. Dados: o bulk oficial do TSE (`votacao_secao_2026_BR` + `detalhe_votacao_secao_2026`) é ingerido UMA vez por `pnpm build:potencial-secoes` num artefato commitado de 27 shards por UF (497.897 seções, 13,6 MB em `public/dados/potencial-secao-2026/`, lido por fs no server com cache por processo; `data/tse-2026/` gitignored); a ingestão valida totais nacionais/BA pinados e aborta em divergência (bulk re-publicado exige PR deliberado), conta os votáveis Lula/Flávio vistos e, em runs `--only`, faz merge do recibo no manifest em vez de sobrescrevê-lo. Fórmula implementada: `válidos = QT_VOTOS_NOMINAIS`, `X₁ = comparecimento − Lula − Flávio` (nulos + brancos + TODOS os terceiros — o plano enumerava Renan+Cury assumindo 4 candidaturas; o bulk real tem 13, e o corte aprovado proíbe transferência seletiva) e `X₂ = aptos − Lula − Flávio`; o cálculo puro vive em `src/lib/sectionPotential.ts` e é o MESMO módulo na tela e no canvas do story (`src/lib/sectionStoryRender.ts`, contexto estrutural, sem segundo cano de imagem — adaptadores de PNG reusam `cardCanvas`). API pública nova (`GET /api/potencial/municipios` e `GET /api/potencial/secao`) com same-origin, rate-limit por IP hasheado (namespace `potencial:`) e zod strict; seção sem dado falha fechado (nunca 0/0 vira percentual) e seção inexistente é 404 com estado honesto "não encontramos essa seção". UI: ilha client com combobox de município (≥3 letras, accent-insensitive pelo normalizador dos pickers), estados carregando/resultado/erro/sem-dado, alvos ≥44px, foco amarelo+separador escuro, `motion-reduce`; o story mantém off-white/hairlines da cena 4 com área tracejada reservada ao sticker. O artefato de design do plan-issue era DEGRADED; a crítica final do `designer` frontier (`openai/gpt-5.6-sol`) certificou o port após sanar régua mobile e foco visível. Sem migration, sem collection, sem Consent, sem PII; e2e novo `frontendPotencial` (projeto + manifest + prewarm no setup). Impl plan: `docs/plans/potencial-secao-story-impl.md`. Testes: 26 unit (matemática com caso real Serrinha/BA ZE 150 seção 50 e prova de ganho ≥ 0, parse/lookup/formatação, renderer com recorder), 5 int (rotas: 200/400/403/404/429 + cache) e 3 e2e (consulta real + download do PNG, seção inexistente, mobile 390 sem overflow).

View file

@ -0,0 +1,129 @@
# Impl: S46 — Página pública "story da sua seção" (potencial de Lula)
Status: aprovado
Atualizado em: 2026-10-07
Issue: #1436
Intenção: docs/plans/potencial-secao-story.md
Appetite restante: herdado (~2–3 dias) — tracer bullet na Fase 1; story e gates na Fase 2/3.
## Leitura da intenção
- **Outcome:** visitante sem login informa UF/município/zona/seção e vê o 1º turno e os dois cenários de 2º turno (X₁ imediato, X₂ total) com o ganho em p.p., rotulado como cenário hipotético, e sai com um story 1080×1920 com os mesmos números — em <1 min no celular.
- **O que NÃO negociar:** o recorte é a seção informada (sem ranking, sem % estadual); seção ausente ou sem dado → estado honesto, nunca estimativa; sem PII/login/Consent; um cenário único ("todos os votos disponíveis vão para Lula; Flávio mantém os dele"); rótulo de cenário hipotético e fonte TSE sempre visíveis; template de story fixo.
- **O que reavaliar da intenção:**
- "Candidatos: Lula, Flávio, Renan, Cury" → o bulk 2026 tem **13 candidaturas** (auditado: 22 Flávio 56.104.503; 13 Lula 53.879.538; 70 Cury 3.448.569; 14 Renan 2.675.887; 55 Caiado 2.605.148; 30 Zema 326.488; + 7 nanicas). A fórmula implementada é `X₁ = comparecimento − Lula − Flávio` (= nulos + brancos + **todos** os terceiros), fiel ao corte aprovado ("todos os votos disponíveis", sem transferência seletiva) e à copy do design ("X₁ = nulos + brancos + votos de terceiros"). Registro para o body do PR: _"o plano enumerava Renan+Cury; a base real tem 13 candidaturas; a fórmula usa todos os terceiros — não seletiva, conforme o corte aprovado"_.
- "Dono de card/imagem" → reusar adaptadores genéricos **sem editar** `src/lib/cardRender.ts`/`src/components/cards/cardCanvas.ts` (serializa com S43; o dono fica intocado).
- "Ingestão quando o bulk sair" → bulk **já publicado** (last-modified 2026-10-06; baixado e auditado nesta sessão).
- **Premissas assumidas (`--auto`):** válidos = `QT_VOTOS_NOMINAIS` (legenda/anulados = 0 auditados); exibição pt-BR com 1 casa; ganho com sinal derivado do valor (nunca `+` hardcoded; prova de `ganho ≥ 0` no unit); face do canvas = Arimo (corpo) + Exo 2 (título/valores-herói), ambas já self-hosted; a área do sticker no story é desenhada como área tracejada reservada, **sem** o texto-legenda do mock (sticker é affordance do Instagram, não pixel de PNG); sem link de descoberta no rodapé; URLs TSE — `https://dadosabertos.tse.jus.br/dataset/resultados-2026` (verificada 200) e, no estado de erro, o autoatendimento do título (`https://www.tse.jus.br/servicos-eleitorais/autoatendimento-eleitoral`, a confirmar no navegador na Fase 1; fallback o portal de dados abertos).
## Abordagem recomendada
```mermaid
flowchart LR
A["scripts/build-potencial-secoes-2026.mjs<br/>bulk TSE 2026 · latin1/; · valida e aborta"] --> B["public/dados/potencial-secao-2026/&lt;UF&gt;.json<br/>+ manifest.json (commitados)"]
B --> C["utilities/potencialSections.ts<br/>server-only · fs + cache por UF"]
C --> D["GET /api/potencial/municipios?uf="]
C --> E["GET /api/potencial/secao?uf&municipio&zona&secao"]
F["(home)/potencial/page.tsx<br/>RSC · metadata · header/footer"] --> G["SectionPotentialStudio<br/>ilha client"]
D --> G
E --> G
G --> H["lib/sectionPotential.ts<br/>computeSectionPotential (puro)"]
H --> I["painel: resultado / 3a erro / sem dado"]
H --> J["lib/sectionStoryRender.ts<br/>canvas puro 1080×1920"]
J --> K["prévia canvas = o MESMO desenho do PNG"]
K --> L["compartilhar (files) | baixar"]
```
**Opções consideradas:** A) artefato estático commitado + API de leitura; B) collection Payload com ~500k linhas + migration; C) importar o JSON em `src/lib`; D) fetch TSE em runtime.
**Recomendação:** A — zero schema; dado imutável por deploy; `public/` é copiado no standalone (Dockerfile) e o cwd em dev/test é o repo.
**Rejeitadas:** B (migration/collection fora do escopo, ops pesado para ler 4 números); C (~16–20 MB no grafo tsc/bundle de todo consumidor); D (dependência de CDN externo em request público, sem contrato).
### Decisões de engenharia (caras de reverter)
1. **Formato do shard** (`public/dados/potencial-secao-2026/<UF>.json`)
Opções: A) por UF, `municipalities: [{ code, name, sections: [zona, secao, aptos, comp, lula, flavio, validos][] }]`; B) mapa `"zona:secao"` → tupla; C) objetos nomeados por seção.
Recomendação: **A** — menor JSON (~30 B/seção; ~16–20 MB em 27 arquivos, medidos no receipt); ordenado por (zona, seção); nomes de campo só no loader; seções presentes no detalhe mas sem voto de presidente entram com zeros (distingue "sem dado" de "não encontrada"). `manifest.json` com versão, data, totais medidos e `sha256Hex` por arquivo.
Rejeitadas: B (chave string por seção infla ~40% sem melhorar o lookup binário); C (3–4× o tamanho sem ganho).
2. **Runtime do artefato** (`src/utilities/potencialSections.ts`, server-only)
Opções: A) `fs.readFile(process.cwd()/public/dados/...)` + cache de módulo por UF + busca binária no array do município; B) `unstable_cache`/`fetch` do asset; C) re-ler a cada request.
Recomendação: **A** — `loadPotentialUfShard`, `findPotentialSection`, `listPotentialMunicipalities`, `listPotentialUfs`; arquivo/versão ausente → `null` (fail-closed; a página fecha honestamente). **Registrar `potencialSections.ts` no pin** de `tests/unit/codebaseConventions.unit.spec.ts` com o comentário do porquê.
Rejeitadas: B (cache de framework para dado de disco, sem tag de bust); C (parse de ~5 MB por request).
3. **Contrato da API pública** (`src/app/(frontend)/api/potencial/{municipios,secao}/route.ts`)
Opções (semântica de ausência): A) 404 para seção/UF ausente e 200 com números crus quando existe (mesmo `validos=0`; "sem dado" é decisão do módulo puro no cliente); B) 200 `{found:false, reason}` para tudo.
Recomendação: **A** — 403 origem (`isSameOriginRequest`), 400 query inválida (zod strict em `src/lib/schemas/sectionPotentialQuery.ts`), 429 rate-limit (`checkContentEventRateLimit(\`potencial:${clientKey}\`, 120)`— namespace próprio, budget default), 404 não encontrada / UF sem shard, 200`{ ok: true, section: { uf, municipalityCode, municipalityName, zone, section, aptos, comparecimento, lula, flavio, validos } }`; `Cache-Control: public, max-age=86400`; `dynamic = 'force-dynamic'`; sem body (GET).
Rejeitadas: B (empurra estado de produto para o transporte; o cálculo puro já é o dono testável da disponibilidade); `no-store`(dado público imutável);`immutable` (o mesmo caminho pode ser reingerido/redeployado).
4. **Cálculo puro + story** (`src/lib/sectionPotential.ts`, `src/lib/sectionStoryRender.ts`)
Opções: A) módulos puros (parse/formatação/cálculo + renderer com contexto estrutural próprio, incluindo métodos de stroke para a área tracejada); B) duplicar a conta na UI e no canvas; C) renderizar o story no servidor.
Recomendação: **A** — a API devolve números crus; o cliente chama `computeSectionPotential` (garante "story == tela"); adaptadores em `src/components/potencial/sectionStoryCanvas.ts` reusam `canvasToPngBlob`/`downloadBlob` de `src/components/cards/cardCanvas.ts` e um `ensureStoryFont` local (pesos 500/600/700 — o `ensureCardFont` só carrega 700).
Rejeitadas: B (drift entre painel e imagem); C (segundo cano de imagem + acoplamento com S43).
5. **Ganho negativo**
Recomendação: exibir o sinal derivado do número (nunca "+" hardcoded) e **provar no unit** que `ganho ≥ 0` sob a hipótese aprovada: com `T` = terceiros e `b+n` = brancos+nulos, `pct2i − pct1 = [V·T + (F+T)(b+n)] / (D·V) ≥ 0`; o piso é `+0,0 p.p.`, visível. O formatter suporta "−" caso uma revisão futura mude a fórmula — nunca esconder o valor.
Rejeitadas: esconder zero ou heurística de "sem ganho" (o aceite quer o número real).
6. **Coordenadas públicas** — `'potencial'` entra em `SHARE_LINK_RESERVED_SLUGS` (`src/lib/shareLink.ts`) + lista literal do `tests/unit/shareLink.unit.spec.ts` (o drift varre `(frontend)` e `(frontend)/(home)` e acha a rota sozinho). Sem migration, sem Consent, sem PII. Sem analytics em v1 (gatilho: pedido de métrica → reusar o beacon existente, sem rota nova).
### Componentes / mudanças
- **`scripts/build-potencial-secoes-2026.mjs`** + npm `build:potencial-secoes`: baixa com `ensureCachedDownload` (`scripts/lib/cli.mjs`) para `data/tse-2026/` (nova linha no `.gitignore`, par de `/data/tse-2022/`); URLs `https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_secao/votacao_secao_2026_BR.zip` e `.../detalhe_votacao_secao/detalhe_votacao_secao_2026.zip`; lê `detalhe_votacao_secao_2026_<UF>.csv` por UF + `votacao_secao_2026_BR.csv` (latin1, `;`, streaming `readline`, parser de linha local no espírito do script de 2022); chave `UF|CD_MUNICIPIO|NR_ZONA|NR_SECAO`, presidente `CD_CARGO=1`, `NR_TURNO=1`; `LULA_VOTAVEL=13`, `FLAVIO_VOTAVEL=22` (validar presença); `X₁ = comp − L − F`, `X₂ = aptos − L − F`, `validos = nominais`; valida os totais nacionais auditados — aptos 158.745.502; comp 125.275.835; nominais 119.306.034; brancos 2.300.798; nulos 3.669.003; `comp = nominais+brancos+nulos`; Σcandidaturas = nominais; 499.248 seções no detalhe (499.206 com voto de presidente); Lula 53.879.538; Flávio 56.104.503 — e **aborta** em divergência (constantes só mudam por PR deliberado); escreve os shards + manifest via `writeRepoFile`; `--only <UF>` para iteração. Não roda em build/deploy.
- **`src/lib/sectionPotential.ts`**: tipos do shard (`POTENTIAL_SHARD_VERSION`, linha de 7 números), `parsePotentialShard`/`indexPotentialShard`/`findPotentialSection` puros, `computeSectionPotential` → `{available:false, reason:'no-data'}` ou `{available:true, pct1, pct2i, pct2t, gainImmediatePp, gainTotalPp, x1, x2}`; invariantes fail-closed (`validos=0`, `L+F>comp`, `comp>aptos` → no-data); formatadores pt-BR (1 casa, delta de valores não arredondados) e `sectionStoryFileName` (`potencial-lula-<uf>-<municipio-slug>-z<zona>-s<secao>.png`).
- **`src/utilities/potencialSections.ts`**: `import 'server-only'`; fs + cache de módulo por UF; parse/índice vêm do lib.
- **Rotas**: `src/app/(frontend)/api/potencial/municipios/route.ts` (lista `{code,name}`) e `.../secao/route.ts` (números crus + identidade); cada uma autocontida na ordem 403→400→429→404→200 (sem abstração prematura para 2 call sites). Adicionar `frontendPotencial` à entrada `src/lib/schemas` do `E2E_AFFECTED_MANIFEST`.
- **Página** `src/app/(frontend)/(home)/potencial/page.tsx`: `generateMetadata` (canonical `absoluteSitePath` + `resolveSiteMetadata` + OG via `resolveOgImage(null)`), `CampaignPageHeader`, `CampaignFooter` com `showJingles/showConteudos/showFotos` (padrão `/jingles`); `listPotentialUfs()` vazio → estado fechado honesto.
- **UI** `src/components/potencial/`: `SectionPotentialStudio.tsx` (fetch + estados carregando 3b / resultado 3c / não encontrada 3a / sem dado), `SectionPotentialForm.tsx` (select UF; `MunicipalityCombobox` com busca ≥3 letras accent-insensitive via `slugify` (`src/lib/slug.ts`), `role=listbox`/teclado; zona/seção 1–4 dígitos, `inputMode=numeric`), `SectionPotentialResult.tsx` (hero do ganho total, três leituras, "Como calculamos" com X₁/X₂, guardrail + fonte), `SectionPotentialStoryPreview.tsx` (canvas real 1080×1920 em `w-full h-auto`, `role=img` + `aria-label` com os números), `sectionStoryCanvas.ts`, `potentialClasses.ts` (foco amarelo + separador escuro; alvos ≥44px; inputs 16px), `sectionPotentialCopy.ts` (copy pt-BR + URLs TSE). Compartilhar: `navigator.canShare({files})`→`navigator.share`; cancelamento (`AbortError`) não baixa; fallback `downloadBlob` — mesmo contrato de `ContentPieceShareSheet`.
- **Fontes** `src/app/(frontend)/fonts.ts`: exportar `campaignTextFont = Arimo(...)` (mesmo `--font-arimo`, sem segunda instância/preload) e `(frontend)/layout.tsx` passa a importar dele; a página propaga `fontFamily` à ilha.
- **`src/lib/sectionStoryRender.ts`**: template fixo (kicker "2º turno · 25/10", título, sub da hipótese, 6 campos, bloco "A conta", legenda X₁/X₂, área tracejada reservada ao sticker sem texto); layout em px derivado das proporções do design (base 1080; o design usa `cqw` — escalar por 1080); truncamento por `measureText` para município longo; sem logo/marca.
- **E2E**: `tests/e2e/frontendPotencial.e2e.spec.ts` + project `frontendPotencial` em `playwright.config.ts` (depende de `frontend` em dev, como `frontendJingles`); entradas no manifest com prefixos reais — `src/app/(frontend)/(home)/potencial`, `src/components/potencial`, `src/lib/sectionPotential`, `src/lib/sectionStoryRender`, `src/utilities/potencialSections`, `public/dados/potencial-secao-2026`, `scripts/build-potencial-secoes-2026.mjs` → `frontendPotencial`. Sem tocar em `E2E_CURATED_SPECS` (sem migration ⇒ não é high-risk).
- **Migration:** **sem migration**. **Access/Consent:** nada (sem auth, sem escrita, sem PII). **UI tier:** C (fluxo novo em rota pública) — portar o artefato DEGRADED classe-a-classe; crítica final do `designer` no fechamento (fail-closed).
### Dados → forma
- Forma: **três leituras lado a lado** (1º turno × 2º imediato × 2º total) + **número-herói** (ganho total em p.p.) + detalhamento auditável recolhível ("Como calculamos" com X₁/X₂) e a mesma estrutura no story. O recorte é sempre a seção; p.p. é a unidade de decisão; município/zona/seção no contexto. Rejeitadas: gráfico de barras (a decisão é "vale um story?"; dois números bastam); slider de cenário (cortado pelo produto); mais casas decimais (ruído).
## Fases verificáveis
1. **Tracer (dados → lib → API → cena 3a/3c)** — script com `--only BA` validado contra os totais → run completo (27 UFs + manifest) + shards commitados; `sectionPotential.ts`, loader e as duas rotas; página com form/resultado/erro/sem-dado (story desabilitado). Unit: Curitiba sintético 91/81/190 → X₁ 13/X₂ 69 → 47,9/56,2/66,4/+8,3/+18,5; bordas (validos=0, L+F>comp) e property do ganho ≥0. Int: Serrinha/BA ZE150 seção 50 real (Lula 174, Flávio 76, b14, n15, X₁ = 39, comp 289, validos 260 → 66,9% → 73,7%; aptos a conferir no artefato) + 400/403/404/429 + header de cache. Reserva do slug + pin do top-level. Verificar no navegador as URLs TSE e a existência da seção no shard.
2. **UI (story)** — fontes (Arimo no owner), `sectionStoryRender` + adaptadores, preview 1080×1920, baixar/compartilhar, estados 3b/3d; port fiel do design (tokens, Exo 2 display + Arimo; mobile 390 empilhado) + a11y (labels, roles, foco, `aria-live` no carregamento). E2E: consulta Serrinha → números na tela; seção inexistente → alerta 3a; canvas `width=1080`/`height=1920`; download com `suggestedFilename` e PNG 1080×1920 conferido com `sharp` (precedente `frontendConteudos`); viewport 390 sem overflow horizontal.
3. **Gates** — `pnpm gate:fast`; `pnpm test:e2e:affected` (frontendPotencial + frontend); `pnpm gate:push` e `pnpm push`; entrada `docs/changelog/2026-10-07-s46-potencial-secao.md`; receipt no PR (tamanho dos shards, totais medidos, URLs TSE verificadas, premissa das 13 candidaturas).
## Rabbit holes / Não escopo (engenharia)
- Deep-link/estado da consulta na URL (`?zona=`) e prefetch — sem pedido de produto; gatilho: pedido de compartilhar a consulta.
- Evento anônimo de download — aceite não pede; gatilho: métrica da campanha (reusar `POST /api/content-events`, nunca rota nova).
- Render do story no servidor (OG image) ou cache de PNG — segundo cano de imagem; gatilho: compartilhar por link em vez de imagem.
- Editor de story / logo / marca — cortados pela intenção (template fixo, sem marca).
- Extrair `parseCsvLine`/`canvasToPngBlob` para módulos compartilhados — só 2 call sites; gatilho: terceiro consumidor.
- Busca por endereço/OCR do título — o link para o autoatendimento do TSE resolve.
## Débitos deferidos (triagem do simplify)
- **F1 — `ensureStoryFont` × `ensureCardFont`:** DRY de 2 call sites; o dono (`cardCanvas`) serializa com S43. Gatilho: S43 mergeada ou 3º font gate de canvas — extrair `ensureCanvasFont(fontFamily, weights)` junto (e antes, sanar o `faces.length > 0` frágil do S16).
- **F4 — manifest e2e só mapeia `src/**`:** `public/dados/\*\*`e o script de ingestão não acordam`frontendPotencial` num PR data-only. Gatilho: PR de re-ingestão do bulk em que se queira e2e no próprio PR (o verify full do deploy cobre antes de prod).
- **F6 — métrica de download do story:** o aceite não pede; gatilho de produto já registrado acima (reusar `POST /api/content-events`, nunca rota nova).
## Riscos e mitigação
- **Revisão do bulk pelo TSE** → validação fail-closed com totais pinados; re-auditar exige PR deliberado (`--only` para inspecionar).
- **Tamanho do artefato no git/imagem** → arrays compactos, sem pretty-print; ~16–20 MB medidos; gzip no transporte; gatilho: se passar de ~25 MB, particionar por zonas.
- **S43 mexe no dono de card** → só importamos adaptadores; zero edição em `src/components/cards/**` e `src/lib/cardRender.ts`.
- **Fonte no canvas** → Arimo/Exo 2 OFL já self-hosted; `document.fonts.load` 500/600/700 antes do draw; fallback Arial no family.
- **`process.cwd()/public` fora do dev** → mesmo contrato do standalone (Dockerfile copia `public/` → `/app/public`); loader falha fechado se ausente.
- **Combobox a11y** → padrão ARIA listbox + teclado; a11y-audit antes do PR.
- **Artefato DEGRADED** → a crítica final do `designer` (tier primário) certifica o port; sem tier primário o fluxo `--auto` para e flipa `blocked` (nunca "segue sem").
## Aceite de engenharia
- [x] Aceite de produto da intenção permanece coberto (3 números + ganho em p.p., rótulo de cenário, fonte TSE, sem cadastro, story 1080×1920 com os mesmos números)
- [x] Invariantes AGENTS/engineering-standards (copy pt-BR/identificadores inglês; sem collection/migration/Consent; nenhuma escrita; `lib` → `utilities` → `components` → `app`)
- [x] Testes de domínio previstos (unit de matemática/parse/formatação/renderer; int das rotas; e2e do fluxo + download; pins de slug/top-level/manifest)
## Self-score (decision-quality)
1. Decisões caras têm rejeitadas? **5/5** — artefato, runtime, API, story e ganho com Opções+Recomendação+Rejeitadas.
2. Cabe no appetite? **5/5** — tracer na Fase 1 (dados→API→cena mínima), uma UI, gates; sem schema/migration.
3. Rabbit holes nomeados? **5/5** — deep-link, analytics, OG server, editor, extrações prematuras, OCR.
4. Depth check? **5/5** — reusa `ensureCachedDownload`/`writeRepoFile`, `slugify`, `canvasToPngBlob`/`downloadBlob`, `isSameOriginRequest` + rate-limit, shells de página/rodapé.
5. Intenção preservada? **5/5** — engenharia não reescreveu o outcome; a única emenda (13 candidaturas) é fiel à hipótese e ao corte aprovados.

File diff suppressed because it is too large Load diff

View file

@ -63,10 +63,13 @@
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"build:potencial-secoes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-potencial-secoes-2026.mjs",
"build:state-deputy-card-assets": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-state-deputy-card-assets.mjs",
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"reels:build": "node scripts/build-reel.mjs",
"reels:colinha": "node scripts/build-colinha-reel.mjs",
"reels:dobradinhas": "node scripts/build-dobradinha-reels.mjs",
"reels:tts:setup": "node scripts/reels-tts-setup.mjs",
"dev": "node scripts/dev.mjs",
"devsafe": "rm -rf .next && node scripts/dev.mjs",

View file

@ -198,6 +198,16 @@ export default defineConfig({
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
// S46 — the `/potencial` public page (TSE section query + story PNG
// download). Reads only the committed artifact and owns no DB rows, so
// it serializes behind frontend in dev (shared dev-server boot) and runs
// parallel to it in prod.
name: 'frontendPotencial',
testMatch: /frontendPotencial\.e2e\.spec\.ts/,
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
// S27 — the Central de Conteúdos public page (catalogue, lazy media,
// vote share, kill switch). Owns its content piece rows only, so it

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,246 @@
{
"version": 1,
"generatedAt": "2026-10-07T21:38:06.060Z",
"scope": "BR",
"sources": [
{
"key": "votacao_secao_2026_BR",
"url": "https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_secao/votacao_secao_2026_BR.zip",
"sha256": "2d42006d0f6c6e00bad83f5c23cd3d78cd3e3182dbc1ad6718222b6b132c23e1",
"bytes": 161616519
},
{
"key": "detalhe_votacao_secao_2026",
"url": "https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_secao/detalhe_votacao_secao_2026.zip",
"sha256": "0b1297dc7ae4a46b6ee0428d0f60b9ab9da6b53419ef7580d9ca20662a192cb1",
"bytes": 176923590
}
],
"candidates": {
"lula": 13,
"flavio": 22,
"count": 13,
"votesByNumber": {
"13": 53722151,
"14": 2667307,
"16": 42730,
"21": 22272,
"22": 55960603,
"27": 39218,
"28": 5123,
"29": 14825,
"30": 323159,
"35": 16508,
"55": 2600440,
"70": 3438835,
"80": 121858
}
},
"totals": {
"sections": 497897,
"aptos": 157828968,
"comparecimento": 124934069,
"nominais": 118975029,
"brancos": 2294982,
"nulos": 3664058,
"lula": 53722151,
"flavio": 55960603,
"candidateVotes": 118975029,
"candidates": 13
},
"ufs": {
"AC": {
"file": "AC.json",
"sha256": "960365d6a2be892f2bbe4fd0c15e0608da6de52401abf5d7f2f09a87e63935ff",
"bytes": 61295,
"municipalities": 22,
"sections": 2270
},
"AL": {
"file": "AL.json",
"sha256": "14a549e2dd5771101ca0e17fbffff22fd9102486691bbffd56fed1edda44b07a",
"bytes": 201778,
"municipalities": 102,
"sections": 7101
},
"AP": {
"file": "AP.json",
"sha256": "d94c4021e15b80e7a13c2969bdfcfca6cb1a3a3455198061884abebc42f7465b",
"bytes": 52935,
"municipalities": 16,
"sections": 1914
},
"AM": {
"file": "AM.json",
"sha256": "3737ff633e331c24aaca95bc67ea3db1eaedb861e10d8fcb0898c74a3e0f5159",
"bytes": 229562,
"municipalities": 62,
"sections": 8157
},
"BA": {
"file": "BA.json",
"sha256": "cdd5e49e1eeede84c11c3585d15457b24639aecf7d654c4d5d60d748532310c1",
"bytes": 1015815,
"municipalities": 417,
"sections": 35476
},
"CE": {
"file": "CE.json",
"sha256": "d14b88aa6fe167fee17d00a1db999460df3773a71dceac68cd65e980bed8df74",
"bytes": 671614,
"municipalities": 184,
"sections": 23765
},
"DF": {
"file": "DF.json",
"sha256": "9a7a8c47b510be58549ae4b04d72692060e113de17fd423c77775aed8ef8545e",
"bytes": 192975,
"municipalities": 1,
"sections": 6969
},
"ES": {
"file": "ES.json",
"sha256": "3d19acea027d3b721016f36d912340b9850d41cc843b579a426716a14789eea8",
"bytes": 275027,
"municipalities": 78,
"sections": 9844
},
"GO": {
"file": "GO.json",
"sha256": "cf6996f0e31b99051e2f9a2e737a52ead53503b868019fe13118cae023fad31a",
"bytes": 446875,
"municipalities": 246,
"sections": 15686
},
"MA": {
"file": "MA.json",
"sha256": "7904d70d6c7d1526920ae20a359f0b8568e459c4e8fb24a65df5877b4bb8dcaa",
"bytes": 509087,
"municipalities": 217,
"sections": 18093
},
"MT": {
"file": "MT.json",
"sha256": "8db7fcd3905bfcdfa023e1ef752d242f80fe2f888fd2118d239df11196070678",
"bytes": 235688,
"municipalities": 142,
"sections": 8287
},
"MS": {
"file": "MS.json",
"sha256": "3ab6f988fdbd46771e945223f08ea953cf97f7b9a37b0eb1e49c690776c7f67a",
"bytes": 197048,
"municipalities": 79,
"sections": 7106
},
"MG": {
"file": "MG.json",
"sha256": "0edfd5996809d7fb42999105639e62a6e361da8b5acdceac0bb6d4f89547a2de",
"bytes": 1515937,
"municipalities": 853,
"sections": 52062
},
"PA": {
"file": "PA.json",
"sha256": "84b2acabf2041803cdb7da626f9e70eb495a5ca508d995d5edbd79fb4939ad37",
"bytes": 585090,
"municipalities": 144,
"sections": 20827
},
"PB": {
"file": "PB.json",
"sha256": "b8b73cdf1655e12cb27ab9ac14b0d7c57688c253703bb126fe7bd261fec55d36",
"bytes": 306198,
"municipalities": 223,
"sections": 10712
},
"PR": {
"file": "PR.json",
"sha256": "a5dc5796142b82293fd59af129c6cabac6829569f13f61fbeb6e53152ba32eac",
"bytes": 778534,
"municipalities": 399,
"sections": 27142
},
"PE": {
"file": "PE.json",
"sha256": "b884c02765060e098c87e395e76b6ef65709ff14c3c466020633a792eb6e8d1e",
"bytes": 608913,
"municipalities": 185,
"sections": 21418
},
"PI": {
"file": "PI.json",
"sha256": "05acc7852de832db1737bfa20dd0f301488dcd710020b6c15d538c6c54f6165f",
"bytes": 289635,
"municipalities": 224,
"sections": 10225
},
"RJ": {
"file": "RJ.json",
"sha256": "895b7c9df23101736538dcbb35a291c69dc67c04b2cb76e3e019abb4ab879ccf",
"bytes": 1076255,
"municipalities": 92,
"sections": 37675
},
"RN": {
"file": "RN.json",
"sha256": "3417841406d73af230bd941cc299375db34982792b565d0b8ea8cdc741c1d3e4",
"bytes": 231337,
"municipalities": 167,
"sections": 8115
},
"RS": {
"file": "RS.json",
"sha256": "323c83a03191ca438357d45f1e776119a1ba9fa49750a8baed0f3c602e8a39e9",
"bytes": 792050,
"municipalities": 497,
"sections": 27547
},
"RO": {
"file": "RO.json",
"sha256": "e6ab63b463e161b4a843678226580767a4c0853da2aabaa05e29bff1e27a45f1",
"bytes": 129953,
"municipalities": 52,
"sections": 4698
},
"RR": {
"file": "RR.json",
"sha256": "4fc426fb2afa7b036145f9613f18539825e3bf3d31360aa334edbfc907a1d637",
"bytes": 40985,
"municipalities": 15,
"sections": 1519
},
"SC": {
"file": "SC.json",
"sha256": "599a4de71abb85aa9669f4107114080d3cd12a32726a7283ffa1b62d3c00e9bd",
"bytes": 492499,
"municipalities": 295,
"sections": 17326
},
"SP": {
"file": "SP.json",
"sha256": "16a706be4f791425afd73736b00b197ee422d95a16460aa3e77fa90d649cf9a3",
"bytes": 2991786,
"municipalities": 645,
"sections": 103656
},
"SE": {
"file": "SE.json",
"sha256": "5df9b985e4a2a9c6171e0843d73504ab954eecc4a2bcbed27c1272487dc32a60",
"bytes": 165772,
"municipalities": 75,
"sections": 5923
},
"TO": {
"file": "TO.json",
"sha256": "715fc87b236caf73ab8e25bb2db4ce9b0268b6360769d9e25bce43078a636400",
"bytes": 125305,
"municipalities": 139,
"sections": 4384
}
},
"validation": [
"estruturais: comparecimento = nominais + brancos + nulos; soma das 13 candidaturas = nominais; nenhuma seção sem aferição; Lula+Flávio ≤ comparecimento.",
"auditoria nacional pinada confere (seções, aptos, comparecimento, nominais, brancos, nulos, Lula, Flávio, candidaturas)."
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
/**
* Lote de reels "reel-card" por dobradinha (C-reel): um reel de conversa por
* dobradinha ativa da campanha, terminando na colinha EXATA do card
* personalizado do site para aquele estadual.
*
* A lista vive em `scripts/reels/dobradinhas.json` (curada a partir da
* plataforma: `state_deputy` cujo nome não contém "(apagar)" — ver o `source`
* do arquivo). Cada reel roda o mesmo builder (`--finale=card`), trocando o
* segundo candidato do ticket (nome, número e foto) e o estadual escolhido no
* estúdio de cards em produção.
*
* Uso:
* node scripts/build-dobradinha-reels.mjs [--only=slug,slug] [--jobs=3]
* [--photos-dir=…] [--skip-existing] [--dry-run] [--audio-only]
*
* As fotos são as dos cards da Plenária da Vitória
* (`DOBRADINHAS-ORGANIZADAS/<pasta>/foto.png`).
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-dobradinha-reels'
const die = dieWithLabel(LABEL)
const CONFIG = join(ROOT, 'scripts/reels/dobradinhas.json')
const BUILDER = join(ROOT, 'scripts/build-colinha-reel.mjs')
const DEFAULT_JOBS = 3
const USAGE = `Uso: node scripts/build-dobradinha-reels.mjs [--only=slug,slug] [--jobs=3]
[--photos-dir=…] [--skip-existing] [--dry-run] [--audio-only]`
const runBuilder = (args) =>
new Promise((resolvePromise) => {
const child = spawn(process.execPath, [BUILDER, ...args], {
cwd: ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
const forward = (chunk) => {
output += chunk.toString()
}
child.stdout.on('data', forward)
child.stderr.on('data', forward)
child.on('close', (code) => {
resolvePromise({ code, output })
})
})
const main = async () => {
const { flags } = parseEqualsFlags(process.argv.slice(2))
if (flags.help === true) {
console.log(USAGE)
return
}
const config = JSON.parse(await readFile(CONFIG, 'utf8'))
const photosDir =
typeof flags['photos-dir'] === 'string' ? resolve(flags['photos-dir']) : config.photosDir
const jobs = flags.jobs === undefined ? DEFAULT_JOBS : Number(flags.jobs)
if (!Number.isFinite(jobs) || jobs < 1) die('--jobs exige um inteiro ≥ 1.')
const only =
typeof flags.only === 'string' ? new Set(flags.only.split(',').map((s) => s.trim())) : null
const dryRun = flags['dry-run'] === true
const skipExisting = flags['skip-existing'] === true
const audioOnly = flags['audio-only'] === true
const entries = config.dobradinhas.filter((entry) => !only || only.has(entry.slug))
if (entries.length === 0) die('Nenhuma dobradinha selecionada (--only sem correspondência?).')
const failures = []
const skipped = []
const queue = [...entries]
const results = []
const runOne = async (entry) => {
const photo = join(photosDir, entry.folder, 'foto.png')
if (!existsSync(photo)) {
failures.push({ slug: entry.slug, reason: `foto ausente: ${photo}` })
console.log(`[${LABEL}] ${entry.slug}: FALHA (foto ausente)`)
return
}
const outSlug = `colinha-${entry.slug}`
const reelPath = join(ROOT, 'data/reels', outSlug, 'reel-card.mp4')
if (skipExisting && existsSync(reelPath)) {
skipped.push(entry.slug)
console.log(`[${LABEL}] ${entry.slug}: já existe, pulando`)
return
}
const args = [
'--finale=card',
'--beeps',
`--estadual-name=${entry.catalog}`,
`--estadual-number=${entry.number}`,
`--estadual-photo=${photo}`,
`--site-deputy=${entry.catalog}`,
`--out-slug=${outSlug}`,
]
if (audioOnly) args.push('--audio-only')
const started = Date.now()
const { code, output } = await runBuilder(args)
const seconds = ((Date.now() - started) / 1000).toFixed(0)
if (code !== 0) {
failures.push({ slug: entry.slug, reason: output.trim().split('\n').slice(-3).join(' / ') })
console.log(`[${LABEL}] ${entry.slug}: FALHA em ${seconds}s`)
return
}
results.push({ slug: entry.slug, seconds })
console.log(`[${LABEL}] ${entry.slug}: ok em ${seconds}s`)
}
if (dryRun) {
for (const entry of entries) {
const photo = join(photosDir, entry.folder, 'foto.png')
console.log(
`${entry.slug.padEnd(14)} ${entry.catalog.padEnd(26)} nº ${entry.number} foto: ${existsSync(photo) ? 'ok' : 'AUSENTE'}`,
)
}
console.log(`\n${entries.length} dobradinhas · ${jobs} em paralelo`)
return
}
const workers = Array.from({ length: Math.min(jobs, queue.length) }, async () => {
while (queue.length > 0) {
const entry = queue.shift()
await runOne(entry)
}
})
await Promise.all(workers)
console.log(`\n[${LABEL}] ${results.length}/${entries.length} reels gerados`)
if (skipped.length > 0) console.log(` pulados (já existiam): ${skipped.join(', ')}`)
if (failures.length > 0) {
for (const failure of failures) console.log(` FALHA ${failure.slug}: ${failure.reason}`)
process.exitCode = 1
}
}
main().catch((error) => die(error?.stack ?? String(error)))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,537 @@
/**
* S46 — "story da sua seção": plataforma por seção eleitoral, presidente T1 2026.
*
* Constrói o artefato commitado que a página pública `/potencial` lê: para cada
* seção eleitoral do Brasil, os números do cenário aprovado — aptos,
* comparecimento, votos de Lula (13), votos de Flávio (22) e válidos
* (nominais) — de onde saem X₁ = comparecimento − Lula − Flávio (nulos +
* brancos + TODOS os terceiros) e X₂ = aptos − Lula − Flávio.
*
* As constantes de auditoria nacional/BA estão pinadas: um bulk re-publicado
* pelo TSE falha fechado até um PR deliberado atualizar os pins.
*
* Fontes (Portal de Dados Abertos do TSE, 1º turno de 04/10/2026):
* votacao_secao_2026_BR.zip votos por candidato por seção (presidente)
* detalhe_votacao_secao_2026.zip aferição por seção × cargo (CSV BRASIL)
* Portal: https://dadosabertos.tse.jus.br/dataset/resultados-2026
* Licença: Creative Commons Atribuição (dados abertos TSE).
*
* Saída: public/dados/potencial-secao-2026/<UF>.json + manifest.json.
* Nunca roda em build/deploy — o artefato é commitado.
*
* Uso:
* pnpm build:potencial-secoes
* pnpm build:potencial-secoes -- --only=BA
*/
import { parse as parseCsv } from 'csv-parse'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { open as openZip } from 'yauzl'
import {
dieWithLabel,
ensureCachedDownload,
parseEqualsFlags,
sha256Hex,
writeRepoFile,
} from './lib/cli.mjs'
// Donos únicos (tsx resolve os paths do tsconfig): a lista de UFs e a versão do
// shard vivem no lib que o runtime lê; as divergências de nome da BA e o
// normalizador de busca vêm dos módulos compartilhados do repo.
const { POTENTIAL_SHARD_VERSION, POTENTIAL_UFS } = await import('../src/lib/sectionPotential.ts')
const { MUNICIPALITY_NAME_ALIASES } = await import('../src/lib/municipalityNameAliases.ts')
const { normalizeSearchPhrase } = await import('../src/lib/wordStartFilter.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-potencial-secoes-2026'
const die = dieWithLabel(LABEL)
const CACHE_DIR = join(ROOT, 'data/tse-2026')
const OUTPUT_DIR = 'public/dados/potencial-secao-2026'
/** O CDN do TSE recusa user-agent headless (C161); o download usa UA de browser. */
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
const PRESIDENT_OFFICE = '1'
const FIRST_TURN = '1'
const LULA_VOTAVEL = 13
const FLAVIO_VOTAVEL = 22
const BRANCO_VOTAVEL = 95
const NULO_VOTAVEL = 96
const SOURCES = [
{
key: 'votacao_secao_2026_BR',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_secao/votacao_secao_2026_BR.zip',
// sha256 do zip baixado em 2026-10-07 (ver manifest.json).
sha256: '2d42006d0f6c6e00bad83f5c23cd3d78cd3e3182dbc1ad6718222b6b132c23e1',
},
{
key: 'detalhe_votacao_secao_2026',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_secao/detalhe_votacao_secao_2026.zip',
sha256: '0b1297dc7ae4a46b6ee0428d0f60b9ab9da6b53419ef7580d9ca20662a192cb1',
},
]
const VOTACAO_ENTRY = 'votacao_secao_2026_BR.csv'
const DETALHE_ENTRY = 'detalhe_votacao_secao_2026_BRASIL.csv'
/**
* Auditoria nacional do presidente T1 2026 (medida em 2026-10-07 dos CSVs,
* escopo Brasil — o exterior `ZZ` fica fora do produto, que consulta a UF do
* título): qualquer divergência aborta a ingestão. Atualizar exige PR.
*/
const NATIONAL_AUDIT = {
sections: 497_897,
aptos: 157_828_968,
comparecimento: 124_934_069,
nominais: 118_975_029,
brancos: 2_294_982,
nulos: 3_664_058,
lula: 53_722_151,
flavio: 55_960_603,
candidateVotes: 118_975_029,
candidates: 13,
}
/** Auditoria do recorte BA (mesma data) — usada no `--only=BA`. */
const UF_AUDIT = {
BA: {
sections: 35_476,
aptos: 11_312_752,
comparecimento: 9_053_601,
nominais: 8_560_542,
brancos: 154_936,
nulos: 338_123,
lula: 5_664_771,
flavio: 2_442_585,
},
}
const int = new Intl.NumberFormat('pt-BR')
const downloadZip = async (url) => {
const response = await fetch(url, { headers: { 'user-agent': BROWSER_UA } })
if (!response.ok) throw new Error(`download falhou (${response.status}): ${url}`)
return Buffer.from(await response.arrayBuffer())
}
// ---------------------------------------------------------------------------
// Zip streaming (yauzl + csv-parse; nada é descompactado em disco)
// ---------------------------------------------------------------------------
const openZipEntryStream = (zipPath, matchEntry) =>
new Promise((resolve, reject) => {
openZip(zipPath, { lazyEntries: true }, (error, zipfile) => {
if (error || !zipfile) {
reject(error ?? new Error(`não abriu ${zipPath}`))
return
}
zipfile.readEntry()
zipfile.on('entry', (entry) => {
if (/\/$/.test(entry.fileName) || !matchEntry(entry.fileName)) {
zipfile.readEntry()
return
}
zipfile.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) {
reject(streamError ?? new Error(`não leu ${entry.fileName}`))
return
}
stream.on('end', () => {
try {
zipfile.close()
} catch {
// o fd já pode ter sido fechado pelo autoClose do yauzl
}
})
resolve(stream)
})
})
zipfile.on('end', () => reject(new Error(`entrada não encontrada em ${zipPath}`)))
zipfile.on('error', reject)
})
})
/**
* Linhas de um CSV dentro do zip (TSE: ISO-8859-1, `;`, campos citados).
* `latin1` é aplicado no stream antes do parser para não decodificar UTF-8.
*/
async function* zipEntryRows(zipPath, matchEntry) {
const stream = await openZipEntryStream(zipPath, matchEntry)
stream.setEncoding('latin1')
const parser = parseCsv({
columns: true,
delimiter: ';',
relax_quotes: true,
relax_column_count: true,
skip_empty_lines: true,
trim: true,
bom: true,
})
stream.pipe(parser)
for await (const row of parser) yield row
}
const num = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
// ---------------------------------------------------------------------------
// Nomes de exibição (TSE em caixa alta → nome canônico do cadastro do repo)
// ---------------------------------------------------------------------------
/**
* Divergências de grafia TSE × cadastro do repo. As da Bahia vêm do dono
* (`MUNICIPALITY_NAME_ALIASES`, o mesmo fold dos imports de apoiador); as
* demais UFs não têm cadastro curado — o fallback é o próprio nome do TSE em
* title case, nunca um erro.
*/
const EXTRA_NAME_ALIASES = {
// Chave já no formato de `normalizeSearchPhrase` (minúsculas, pontuação → espaço).
'RS|sant ana do livramento': "Sant'Ana do Livramento",
}
const titleCaseName = (value) => {
const small = new Set(['de', 'da', 'do', 'das', 'dos', 'e'])
return value
.toLocaleLowerCase('pt-BR')
.split(' ')
.map((word, index) =>
index > 0 && small.has(word)
? word
: word.replace(/^([a-zà-öø-ÿ])/, (letter) => letter.toLocaleUpperCase('pt-BR')),
)
.join(' ')
}
const buildNameResolver = (citiesByState, baAliases) => {
const canonical = new Map()
for (const [uf, cities] of Object.entries(citiesByState)) {
for (const name of cities) canonical.set(`${uf}|${normalizeSearchPhrase(name)}`, name)
}
for (const [variant, name] of baAliases) {
canonical.set(`BA|${normalizeSearchPhrase(variant)}`, name)
}
for (const [key, name] of Object.entries(EXTRA_NAME_ALIASES)) canonical.set(key, name)
const stats = { matched: 0, fallback: 0, examples: [] }
return {
resolve: (uf, tseName) => {
const hit = canonical.get(`${uf}|${normalizeSearchPhrase(tseName)}`)
if (hit) {
stats.matched += 1
return hit
}
stats.fallback += 1
if (stats.examples.length < 200) stats.examples.push(`${uf}: ${tseName}`)
return titleCaseName(tseName)
},
stats,
}
}
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
const auditPatch = (totals, audit) => {
const diffs = []
for (const [field, expected] of Object.entries(audit)) {
const found = totals[field]
if (found !== expected)
diffs.push(`${field}: esperado ${int.format(expected)}, encontrado ${int.format(found)}`)
}
return diffs
}
async function main() {
const { flags } = parseEqualsFlags(process.argv.slice(2))
const selected = new Set(
flags.only
? String(flags.only)
.split(',')
.map((uf) => uf.trim().toUpperCase())
.filter(Boolean)
: POTENTIAL_UFS,
)
if (selected.size === 0) die('--only está vazio — informe ao menos uma UF (ex.: --only=BA).')
for (const uf of selected) if (!POTENTIAL_UFS.includes(uf)) die(`UF fora do Brasil: ${uf}`)
const runAll = selected.size === POTENTIAL_UFS.length
console.log(`[${LABEL}] UFs: ${[...selected].join(', ')}${runAll ? ' (nacional)' : ''}`)
const { CitiesByState } = await import('../src/lib/cities.ts')
const names = buildNameResolver(CitiesByState, MUNICIPALITY_NAME_ALIASES)
// 1. Downloads (cache em data/tse-2026/, gitignored) + hashes pinados.
const downloaded = {}
const sourceReceipt = []
for (const source of SOURCES) {
console.log(`[${LABEL}] garantindo ${source.key}.zip`)
const cached = await ensureCachedDownload({
label: LABEL,
key: source.key,
url: source.url,
ext: 'zip',
cacheDir: CACHE_DIR,
expectedSha256: source.sha256,
download: downloadZip,
})
downloaded[source.key] = join(CACHE_DIR, `${source.key}.zip`)
sourceReceipt.push({
key: source.key,
url: cached.url,
sha256: cached.hash,
bytes: cached.buffer.length,
})
}
// 2. Aferição por seção (CSV BRASIL): universo de seções + aptos/comp/válidos.
const sections = new Map()
const tseNames = new Map()
const totals = {
sections: 0,
aptos: 0,
comparecimento: 0,
nominais: 0,
brancos: 0,
nulos: 0,
lula: 0,
flavio: 0,
}
console.log(`[${LABEL}] lendo aferição por seção (presidente, T1)...`)
for await (const row of zipEntryRows(downloaded.detalhe_votacao_secao_2026, (name) =>
name.endsWith(DETALHE_ENTRY),
)) {
if (row.NR_TURNO !== FIRST_TURN || row.CD_CARGO !== PRESIDENT_OFFICE) continue
const uf = row.SG_UF
if (!selected.has(uf)) continue
const key = `${uf}|${row.CD_MUNICIPIO}|${Number(row.NR_ZONA)}|${Number(row.NR_SECAO)}`
sections.set(key, [
num(row.QT_APTOS),
num(row.QT_COMPARECIMENTO),
num(row.QT_VOTOS_NOMINAIS),
0,
0,
])
tseNames.set(`${uf}|${row.CD_MUNICIPIO}`, row.NM_MUNICIPIO)
totals.sections += 1
totals.aptos += num(row.QT_APTOS)
totals.comparecimento += num(row.QT_COMPARECIMENTO)
totals.nominais += num(row.QT_VOTOS_NOMINAIS)
totals.brancos += num(row.QT_VOTOS_BRANCOS)
totals.nulos += num(row.QT_VOTOS_NULOS)
}
console.log(`[${LABEL}] seções: ${int.format(sections.size)}`)
if (sections.size === 0) die('nenhuma seção carregada — confira a UF do --only.')
// 3. Votos por candidato (arquivo BR inteiro, um passe): Lula/Flávio por seção
// + total nacional de todas as candidaturas (auditoria).
const candidateVotes = new Map()
let missingSections = 0
let seenLula = 0
let seenFlavio = 0
console.log(`[${LABEL}] lendo votos por seção (presidente, T1)...`)
for await (const row of zipEntryRows(downloaded.votacao_secao_2026_BR, (name) =>
name.endsWith(VOTACAO_ENTRY),
)) {
if (row.NR_TURNO !== FIRST_TURN || row.CD_CARGO !== PRESIDENT_OFFICE) continue
const uf = row.SG_UF
if (!selected.has(uf)) continue
const number = num(row.NR_VOTAVEL)
const votes = num(row.QT_VOTOS)
if (number !== BRANCO_VOTAVEL && number !== NULO_VOTAVEL) {
candidateVotes.set(number, (candidateVotes.get(number) ?? 0) + votes)
}
if (number !== LULA_VOTAVEL && number !== FLAVIO_VOTAVEL) continue
const key = `${uf}|${row.CD_MUNICIPIO}|${Number(row.NR_ZONA)}|${Number(row.NR_SECAO)}`
const section = sections.get(key)
if (!section) {
missingSections += 1
continue
}
if (number === LULA_VOTAVEL) {
seenLula += 1
section[3] += votes
totals.lula += votes
} else {
seenFlavio += 1
section[4] += votes
totals.flavio += votes
}
}
const candidateTotal = [...candidateVotes.values()].reduce((sum, votes) => sum + votes, 0)
totals.candidateVotes = candidateTotal
totals.candidates = candidateVotes.size
// 4. Validações estruturais e pinadas (fail-closed).
const validation = []
const structural = []
if (totals.comparecimento !== totals.nominais + totals.brancos + totals.nulos) {
structural.push(
`comparecimento (${int.format(totals.comparecimento)}) ≠ nominais+brancos+nulos ` +
`(${int.format(totals.nominais + totals.brancos + totals.nulos)})`,
)
}
if (totals.candidateVotes !== totals.nominais) {
structural.push(
`soma das candidaturas (${int.format(totals.candidateVotes)}) ≠ nominais ` +
`(${int.format(totals.nominais)})`,
)
}
if (missingSections > 0) {
structural.push(`${int.format(missingSections)} votos de seção fora da aferição`)
}
// Sem os dois votáveis o shard sairia com colunas zeradas em silêncio (as
// auditorias pinadas só cobrem BR e BA).
if (seenLula === 0 || seenFlavio === 0) {
structural.push(
`votáveis ausentes no recorte: Lula=${int.format(seenLula)} linhas, Flávio=${int.format(seenFlavio)} linhas`,
)
}
let negative = 0
for (const section of sections.values()) {
if (section[1] < section[3] + section[4]) negative += 1
}
if (negative > 0)
structural.push(`${int.format(negative)} seções com Lula+Flávio > comparecimento`)
if (structural.length > 0) die(`invariantes violadas:\n - ${structural.join('\n - ')}`)
validation.push(
`estruturais: comparecimento = nominais + brancos + nulos; soma das ${int.format(candidateVotes.size)} candidaturas = nominais; nenhuma seção sem aferição; Lula+Flávio ≤ comparecimento.`,
)
if (runAll) {
const diffs = auditPatch(totals, NATIONAL_AUDIT)
if (diffs.length > 0) die(`auditoria nacional divergiu:\n - ${diffs.join('\n - ')}`)
validation.push(
'auditoria nacional pinada confere (seções, aptos, comparecimento, nominais, brancos, nulos, Lula, Flávio, candidaturas).',
)
} else if (selected.size === 1) {
const uf = [...selected][0]
const audit = UF_AUDIT[uf]
if (audit) {
const diffs = auditPatch(totals, audit)
if (diffs.length > 0) die(`auditoria de ${uf} divergiu:\n - ${diffs.join('\n - ')}`)
validation.push(`auditoria pinada de ${uf} confere.`)
} else {
validation.push(`sem auditoria pinada para ${uf} — receita gerada sem conferência.`)
}
} else {
validation.push(
'recorte com mais de uma UF e sem auditoria nacional — receita gerada sem conferência.',
)
}
// 5. Shards por UF.
const byUf = new Map()
for (const [key, value] of sections) {
const [uf, code, zone, section] = key.split('|')
let municipalities = byUf.get(uf)
if (!municipalities) {
municipalities = new Map()
byUf.set(uf, municipalities)
}
let municipality = municipalities.get(code)
if (!municipality) {
municipality = {
code: Number(code),
name: names.resolve(uf, tseNames.get(`${uf}|${code}`) ?? String(code)),
sections: [],
}
municipalities.set(code, municipality)
}
municipality.sections.push([
Number(zone),
Number(section),
value[0],
value[1],
value[3],
value[4],
value[2],
])
}
const ufReceipt = {}
let totalBytes = 0
for (const uf of selected) {
const municipalities = [...(byUf.get(uf)?.values() ?? [])]
for (const municipality of municipalities) {
municipality.sections.sort((left, right) => left[0] - right[0] || left[1] - right[1])
}
municipalities.sort((left, right) => left.code - right.code)
const body = JSON.stringify({ version: POTENTIAL_SHARD_VERSION, uf, municipalities })
await writeRepoFile({
label: LABEL,
root: ROOT,
relativePath: `${OUTPUT_DIR}/${uf}.json`,
body,
})
const bytes = Buffer.byteLength(body)
totalBytes += bytes
ufReceipt[uf] = {
file: `${uf}.json`,
sha256: sha256Hex(Buffer.from(body)),
bytes,
municipalities: municipalities.length,
sections: municipalities.reduce((sum, item) => sum + item.sections.length, 0),
}
}
// 6. Manifest: o run nacional grava o recibo completo; um `--only` só
// atualiza as UFs regravadas dentro do recibo existente (totais e auditoria
// nacionais continuam os do run completo).
const manifestPath = `${OUTPUT_DIR}/manifest.json`
let manifest
if (runAll) {
manifest = {
version: POTENTIAL_SHARD_VERSION,
generatedAt: new Date().toISOString(),
scope: 'BR',
sources: sourceReceipt,
candidates: {
lula: LULA_VOTAVEL,
flavio: FLAVIO_VOTAVEL,
count: candidateVotes.size,
votesByNumber: Object.fromEntries(
[...candidateVotes].sort((left, right) => left[0] - right[0]),
),
},
totals,
ufs: ufReceipt,
validation,
}
} else {
const existing = await readFile(join(ROOT, manifestPath), 'utf8').catch(() => null)
if (!existing) die(`manifest nacional ausente — rode o run completo antes de um --only`)
manifest = JSON.parse(existing)
manifest.generatedAt = new Date().toISOString()
manifest.ufs = { ...manifest.ufs, ...ufReceipt }
manifest.validation = [
...manifest.validation,
`recorte ${[...selected].join(', ')} regravado em ${new Date().toISOString()} — ${validation.join(' ')}`,
]
}
await writeRepoFile({
label: LABEL,
root: ROOT,
relativePath: manifestPath,
body: `${JSON.stringify(manifest, null, 2)}\n`,
})
console.log(
`[${LABEL}] pronto: ${Object.keys(ufReceipt).length} UFs, ${int.format(sections.size)} seções, ` +
`${(totalBytes / 1024 / 1024).toFixed(1)} MB de dados; nomes canônicos=${int.format(names.stats.matched)} ` +
`fallback=${int.format(names.stats.fallback)}${names.stats.examples.length ? ` (ex.: ${names.stats.examples.join('; ')})` : ''}`,
)
console.log(
`[${LABEL}] totais: Lula=${int.format(totals.lula)} Flávio=${int.format(totals.flavio)} nominais=${int.format(totals.nominais)}`,
)
}
await main()

328
scripts/lib/colinhaReel.mjs Normal file
View file

@ -0,0 +1,328 @@
/**
* Reel "Sua colinha para 2026" — the script data and the pure timeline of the
* WhatsApp-style chat reel (a creative piece inspired by the PT-MG "Seu time"
* reel of dep_padrejoao; the chat walks the voter through the urna order:
* federal → estadual → senador 1ª/2ª vaga → governador → presidente).
*
* No DOM, no ffmpeg, no browser: every constant here is data, and
* `buildColinhaTimeline` is the deterministic map beat → [atMs, endsAtMs]. The
* template (`colinhaReelTemplate.mjs`) renders one state per `t`, and the
* builder (`build-colinha-reel.mjs`) walks `frameCount(totalMs, fps)` — the
* three share this module, so the script is the single source of the reel.
*
* Candidate photos and the star badge live in
* `scripts/reels/assets/<slug>/` (provenance in the folder README); the
* official arts (colinha slip, dobradinha santinho) come from `public/`.
*/
export const COLINHA_REEL = {
slug: 'colinha-time-1313',
title: 'Sua colinha para 2026 — Time 1313',
chatTitle: 'Time 1313',
chatHour: '18:30',
/** Cover contract of the private library (C195): alt text of the cover art. */
coverAlt:
'Conversa no WhatsApp do "Time 1313" montando a colinha de votação: Jorge Solla 1313, Julio Pinheiro 13999, Jaques Wagner 130, Rui Costa 133, Jerônimo 13 e Lula 13.',
/** The state + election line inside every candidate card. */
cardState: 'BA · ELEIÇÕES 2026',
cardParty: 'PT',
fps: 30,
}
/**
* The ticket in urna order — the same order the chat asks and the same rows of
* the official colinha art (`public/cards/modelo-colinha.jpeg`). `ask` is the
* office as it is spoken in the chat; `office` is the printed label of the
* card; `number` is the ballot number (digits only). `photo` is a file inside
* `scripts/reels/assets/<slug>/`.
*/
export const COLINHA_CANDIDATES = [
{
id: 'solla',
ask: 'Deputado Federal',
office: 'DEPUTADO FEDERAL',
name: 'JORGE SOLLA',
number: '1313',
photo: 'solla.webp',
/** Card framing: the master is framed shoulder-up from the head down. */
photoZoom: 1.22,
/** End screen: the group figure, the slip row and the red-band logo. */
figure: { cx: 177.5, cy: 235, rx: 67.5, ry: 107.5 },
row: { top: 458, height: 71 },
logo: { left: 14, top: 316, width: 158 },
},
{
id: 'julio',
ask: 'Deputado Estadual',
office: 'DEPUTADO ESTADUAL',
name: 'JULIO PINHEIRO',
number: '13999',
photo: 'julio.webp',
photoZoom: 1.18,
photoShiftY: 3.5,
figure: { cx: 355, cy: 232.5, rx: 67.5, ry: 107.5 },
row: { top: 542, height: 59 },
logo: { left: 185, top: 320, width: 150 },
},
{
id: 'wagner',
ask: 'Senador (1ª vaga)',
office: 'SENADOR (1ª VAGA)',
name: 'JAQUES WAGNER',
number: '130',
photo: 'wagner.webp',
photoZoom: 1.05,
photoShiftY: 2,
figure: { cx: 500, cy: 245, rx: 50, ry: 100 },
row: { top: 607, height: 71 },
logo: { left: 464, top: 374, width: 72 },
},
{
id: 'rui',
ask: 'Senador (2ª vaga)',
office: 'SENADOR (2ª VAGA)',
name: 'RUI COSTA',
number: '133',
photo: 'rui.webp',
photoZoom: 1.05,
photoShiftY: 2,
figure: { cx: 440, cy: 240, rx: 57.5, ry: 102.5 },
row: { top: 694, height: 67 },
logo: { left: 386, top: 370, width: 72 },
},
{
id: 'jeronimo',
ask: 'Governador',
office: 'GOVERNADOR',
name: 'JERÔNIMO',
number: '13',
photo: 'jeronimo.webp',
photoZoom: 1.05,
photoShiftY: 2,
figure: { cx: 35, cy: 250, rx: 45, ry: 100 },
row: { top: 771, height: 68 },
logo: { left: 464, top: 321, width: 72 },
},
{
id: 'lula',
ask: 'Presidente',
office: 'PRESIDENTE',
name: 'LULA',
number: '13',
photo: 'lula.webp',
photoZoom: 1.12,
photoShiftY: 3.2,
figure: { cx: 97.5, cy: 235, rx: 62.5, ry: 107.5 },
row: { top: 847, height: 71 },
logo: { left: 386, top: 318, width: 72 },
},
]
/**
* The "sem estadual" step: instead of one ballot number, the card tells the
* voter to pick a state deputy from Lula's team — no party is named.
*/
export const COLINHA_GENERIC_ESTADUAL = {
id: 'estadual-livre',
ask: 'Deputado Estadual',
office: 'DEPUTADO ESTADUAL',
/** The card headline: no name, the instruction itself. */
name: 'DO TIME DO LULA',
number: '',
generic: true,
/** The chat beat where the voter asks who is on Lula's team. */
voterText: 'e quem é do time do Lula?',
figure: { cx: 355, cy: 232.5, rx: 67.5, ry: 107.5 },
row: { top: 542, height: 59 },
logo: { left: 185, top: 320, width: 150 },
}
/**
* The urna order of the chat, derived from the candidates (the reel never
* repeats the ticket): federal (4 digits) → estadual (5) → two senators (3+3)
* → governador (2) → presidente (2).
*/
export const COLINHA_URNA_ORDER = COLINHA_CANDIDATES.map((candidate) => candidate.id)
/**
* Holding times of the chat beats (ms). The first ask is the full question and
* every following ask is the short "e pra …?"; the holds follow the reference
* reel rhythm (each candidate cycle ≈ 3.2 s).
*/
export const COLINHA_BEAT_DURATIONS = {
hook: 1200,
typing: 650,
reply: 600,
ask: 700,
number: 500,
card: 1950,
closingTyping: 650,
closing: 1250,
/** Screen flip transition between the chat and the end screen. */
flip: 420,
/** The end screen: flip in, then one reveal per candidate, then the slogan. */
endHold: 400,
reveal: 700,
brand: 2200,
/** The static finale: the slip arrives complete and holds. */
staticHold: 5000,
}
/**
* The end screen of the reel — the official colinha art assembles itself: one
* reveal beat per candidate (their figure in the sky band, their row in the
* slip and their logo in the red band), then the "Mais Saúde, Mais Futuro"
* title appears last over the complete slip. Positions are CSS px on the
* 540×960 stage (the art is measured at 1080×1920 → /2).
*/
export const COLINHA_END = {
/** The printed title band of the art (revealed last, with the slogan). */
titleBand: { top: 0, height: 125 },
/** The group/sky band of the art (kept covered by the composed sky). */
groupBand: { top: 125, height: 225 },
/** The red band of the art (its own lockups are covered and redrawn). */
band: { color: '#e50e2f' },
/** The white slip rows are masked per candidate before their reveal. */
rowMask: { left: 100, width: 360 },
}
const candidateById = new Map(COLINHA_CANDIDATES.map((candidate) => [candidate.id, candidate]))
export const getColinhaCandidate = (id) => candidateById.get(id)
/**
* The chat script: voter beats on the right, assistant beats on the left, one
* number bubble + one card per candidate, then the closing and the end screen.
* `finale: 'motion'` assembles the slip candidate by candidate; `finale:
* 'static'` and `'card'` flip straight into the complete slip and hold — the
* latter is the site's own personalized-card output.
*
* @param {{ finale?: 'motion' | 'static' | 'card', candidates?: typeof COLINHA_CANDIDATES }} [options]
* @returns {Array<{ id: string, kind: 'voter' | 'assistant' | 'typing' | 'number' | 'card' | 'end' | 'reveal' | 'brand', text?: string, candidateId?: string, screen?: 'colinha', seen?: boolean, durationMs: number, enterMs: number, hideAtMs?: number }>}
*/
export const buildColinhaBeats = ({ finale = 'motion', candidates = COLINHA_CANDIDATES } = {}) => {
const {
hook,
typing,
reply,
ask,
number,
card,
closingTyping,
closing,
flip,
endHold,
reveal,
brand,
staticHold,
} = COLINHA_BEAT_DURATIONS
const beats = [
{
id: 'hook',
kind: 'voter',
text: 'oi! me ajuda a montar minha colinha pra 2026?',
seen: true,
durationMs: hook,
},
{ id: 'typing-1', kind: 'typing', durationMs: typing },
{ id: 'reply-1', kind: 'assistant', text: 'claro! vamos na ordem da urna.', durationMs: reply },
]
candidates.forEach((candidate, index) => {
beats.push({
id: `ask-${candidate.id}`,
kind: 'assistant',
text: index === 0 ? 'pra Deputado Federal, quem você escolheu?' : `e pra ${candidate.ask}?`,
durationMs: ask,
})
if (candidate.generic) {
// The "sem estadual" step: the voter asks who is on Lula's team and the
// card instructs them to pick a state deputy from that team.
beats.push({
id: `number-${candidate.id}`,
kind: 'voter',
text: candidate.voterText,
seen: true,
durationMs: number,
})
} else {
beats.push({
id: `number-${candidate.id}`,
kind: 'number',
text: candidate.number,
candidateId: candidate.id,
seen: true,
durationMs: number,
})
}
beats.push({
id: `card-${candidate.id}`,
kind: 'card',
candidateId: candidate.id,
durationMs: card,
})
})
beats.push({ id: 'typing-end', kind: 'typing', durationMs: closingTyping })
beats.push({
id: 'closing',
kind: 'assistant',
text: 'prontinho! colinha completa. bom voto! ✅',
durationMs: closing,
})
beats.push({
id: 'end-colinha',
kind: 'end',
screen: 'colinha',
durationMs: flip * 2 + (finale === 'motion' ? endHold : staticHold),
})
if (finale !== 'motion') {
// The static/card finales: the slip arrives complete and holds.
beats.push({ id: 'brand', kind: 'brand', durationMs: 1 })
} else {
for (const candidate of candidates) {
beats.push({
id: `reveal-${candidate.id}`,
kind: 'reveal',
candidateId: candidate.id,
durationMs: reveal,
})
}
beats.push({ id: 'brand', kind: 'brand', durationMs: brand })
}
return beats.map((beat) => ({
...beat,
durationMs: beat.durationMs,
/** Entrance window of the beat (the renderer eases over it). */
enterMs: beat.kind === 'end' ? flip * 2 : 260,
}))
}
/**
* Deterministic timeline: beats in order, each starting where the previous
* ends. `totalMs` is the reel duration; `frameCount` is what the builder
* screenshots at `fps` (the last frame is held for one frame slot).
*
* @param {ReturnType<typeof buildColinhaBeats>} [beats]
* @param {number} [fps]
*/
export const buildColinhaTimeline = (beats = buildColinhaBeats(), fps = COLINHA_REEL.fps) => {
let atMs = 0
const entries = beats.map((beat) => {
const entry = { ...beat, atMs, endsAtMs: atMs + beat.durationMs }
atMs = entry.endsAtMs
return entry
})
entries.forEach((entry, index) => {
const next = entries[index + 1]
if (entry.kind === 'typing' && next) entry.hideAtMs = next.atMs
})
return {
entries,
totalMs: atMs,
fps,
frameCount: Math.max(1, Math.round((atMs / 1000) * fps)),
byId: new Map(entries.map((entry) => [entry.id, entry])),
}
}

View file

@ -0,0 +1,538 @@
/**
* Reel "Sua colinha para 2026" — the visual template of the chat reel: the
* WhatsApp-style conversation (voter on the right, "Time 1313" on the left),
* the printed-urna candidate cards and the two end screens (the filled colinha
* slip and the official dobradinha santinho). Structure ports the reference
* reel (dep_padrejoao) with the campaign kit language: official star badge,
* Brexter display face, kit palette.
*
* Pure string builder: fonts and image data URIs are passed in by the builder
* (`build-colinha-reel.mjs`); the page is rendered at 540×960 with
* deviceScaleFactor 2 → physical 1080×1920 frames. `window.colinhaRender(t)`
* sets the whole page state for a millisecond of the timeline (no CSS
* animations — every frame is deterministic), and `window.colinhaReady` is the
* promise the builder awaits before screenshotting.
*/
import { COLINHA_END, COLINHA_REEL } from './colinhaReel.mjs'
const BRAND = {
red: '#e4102f',
blue: '#184e92',
ink: '#111111',
chatGradient: 'linear-gradient(118deg,#5a67ec 0%,#8d37db 58%,#b52fb5 100%)',
}
const escapeHtml = (value) =>
String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
const BACK_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>'
const VIDEO_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="6.5" width="12.5" height="11" rx="2.6"/><path d="M15 10.6l6-3.1v9l-6-3.1z"/></svg>'
const INFO_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9.2"/><path d="M12 11v5.4"/><path d="M12 7.6h.01"/></svg>'
const CAMERA_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7.6h2.6l1.5-2.4h7.8l1.5 2.4H20a1.6 1.6 0 0 1 1.6 1.6v8.2A1.6 1.6 0 0 1 20 19H4a1.6 1.6 0 0 1-1.6-1.6V9.2A1.6 1.6 0 0 1 4 7.6z"/><circle cx="12" cy="13.2" r="3.4"/></svg>'
const MIC_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.6 11.4a6.4 6.4 0 0 0 12.8 0"/><path d="M12 17.8V21"/></svg>'
const IMAGE_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="15" rx="2.6"/><circle cx="9" cy="10" r="1.7"/><path d="M4.2 17.5l4.6-4.4 3.4 3.2 3.2-3 4.4 4.2"/></svg>'
const PARTY_STAR =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.8l2.9 6.9 7 .6-5.3 4.6 1.6 6.9L12 17l-6.2 3.8 1.6-6.9L2.1 9.3l7-.6z"/></svg>'
/**
* `@font-face` blocks as data URIs. Inter (the site's own grotesque) carries
* the whole reel — chat, cards, labels; Brexter is the kit display face, kept
* for the chat title; Arimo is the local metric-compatible Arial substitute
* used only where the drawn row must match the site's own colinha render.
*/
export const colinhaFontCss = ({ brexterTtf, interWoff2, arimoWoff2 }) => `
@font-face{font-family:ReelBrexter;src:url(data:font/ttf;base64,${brexterTtf}) format('truetype');font-weight:400;font-style:normal}
@font-face{font-family:ReelInter;src:url(data:font/woff2;base64,${interWoff2}) format('woff2');font-weight:100 900;font-style:normal}
@font-face{font-family:ReelArimo;src:url(data:font/woff2;base64,${arimoWoff2}) format('woff2');font-weight:100 900;font-style:normal}
`
const BASE_CSS = `
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{width:540px;height:960px;overflow:hidden;background:#fff}
body{font-family:ReelInter,Inter,system-ui,sans-serif;color:${BRAND.ink};-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}
.stage{position:relative;width:540px;height:960px;overflow:hidden;background:#fff;perspective:1600px}
.brexter{font-family:ReelBrexter,Impact,Haettenschweiler,'Arial Narrow Bold',sans-serif;font-weight:400}
img{display:block}
/* ---------------- chat ---------------- */
.chat{position:absolute;inset:0;z-index:1;background:#fff;transform-origin:50% 50%}
.chat-top{position:absolute;z-index:6;inset:0 0 auto;height:52px;display:flex;align-items:center;gap:9px;padding:0 13px 0 10px;background:#fff;border-bottom:1px solid #eceef1}
.chat-top__back{width:18px;height:18px;color:#15181d}
.chat-top__avatar{width:35px;height:35px;flex:none;overflow:hidden;border-radius:50%;background:#fff;box-shadow:0 0 0 1px #e7eaee}
.chat-top__avatar img{width:100%;height:100%;object-fit:cover}
.chat-top__name{font-size:24px;line-height:1;letter-spacing:.005em;white-space:nowrap}
.chat-top__icons{display:flex;align-items:center;gap:16px;margin-left:auto;color:#15181d}
.chat-top__icons svg{width:22px;height:22px}
.messages{position:absolute;z-index:4;top:52px;bottom:53px;left:0;right:0;padding:0 12px 14px;overflow:hidden;background:#fff}
.day{margin:13px 0 11px;text-align:center;font-size:13px;font-weight:700;letter-spacing:.19em;color:#8b9099;text-transform:uppercase}
.row{display:flex;margin:0 0 8px}
.row--right{flex-direction:column;align-items:flex-end}
.row--left{flex-direction:column;align-items:flex-start}
.row--card{margin-bottom:11px}
.bubble{max-width:440px;padding:11px 15px;border-radius:18px;font-size:20px;line-height:1.34;letter-spacing:.001em}
.bubble--assistant{background:#f0f1f3;color:#141414;border-bottom-left-radius:5px}
.bubble--voter{background:${BRAND.chatGradient};color:#fff;border-bottom-right-radius:5px}
.bubble--number{background:${BRAND.chatGradient};color:#fff;border-bottom-right-radius:5px;font-weight:700;letter-spacing:.02em}
.seen{display:none;margin:2px 5px 0 0;font-size:15px;color:#9aa0a8}
.typing{display:flex;align-items:center;gap:3.8px;padding:12px 13px}
.typing i{width:5.4px;height:5.4px;border-radius:50%;background:#b9bec6;display:block}
.bubble--card{background:#f0f1f3;padding:7px 7px 8px;border-radius:16px;border-bottom-left-radius:5px;max-width:none}
.anotei-bar{height:3px;margin:-7px -7px 7px;border-radius:16px 16px 0 0;background:#dfe3e9;overflow:hidden}
.anotei-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,${BRAND.red},${BRAND.blue})}
.anotei-label{margin:0 2px 8px;font-size:19px;color:#141414}
/* candidate card (urna language) */
.card{width:420px;padding:11px;display:grid;grid-template-columns:168px minmax(0,1fr);grid-template-rows:auto auto;column-gap:13px;row-gap:9px;background:#fff;border-radius:13px;box-shadow:0 1px 2px rgb(16 24 40 / 10%)}
.card__photo{position:relative;grid-column:1;grid-row:1;width:168px;height:208px;overflow:hidden;border-radius:9px;background:linear-gradient(168deg,#fdfaf4 0%,#f2e9da 100%);box-shadow:inset 0 0 0 1px rgb(16 24 40 / 5%)}
.card__photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 0}
.card__info{grid-column:2;grid-row:1;display:flex;flex-direction:column;min-width:0;padding:3px 1px 0 0}
.card__office{font-size:14px;font-weight:700;letter-spacing:.11em;color:#31363d;white-space:nowrap}
.card__party{display:flex;align-items:center;gap:7px;margin-top:7px;font-size:28px;font-weight:900;line-height:1;letter-spacing:-.01em;color:#0f0f0f}
.card__party svg{width:22px;height:22px;color:${BRAND.red}}
.card__state{margin-top:9px;font-size:12.5px;font-weight:700;letter-spacing:.145em;color:#767d86}
.card__spacer{flex:1 1 auto;min-height:8px}
.card__urna{font-size:12.5px;font-weight:700;letter-spacing:.145em;color:#767d86;margin-bottom:5px}
.card__digits{display:flex;gap:5px}
.card__digit{display:grid;place-items:center;width:43px;height:53px;border-radius:8px;background:#1c1c1c;color:#fff;font-size:32px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
/* "Sem estadual": the very same card shell as the candidates — photo window on
the left (the official Lula mark on a light neutral background) and the info
column on the right, with the instruction to pick a state deputy from Lula's
team where the urna digits sit. No party is named. */
.card__photo--mark{background:linear-gradient(160deg,#fdfbf6 0%,#efe9dd 100%);display:flex;align-items:center;justify-content:center;padding:0}
.card__photo--mark img{position:static;width:96%;height:auto;object-fit:contain}
.card__lead{margin-top:6px;font-size:26px;font-weight:900;line-height:1;letter-spacing:-.012em;color:#0f0f0f}
.card__name{grid-column:1/-1;grid-row:2;font-size:34px;font-weight:900;line-height:1;letter-spacing:-.012em;color:#0f0f0f;padding:3px 1px 1px}
/* composer */
.composer{position:absolute;z-index:6;inset:auto 0 0;height:53px;display:flex;align-items:center;gap:9px;padding:8px 11px 9px;background:#fff}
.composer__cam{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:${BRAND.chatGradient};color:#fff}
.composer__cam svg{width:17px;height:17px}
.composer__pill{flex:1 1 auto;display:flex;align-items:center;gap:12px;height:35px;padding:0 13px;border:1px solid #e7e9ed;border-radius:18px;color:#9aa0a8}
.composer__pill span{font-size:16px}
.composer__icons{display:flex;align-items:center;gap:14px;margin-left:auto;color:#15181d}
.composer__icons svg{width:19px;height:19px}
/* ---------------- end screen: the colinha assembles itself ---------------- */
.screen{position:absolute;inset:0;z-index:2;background:#fff;transform-origin:50% 50%;backface-visibility:hidden;visibility:hidden}
.screen--colinha{z-index:3}
.colinha-art{position:absolute;inset:0;width:540px;height:960px;object-fit:cover}
/* The official group photo (the "Time de você" family shot) fills the art's
baked photo band; the wave clip keeps the art's crest in front of it. Each
candidate's patch of the blurred copy fades out at their reveal. */
.figures{position:absolute;inset:0;clip-path:var(--wave)}
.group{position:absolute;inset:0;background-image:var(--group);background-size:540px 720px;background-position:0 -60px;background-repeat:no-repeat}
.group--blur{background-image:var(--group-blur);opacity:1;mask-repeat:no-repeat;-webkit-mask-repeat:no-repeat}
/* The art's own red band content is covered, but its coloured crest stays: the
cover is clipped by the band's own top curve. */
.band-cover{position:absolute;inset:0;clip-path:var(--band)}
.band-logo{position:absolute;opacity:0}
.band-logo img{display:block;width:100%;height:auto}
/* The slip's own title (with its sky), feathered; it appears last. */
.title-patch{position:absolute;left:90px;top:5px;width:360px;height:135px;opacity:0}
.title-patch img{display:block;width:100%;height:100%}
.row-mask{position:absolute;background:#fff}
.colinha-row{position:absolute;inset:0;opacity:0}
.colinha-mask{position:absolute;left:132.3px;top:536.64px;width:309.96px;height:27.84px;background:#fff}
.colinha-copy{position:absolute;left:145.26px;top:542.02px;width:285.12px;height:24px;display:flex;align-items:baseline;justify-content:space-between;gap:8.1px}
.colinha-copy__office{font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:13.45px;font-weight:400;line-height:1;color:#202020;white-space:nowrap;flex:none}
.colinha-copy__name{font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:18.36px;font-weight:900;line-height:1;color:${BRAND.red};letter-spacing:-.04em;white-space:nowrap;text-align:right}
.colinha-digits{position:absolute;left:139.86px;top:560.64px;display:flex;gap:4.38px}
.colinha-digit{display:grid;place-items:center;width:32.94px;height:41.76px;font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:22.95px;font-weight:900;line-height:1;color:#171717}
/* intro fade (white cover over the very first frames) */
.fade{position:absolute;z-index:9;inset:0;background:#fff;pointer-events:none}
`
const RENDER_JS = `
;(() => {
const CFG = window.__colinhaConfig
const clamp01 = (v) => Math.min(1, Math.max(0, v))
const easeOutCubic = (p) => 1 - Math.pow(1 - p, 3)
const easeInOutCubic = (p) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2)
const beats = CFG.beats
const nodeById = new Map()
for (const el of document.querySelectorAll('[data-beat]')) nodeById.set(el.dataset.beat, el)
const messages = document.getElementById('messages')
const chat = document.getElementById('chat')
const screens = { colinha: document.getElementById('screen-colinha') }
const fade = document.getElementById('fade')
const ends = beats.filter((beat) => beat.kind === 'end')
const titlePatch = document.getElementById('title-patch')
const figures = new Map()
const logos = new Map()
const rowMasks = new Map()
for (const el of document.querySelectorAll('[data-figure]')) figures.set(el.dataset.figure, el)
for (const el of document.querySelectorAll('[data-logo]')) logos.set(el.dataset.logo, el)
for (const el of document.querySelectorAll('[data-row]')) rowMasks.set(el.dataset.row, el)
const drawnRow = document.getElementById('colinha-drawn')
let scrollTargets = []
let prepared = false
const prepare = () => {
for (const el of nodeById.values()) el.style.display = ''
for (const el of document.querySelectorAll('.seen')) el.style.display = 'block'
const max = Math.max(0, messages.scrollHeight - messages.clientHeight)
scrollTargets = beats.map((beat) => {
const el = nodeById.get(beat.id)
if (!el) return max
const bottom = el.offsetTop + el.offsetHeight + 12
return Math.min(max, Math.max(0, bottom - messages.clientHeight))
})
for (const el of nodeById.values()) el.style.display = ''
for (const el of document.querySelectorAll('.seen')) el.style.display = ''
fitColinhaName()
fitCardNames()
prepared = true
}
/** Long ballot names shrink on the card's one name line instead of wrapping. */
function fitCardNames() {
const max = 420 - 22
const ctx = document.createElement('canvas').getContext('2d')
if (!ctx) return
for (const el of document.querySelectorAll('.card__name')) {
el.style.fontSize = ''
el.style.whiteSpace = ''
const size = Number.parseFloat(getComputedStyle(el).fontSize)
ctx.font =
getComputedStyle(el).fontWeight + ' ' + size + 'px ReelInter, Inter, system-ui, sans-serif'
const width = ctx.measureText(el.textContent ?? '').width
if (width > max && width > 0) {
el.style.fontSize = Math.max(20, Math.floor((size * max) / width)) + 'px'
}
el.style.whiteSpace = 'nowrap'
}
}
function fitColinhaName() {
const name = document.querySelector('.colinha-copy__name')
const office = document.querySelector('.colinha-copy__office')
if (!name || !office) return
const band = 285.12
const gap = 8.1
const max = band - office.getBoundingClientRect().width - gap
const width = name.getBoundingClientRect().width
if (width > max && width > 0) {
name.style.fontSize = Math.floor((18.36 * max) / width) + 'px'
}
}
const renderTyping = (el, t, enter) => {
const dots = el.querySelectorAll('i')
dots.forEach((dot, index) => {
const phase = ((t - enter) / 420) * Math.PI * 2 - index * 0.7
const lift = Math.max(0, Math.sin(phase)) * 2.6
dot.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)'
dot.style.opacity = (0.55 + 0.45 * Math.max(0, Math.sin(phase))).toFixed(3)
})
}
window.colinhaReset = () => {
prepared = false
for (const el of nodeById.values()) el.style.display = ''
}
window.colinhaRender = (t) => {
if (!prepared) prepare()
const fadeP = clamp01(t / 180)
fade.style.opacity = String(1 - fadeP)
let active = -1
for (let i = 0; i < beats.length; i++) {
if (t >= beats[i].atMs) active = i
else break
}
for (let i = 0; i < beats.length; i++) {
const beat = beats[i]
if (beat.kind === 'end') continue
const el = nodeById.get(beat.id)
if (!el) continue
const visible = t >= beat.atMs && (beat.hideAtMs === undefined || t < beat.hideAtMs)
if (!visible) { el.style.display = 'none'; continue }
el.style.display = ''
const p = easeOutCubic(clamp01((t - beat.atMs) / beat.enterMs))
if (beat.kind === 'card') {
el.style.opacity = String(p)
el.style.transform = 'translateY(' + ((1 - p) * 15).toFixed(2) + 'px) scale(' + (0.965 + 0.035 * p).toFixed(4) + ')'
} else if (beat.kind === 'typing') {
renderTyping(el, t, beat.atMs)
} else {
el.style.opacity = String(p)
el.style.transform = 'translateY(' + ((1 - p) * 9).toFixed(2) + 'px)'
}
if (beat.seen) {
const seen = el.querySelector('.seen')
if (seen) {
const sp = easeOutCubic(clamp01((t - beat.atMs - 720) / 260))
seen.style.display = sp > 0 ? 'block' : 'none'
seen.style.opacity = String(sp)
}
}
if (beat.kind === 'card') {
const bar = el.querySelector('.anotei-bar i')
if (bar) {
const from = (beat.cardProgressFrom ?? 0) * 100
const to = beat.cardProgress * 100
bar.style.width = (from + (to - from) * easeOutCubic(clamp01((t - beat.atMs) / 620))).toFixed(2) + '%'
}
}
}
if (active >= 0) {
const from = active > 0 ? scrollTargets[active - 1] : 0
const to = scrollTargets[active]
const p = easeInOutCubic(clamp01((t - beats[active].atMs) / 460))
messages.scrollTop = from + (to - from) * p
} else {
messages.scrollTop = 0
}
if (ends.length > 0) {
const flip = CFG.flipMs
const first = ends[0]
const second = ends[1]
const out1 = clamp01((t - first.atMs) / flip)
if (t < first.atMs) {
chat.style.visibility = 'visible'
chat.style.transform = 'rotateY(0deg)'
} else if (out1 < 1) {
chat.style.visibility = 'visible'
chat.style.transform = 'rotateY(' + (-90 * easeInOutCubic(out1)).toFixed(3) + 'deg)'
} else {
chat.style.visibility = 'hidden'
}
const in1 = clamp01((t - first.atMs - flip) / flip)
if (in1 > 0) {
screens.colinha.style.visibility = 'visible'
const out2 = second ? clamp01((t - second.atMs) / flip) : 0
const rot = second && t >= second.atMs ? -90 * easeInOutCubic(out2) : 90 * (1 - easeInOutCubic(in1))
screens.colinha.style.transform = 'rotateY(' + rot.toFixed(3) + 'deg)'
}
}
for (const reveal of CFG.reveals) {
const p = easeOutCubic(clamp01((t - reveal.atMs) / reveal.durationMs))
const figure = figures.get(reveal.candidateId)
if (figure) figure.style.opacity = String(1 - p)
const logo = logos.get(reveal.candidateId)
if (logo) {
logo.style.opacity = String(p)
logo.style.transform = 'scale(' + (0.82 + 0.18 * p).toFixed(4) + ')'
}
const mask = rowMasks.get(reveal.candidateId)
if (mask) mask.style.opacity = String(1 - p)
if (drawnRow && reveal.candidateId === CFG.drawnRow) drawnRow.style.opacity = String(p)
}
if (CFG.brandAtMs !== undefined) {
const brandP = easeInOutCubic(clamp01((t - CFG.brandAtMs) / 900))
if (titlePatch) titlePatch.style.opacity = String(brandP)
}
}
window.colinhaReady = (async () => {
await document.fonts.ready
await Promise.all([...document.images].map((img) => img.decode().catch(() => undefined)))
prepare()
window.colinhaRender(0)
return true
})()
})()
`
/**
* @param {{ beats: Array<{ id: string, kind: string, atMs: number, enterMs: number, text?: string, candidateId?: string, screen?: string, seen?: boolean, hideAtMs?: number, cardProgress?: number }>, candidates: Array<{ id: string, office: string, name: string, number: string, photo: string, figure: Record<string, number>, logo: Record<string, number>, row: Record<string, number> }>, reveals: Array<{ candidateId: string, atMs: number, durationMs: number }>, brandAtMs: number, drawnRow: string, totalMs: number, flipMs: number, fontCss: string, assets: Record<string, string>, cardFinale?: boolean }} options
*/
export const colinhaReelHtml = ({
beats,
candidates,
reveals,
brandAtMs,
drawnRow,
totalMs,
flipMs,
fontCss,
assets,
cardFinale = false,
}) => {
const candidateById = new Map(candidates.map((candidate) => [candidate.id, candidate]))
const estadual = candidates[1]
const cardCount = candidates.length
const rows = beats
.filter((beat) => beat.kind !== 'end')
.map((beat) => {
if (beat.kind === 'typing') {
return `<div class="row row--left" data-beat="${beat.id}"><div class="bubble bubble--assistant typing"><i></i><i></i><i></i></div></div>`
}
if (beat.kind === 'card') {
const candidate = candidateById.get(beat.candidateId)
if (candidate.generic) {
return `<div class="row row--left row--card" data-beat="${beat.id}">
<div class="bubble bubble--card">
<div class="anotei-bar"><i style="width:${(((candidates.findIndex((c) => c.id === beat.candidateId) + 1) / cardCount) * 100).toFixed(2)}%"></i></div>
<div class="anotei-label">anotei:</div>
<article class="card">
<div class="card__photo card__photo--mark"><img src="${assets.lulaMark}" alt="Marca oficial de Lula"></div>
<div class="card__info">
<div class="card__office">${escapeHtml(candidate.office)}</div>
<div class="card__lead">${escapeHtml(candidate.name)}</div>
<div class="card__state">${escapeHtml(COLINHA_REEL.cardState)}</div>
</div>
<div class="card__name">ESCOLHA O SEU</div>
</article>
</div>
</div>`
}
const progress =
((candidates.findIndex((c) => c.id === beat.candidateId) + 1) / cardCount) * 100
const digits = [...candidate.number]
.map((digit) => `<span class="card__digit">${escapeHtml(digit)}</span>`)
.join('')
const photoStyle = candidate.photoZoom
? ` style="transform:translateY(${candidate.photoShiftY ?? 0}%) scale(${candidate.photoZoom});transform-origin:${candidate.photoOrigin ?? '50% 0%'}"`
: ''
return `<div class="row row--left row--card" data-beat="${beat.id}">
<div class="bubble bubble--card">
<div class="anotei-bar"><i style="width:${progress.toFixed(2)}%"></i></div>
<div class="anotei-label">anotei:</div>
<article class="card">
<div class="card__photo"><img src="${assets[`photo:${candidate.id}`]}" alt="${escapeHtml(candidate.name)}"${photoStyle}></div>
<div class="card__info">
<div class="card__office">${escapeHtml(candidate.office)}</div>
<div class="card__party">${escapeHtml(COLINHA_REEL.cardParty)}${PARTY_STAR}</div>
<div class="card__state">${escapeHtml(COLINHA_REEL.cardState)}</div>
<div class="card__spacer"></div>
<div class="card__urna">NA URNA</div>
<div class="card__digits">${digits}</div>
</div>
<div class="card__name">${escapeHtml(candidate.name)}</div>
</article>
</div>
</div>`
}
const seen = beat.seen ? '<div class="seen">Visto</div>' : ''
const variant = beat.kind === 'number' ? 'number' : beat.kind
return `<div class="row ${beat.kind === 'voter' || beat.kind === 'number' ? 'row--right' : 'row--left'}" data-beat="${beat.id}">
<div class="bubble bubble--${variant}">${escapeHtml(beat.text)}</div>
${seen}
</div>`
})
.join('\n')
const colinhaDigits = [...estadual.number]
.map((digit) => `<span class="colinha-digit">${escapeHtml(digit)}</span>`)
.join('')
const figuresMarkup = candidates
.map((candidate) => {
const { cx, cy, rx, ry } = candidate.figure
const mask = `radial-gradient(ellipse ${rx}px ${ry}px at ${cx}px ${cy}px, #000 52%, transparent 100%)`
return `<div class="group group--blur" data-figure="${candidate.id}" style="mask-image:${mask};-webkit-mask-image:${mask}"></div>`
})
.join('\n ')
const logosMarkup = candidates
.map(
(candidate) =>
`<div class="band-logo" data-logo="${candidate.id}" style="left:${candidate.logo.left}px;top:${candidate.logo.top}px;width:${candidate.logo.width}px"><img src="${assets[`logo:${candidate.id}`]}" alt=""></div>`,
)
.join('\n ')
const rowMasksMarkup = candidates
.map(
(candidate) =>
`<div class="row-mask" data-row="${candidate.id}" style="left:${COLINHA_END.rowMask.left}px;top:${candidate.row.top}px;width:${COLINHA_END.rowMask.width}px;height:${candidate.row.height}px"></div>`,
)
.join('\n ')
let cardIndex = -1
const config = {
totalMs,
flipMs,
reveals: reveals.map((reveal) => ({ ...reveal })),
brandAtMs,
drawnRow,
beats: beats.map((beat) => {
const cardProgress = beat.kind === 'card' ? (++cardIndex + 1) / cardCount : undefined
return {
id: beat.id,
kind: beat.kind,
atMs: beat.atMs,
enterMs: beat.enterMs,
...(beat.candidateId ? { candidateId: beat.candidateId } : {}),
...(beat.screen ? { screen: beat.screen } : {}),
...(beat.seen ? { seen: true } : {}),
...(beat.hideAtMs !== undefined ? { hideAtMs: beat.hideAtMs } : {}),
...(cardProgress !== undefined
? { cardProgress, cardProgressFrom: Math.max(0, cardProgress - 1 / cardCount) }
: {}),
}
}),
}
return `<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>${escapeHtml(COLINHA_REEL.title)}</title>
<style>${fontCss}${BASE_CSS}</style>
</head>
<body>
<div class="stage" style="--wave:${assets.waveClip};--band:${assets.bandClip};--group:url(&quot;${assets.group}&quot;);--group-blur:url(&quot;${assets.groupBlur}&quot;)">
<div class="chat" id="chat">
<header class="chat-top">
<span class="chat-top__back">${BACK_ICON}</span>
<span class="chat-top__avatar"><img src="${assets.avatar}" alt="Jorge Solla 1313"></span>
<span class="chat-top__name brexter">${escapeHtml(COLINHA_REEL.chatTitle)}</span>
<span class="chat-top__icons">${VIDEO_ICON}${INFO_ICON}</span>
</header>
<div class="messages" id="messages">
<div class="day">HOJE, ${escapeHtml(COLINHA_REEL.chatHour)}</div>
${rows}
</div>
<footer class="composer">
<span class="composer__cam">${CAMERA_ICON}</span>
<span class="composer__pill"><span>Mensagem...</span><span class="composer__icons">${MIC_ICON}${IMAGE_ICON}</span></span>
</footer>
</div>
<section class="screen screen--colinha" id="screen-colinha">
${
cardFinale
? `<img class="colinha-art" src="${assets.siteCard}" alt="Colinha personalizada gerada pelo site, com o time completo">`
: `<img class="colinha-art" src="${assets.colinhaArt}" alt="Colinha oficial de votação com o time completo">
<div class="figures">
<div class="group group--sharp"></div>
${figuresMarkup}
</div>
<div class="band-cover" style="background:${COLINHA_END.band.color}"></div>
${logosMarkup}
<div class="title-patch" id="title-patch"><img src="${assets.titlePatch}" alt="Mais Saúde, Mais Futuro"></div>
${rowMasksMarkup}
<div class="colinha-row" id="colinha-drawn">
<div class="colinha-mask"></div>
<div class="colinha-copy">
<span class="colinha-copy__office">${escapeHtml(estadual.office)}</span>
<span class="colinha-copy__name">${escapeHtml(estadual.name)}</span>
</div>
<div class="colinha-digits">${colinhaDigits}</div>
</div>`
}
</section>
<div class="fade" id="fade"></div>
</div>
<script>window.__colinhaConfig=${JSON.stringify(config)};${RENDER_JS}</script>
</body>
</html>
`
}

View file

@ -101,6 +101,23 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'],
specs: ['frontend', 'campaignNewsletter'],
},
{
// S46 — the public `/potencial` surface: the section query endpoints over
// the committed TSE shards, the pure calculator and the story canvas
// export. The artifact itself (`public/dados/…`) and its build script are
// outside `src/` — the manifest only maps src diffs; a data-only rebuild
// rides the deploy verify's full suite.
prefixes: [
'src/app/(frontend)/(home)/potencial',
'src/app/(frontend)/api/potencial',
'src/app/(frontend)/fonts.ts',
'src/components/potencial',
'src/lib/sectionPotential',
'src/lib/sectionStoryRender',
'src/utilities/potencialSections',
],
specs: ['frontendPotencial'],
},
{
// S19/S29 — the share-link surface: the `[type]` branch serves the OG card,
// the instant handoff and the announcement page (`.ics` included), and the
@ -510,6 +527,7 @@ export const E2E_AFFECTED_MANIFEST = [
'campaignSpeechCut',
'campaignReel',
'frontendFotosSelfie',
'frontendPotencial',
],
},
{

View file

@ -69,6 +69,8 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/cityReportResearch.mjs',
'scripts/lib/cityReportTerritory.mjs',
'scripts/lib/cli.mjs',
'scripts/lib/colinhaReel.mjs',
'scripts/lib/colinhaReelTemplate.mjs',
'scripts/lib/conflictMarkers.mjs',
'scripts/lib/db-start.mjs',
'scripts/lib/deploy-trigger.mjs',

View file

@ -0,0 +1,58 @@
# Ativos do reel "Sua colinha para 2026" (colinha-time-1313)
Fotos oficiais recortadas e o selo da estrela usados pelo builder
[`scripts/build-colinha-reel.mjs`](../../../build-colinha-reel.mjs) — o reel de
chat que monta a colinha do time 1313 na ordem da urna. Os derivativos são
commitados (o reel é reprodutível a partir deles); as origens pesadas ficam fora
do repo.
| Arquivo | Origem | Receita |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `solla.webp` | `FOTO SOLLA CAMISA BRANCA.png` (kit oficial, `OneDrive_2026-09-19/kit solla 1313/`) | `trim` + altura 1000 px + webp q88 |
| `julio.webp` | `DOBRADINHAS-ORGANIZADAS/Júlio Pinheiro/foto.png` (extração oficial da campanha; `FONTES.txt` da pasta documenta a camada do adesivo) | idem |
| `wagner.webp` | `public/WAGNER - 2-9 final.webp` (site) | idem |
| `rui.webp` | `public/RUI - 2-2.webp` (site) | idem |
| `jeronimo.webp` | `public/Jeronimo.webp` (site) | idem |
| `lula.webp` | `public/Lula.webp` (site) | idem |
| `logo-o.webp` | `public/campaign-kit/radio/radio-jorge-solla-1313-logo.png` (kit oficial — a logo "O" da rádio, usada no avatar do header) | `trim` + 256 px + webp q90 |
| `sfx-confirma.mp3` | o "confirma" da urna eletrônica (som do TSE), extraído de uma gravação pública do efeito (YouTube `SALMI6_CSYk`): trecho 2,98–3,98 s (a sequência rápida de bipes após a tecla verde), high-pass 350 Hz / low-pass 9 kHz, mono, loudness −15 LUFS | `ffmpeg -ss 2.98 -t 1.00 -af "highpass=f=350,lowpass=f=9000" -ac 1 -ar 44100` + `loudnorm=I=-15:TP=-1.5` |
| `jingle-play.mp3` | o jingle de axé da campanha (`/home/fsolla/Downloads/Jorge solla play.wav`, 2:11) — derivado mono 112 kbps; entra como cama sonora a partir de 0:04 do vídeo, bem baixo | `ffmpeg -i "Jorge solla play.wav" -ac 1 -ar 44100 -b:a 112k` |
| `fonts/inter-latin.woff2` | Inter (Google Fonts, OFL) — subset latim, eixo `wght` 100–900 | baixado do CSS `https://fonts.googleapis.com/css2?family=Inter:wght@100..900` (bloco `latin`) |
Os demais ativos do reel são lidos direto do site: a arte da colinha
(`public/cards/modelo-colinha.jpeg`), o fundo oficial da dobradinha
(`public/cards/estaduais/julio-fotos.webp`, céu da cena final) e o overlay
(`public/cards/estaduais/julio-base.webp`, lockup do Julio) e as fontes Brexter
(`src/app/(frontend)/fonts/`), Inter (os `fonts/` daqui) e Arimo
(`~/.local/share/fonts`, dependência já documentada pelo `build-radio-artes`
para a linha desenhada da colinha).
A cena final é remontada pelo builder a partir dessas artes: o grupo é a foto
oficial `julio-fotos.webp` (com uma cópia desfocada para o reveal por foco), a
faixa vermelha tem a crista preservada (recorte pelo topo da própria faixa) e
suas logos saem das artes com o vermelho (`#e50e2f`) virando alfa — a quadrada
do Solla e as quatro marcas da colinha, o lockup do Julio do overlay da
dobradinha. Nada disso é commitado — são derivados em memória a cada build.
O enquadramento da foto de cada candidato no card é dado pelo par
`photoZoom`/`photoShiftY` (e `photoOrigin`, quando preciso) em
`scripts/lib/colinhaReel.mjs`: o card usa `object-fit: cover` com o topo
ancorado e o zoom/deslocamento garantem cabeça inteira com respiro.
Regenerar um derivativo (exemplo da foto do Solla):
```bash
node -e "require('sharp')('<origem>').trim({threshold:8}).resize({height:1000,fit:'inside'}).webp({quality:88}).toFile('scripts/reels/assets/colinha-time-1313/solla.webp')"
```
A terceira versão do reel (`--finale=card`) não é composta aqui: o builder
dirige o estúdio de cards em produção (`/cards?model=minha-colinha`), escolhe o
estadual Julio Pinheiro e levanta o canvas 1080×1920 que o renderer do próprio
site pintou — o PNG cru sai no pacote como `colinha-site.png`.
O `--beeps` do builder mixa esse efeito (um bipe por card, no beat de cada
candidato) na trilha do vídeo, sem re-encodar o vídeo (`-c:v copy`); o lote
`pnpm reels:dobradinhas` já liga a flag.
Uso no reel: `pnpm reels:colinha` (ver o cabeçalho do builder para as flags de
finale, preview, capa, beeps e dry-run).

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -0,0 +1,258 @@
{
"source": "Produção (state_deputy × contact) em 2026-10-01: as dobradinhas cujo nome NÃO contém \"(apagar)\" (marcador temporário da assessoria para as que saíram). 46 registros ativos; 4 sem par no catálogo do site (Gildo Baleia, Juvenilson, Rafael Meireles) e 1 duplicata (Geane Vasconcelo ACS) ficam fora — o reel-card exige o card do site e a foto oficial. As fotos vêm de DOBRADINHAS-ORGANIZADAS/<pasta>/foto.png (as mesmas dos cards da Plenária da Vitória).",
"photosDir": "/home/fsolla/Downloads/DOBRADINHAS-ORGANIZADAS",
"dobradinhas": [
{
"slug": "enfermagem",
"catalog": "Coletivo de Enfermagem",
"number": "13763",
"folder": "Coletivo de Enfermagem da Bahia"
},
{
"slug": "andrea",
"catalog": "Andréa Castro",
"number": "55670",
"folder": "Andréa Castro"
},
{
"slug": "andre",
"catalog": "André Fidalgo",
"number": "13131",
"folder": "André Fidalgo"
},
{
"slug": "arthur",
"catalog": "Artur Barachisio Lisbôa",
"number": "55255",
"folder": "Arthur Lisboa"
},
{
"slug": "bobo",
"catalog": "Bobô",
"number": "65888",
"folder": "Bobô"
},
{
"slug": "carol",
"catalog": "Carol da Pitanguinha",
"number": "13110",
"folder": "Carol Pitanguinha"
},
{
"slug": "cicero",
"catalog": "Cícero Monteiro",
"number": "70133",
"folder": "Cicero Monteiro"
},
{
"slug": "denise",
"catalog": "Denise Menezes",
"number": "55111",
"folder": "Denise Menezes"
},
{
"slug": "euclides",
"catalog": "Euclides Fernandes",
"number": "13123",
"folder": "Euclides Fernandes"
},
{
"slug": "fabiola",
"catalog": "Dra. Fabíola Mansur",
"number": "43123",
"folder": "Fabíola Mansur"
},
{
"slug": "fatima",
"catalog": "Fátima Nunes",
"number": "13567",
"folder": "Fátima Nunes"
},
{
"slug": "geane",
"catalog": "Geane Vasconcelos",
"number": "12120",
"folder": "Geane"
},
{
"slug": "hilton",
"catalog": "Hilton",
"number": "50150",
"folder": "Hilton"
},
{
"slug": "ivana",
"catalog": "Ivana Bastos",
"number": "55555",
"folder": "Ivana Bastos"
},
{
"slug": "jamille",
"catalog": "Jamile da Saúde",
"number": "45456",
"folder": "Jamille"
},
{
"slug": "josafa",
"catalog": "Josafá Marinho",
"number": "12222",
"folder": "Josafá"
},
{
"slug": "juliete",
"catalog": "Juliete Barreto",
"number": "65100",
"folder": "Juliete"
},
{
"slug": "julio",
"catalog": "Julio Pinheiro",
"number": "13999",
"folder": "Júlio Pinheiro"
},
{
"slug": "junior",
"catalog": "Júnior Muniz",
"number": "13000",
"folder": "Junior Muniz"
},
{
"slug": "kleber",
"catalog": "Kleber Rosa",
"number": "50500",
"folder": "Kleber"
},
{
"slug": "leninha",
"catalog": "Lenínha Valente",
"number": "13111",
"folder": "Leninha"
},
{
"slug": "leo",
"catalog": "Leo de Neco",
"number": "70700",
"folder": "Leo de Neco"
},
{
"slug": "magno",
"catalog": "Magno",
"number": "18000",
"folder": "Magno"
},
{
"slug": "mestre",
"catalog": "Mestre Reginaldo",
"number": "18273",
"folder": "Mestre Reginaldo"
},
{
"slug": "neusa",
"catalog": "Neusa Cadore",
"number": "13690",
"folder": "Neusa"
},
{
"slug": "niltinho",
"catalog": "Niltinho",
"number": "55999",
"folder": "Niltinho"
},
{
"slug": "osni",
"catalog": "Osni",
"number": "13013",
"folder": "Osni"
},
{
"slug": "patrick",
"catalog": "Patrick Lopes",
"number": "70123",
"folder": "Patrick Lopes"
},
{
"slug": "pinheiro",
"catalog": "Pinheiro",
"number": "13700",
"folder": "Pinheiro"
},
{
"slug": "radiovaldo",
"catalog": "Radiovaldo",
"number": "13050",
"folder": "Radiovaldo Costa"
},
{
"slug": "robinson",
"catalog": "Robinson",
"number": "13500",
"folder": "Robinson"
},
{
"slug": "rogerio",
"catalog": "Rogério Andrade",
"number": "15444",
"folder": "Rogério Andrade"
},
{
"slug": "rosemberg",
"catalog": "Rosemberg",
"number": "13444",
"folder": "Rosemberg"
},
{
"slug": "rosival",
"catalog": "Rosival Leite",
"number": "13333",
"folder": "Rosival Leite"
},
{
"slug": "rowenna",
"catalog": "Rowenna",
"number": "13456",
"folder": "Rowenna"
},
{
"slug": "silva",
"catalog": "Silva Neto",
"number": "70444",
"folder": "Silva Neto"
},
{
"slug": "silvio",
"catalog": "Silvio Dias",
"number": "13113",
"folder": "Sílvio Dias"
},
{
"slug": "vilma",
"catalog": "Vilma Reis",
"number": "13033",
"folder": "Vilma Reis"
},
{
"slug": "wenceslau",
"catalog": "Wenceslau",
"number": "65111",
"folder": "Wenceslau"
},
{
"slug": "yulo",
"catalog": "Yulo",
"number": "13234",
"folder": "Yulo"
},
{
"slug": "ze-raimundo",
"catalog": "Zé Raimundo",
"number": "13222",
"folder": "Zé Raimundo"
},
{
"slug": "angelo",
"catalog": "Angelo Almeida",
"number": "13100",
"folder": "Angelo Almeida"
}
]
}

View file

@ -0,0 +1,89 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { campaignTextFont } from '@/app/(frontend)/fonts'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { SectionPotentialStudio } from '@/components/potencial/SectionPotentialStudio'
import {
POTENTIAL_HEADLINE,
POTENTIAL_INTRO,
POTENTIAL_PATH,
} from '@/components/potencial/sectionPotentialCopy'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
/**
* S46 — a página pública do "potencial da sua seção": informa UF, município,
* zona e seção do título e sai com o story 1080×1920. Sem login, sem cadastro
* e sem coleta de contato; os números vêm do artefato TSE commitado, servido
* pelas rotas `/api/potencial/*`. Indexável com título e descrição próprios.
*/
export async function generateMetadata(): Promise<Metadata> {
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, POTENTIAL_PATH)
const { url: imageUrl } = await resolveOgImage(null)
const title = `${POTENTIAL_HEADLINE} | ${siteName}`
const images = imageUrl ? [{ url: imageUrl, alt: POTENTIAL_HEADLINE }] : []
return {
title,
description: POTENTIAL_INTRO,
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: POTENTIAL_INTRO,
images,
},
twitter: {
card: 'summary_large_image',
title,
description: POTENTIAL_INTRO,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function PotencialPage() {
const [showJingles, showConteudos, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
return (
<>
<CampaignPageHeader />
<div className="border-b border-(--campaign-line) bg-white">
<div className="mx-auto w-full max-w-6xl px-5 py-2.5 text-sm text-(--campaign-muted) sm:px-8">
<Link
href="/"
className="rounded-sm font-bold text-(--pt-red) no-underline underline-offset-4 hover:underline"
>
Início
</Link>
<span className="px-1 text-[#c9c2bd]">/</span> Potencial da sua seção
</div>
</div>
<main className="w-full bg-white text-black">
<SectionPotentialStudio fontFamily={campaignTextFont.style.fontFamily} />
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos={showFotos}
/>
</>
)
}

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { potentialMunicipiosQuerySchema } from '@/lib/schemas/sectionPotentialQuery'
import { listPotentialMunicipalities } from '@/lib/sectionPotential'
import {
checkContentEventRateLimit,
contentEventClientKey,
} from '@/utilities/content/contentEventRateLimit'
import { loadPotentialUfShard } from '@/utilities/potencialSections'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
/**
* S46 — the municípios of one UF for the title form's combobox. Public read of
* committed TSE geography: same-origin, rate-limited like the other anonymous
* JSON routes, strict query. The answer is lean (`code` + `name`), alphabetical
* and cacheable for a day — the artifact only changes with a deploy.
*/
const json = (body: unknown, status = 200, cacheable = false): NextResponse =>
NextResponse.json(body, {
status,
headers: cacheable
? { 'Cache-Control': 'public, max-age=86400' }
: { 'Cache-Control': 'no-store' },
})
export const GET = async (request: Request): Promise<NextResponse> => {
if (!isSameOriginRequest(request)) return json({ ok: false, error: 'origin' }, 403)
const clientKey = contentEventClientKey(request.headers)
if (!checkContentEventRateLimit(clientKey && `potencial:${clientKey}`)) {
return json({ ok: false, error: 'rate-limited' }, 429)
}
const parsed = potentialMunicipiosQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
)
if (!parsed.success) return json({ ok: false, error: 'query' }, 400)
const shard = await loadPotentialUfShard(parsed.data.uf)
if (!shard) return json({ ok: false, error: 'not-found' }, 404)
return json({ ok: true, municipalities: listPotentialMunicipalities(shard) }, 200, true)
}

View file

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server'
import { potentialSectionQuerySchema } from '@/lib/schemas/sectionPotentialQuery'
import { findPotentialSection } from '@/lib/sectionPotential'
import {
checkContentEventRateLimit,
contentEventClientKey,
} from '@/utilities/content/contentEventRateLimit'
import { loadPotentialUfShard } from '@/utilities/potencialSections'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
/**
* S46 — the raw numbers of one polling section (TSE 1st round 2026): identity
* plus aptos/comparecimento/Lula/Flávio/válidos. The percentages are computed
* by the shared pure module on the client, so the panel and the story image
* can never drift. Unknown UF or section answers 404 — the page's honest
* "não encontramos essa seção" state; a section with no valid votes is a 200
* whose `no-data` verdict belongs to the pure calculator.
*/
const json = (body: unknown, status = 200, cacheable = false): NextResponse =>
NextResponse.json(body, {
status,
headers: cacheable
? { 'Cache-Control': 'public, max-age=86400' }
: { 'Cache-Control': 'no-store' },
})
export const GET = async (request: Request): Promise<NextResponse> => {
if (!isSameOriginRequest(request)) return json({ ok: false, error: 'origin' }, 403)
const clientKey = contentEventClientKey(request.headers)
if (!checkContentEventRateLimit(clientKey && `potencial:${clientKey}`)) {
return json({ ok: false, error: 'rate-limited' }, 429)
}
const parsed = potentialSectionQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
)
if (!parsed.success) return json({ ok: false, error: 'query' }, 400)
const shard = await loadPotentialUfShard(parsed.data.uf)
if (!shard) return json({ ok: false, error: 'not-found' }, 404)
const section = findPotentialSection(shard, {
municipalityCode: parsed.data.codigo,
zone: parsed.data.zona,
section: parsed.data.secao,
})
if (!section) return json({ ok: false, error: 'not-found' }, 404)
return json({ ok: true, section }, 200, true)
}

View file

@ -1,3 +1,4 @@
import { Arimo, Exo_2 } from 'next/font/google'
import localFont from 'next/font/local'
/**
@ -16,3 +17,12 @@ export const brexterBold = localFont({
// loads it on demand via `document.fonts.load`).
preload: false,
})
/**
* Campanha 2026 — Exo 2 display (títulos/CTAs) e Arimo (texto), as duas faces
* OFL self-hosted do site. Vivem aqui (não no layout) desde o S46: a página
* pública do story passa a família ao canvas do cliente, e o `(frontend)`
* layout consome as mesmas instâncias — uma só, sem preload duplicado.
*/
export const campaignDisplayFont = Exo_2({ subsets: ['latin'], variable: '--font-exo2' })
export const campaignTextFont = Arimo({ subsets: ['latin'], variable: '--font-arimo' })

View file

@ -7,15 +7,12 @@ import { resolveSiteMetadata } from '@/utilities/seo'
import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Metadata } from 'next'
import { Arimo, Exo_2, Inter } from 'next/font/google'
import { Inter } from 'next/font/google'
import React from 'react'
import { campaignDisplayFont, campaignTextFont } from './fonts'
import './styles.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
// Campanha 2026: Exo 2 para display (títulos/CTAs) e Arimo para cards/tags —
// mesmo uso das peças do Penpot e do santinho. OFL, self-hosted pelo next/font.
const exo2 = Exo_2({ subsets: ['latin'], variable: '--font-exo2' })
const arimo = Arimo({ subsets: ['latin'], variable: '--font-arimo' })
export async function generateMetadata(): Promise<Metadata> {
const payload = await getCachedGlobal('metadata')()
@ -69,7 +66,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
return (
<html
lang="pt-BR"
className={`${inter.variable} ${exo2.variable} ${arimo.variable} w-screen h-screen scrollbar-hide`}
className={`${inter.variable} ${campaignDisplayFont.variable} ${campaignTextFont.variable} w-screen h-screen scrollbar-hide`}
style={{ colorScheme: 'light' }}
suppressHydrationWarning
>

View file

@ -0,0 +1,211 @@
'use client'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import {
filterPotentialMunicipalities,
type PotentialMunicipalityOption,
type PotentialUf,
} from '@/lib/sectionPotential'
import { cn } from '@/lib/utils'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
POTENTIAL_FIELD_INPUT,
POTENTIAL_FIELD_LABEL,
POTENTIAL_FOCUS,
POTENTIAL_NOTE,
} from './sectionPotentialClasses'
import {
POTENTIAL_MUNICIPALITY_EMPTY,
POTENTIAL_MUNICIPALITY_ERROR,
POTENTIAL_MUNICIPALITY_HELP,
POTENTIAL_MUNICIPALITY_PICK,
} from './sectionPotentialCopy'
type MunicipalityComboboxProps = {
uf: PotentialUf
value: string
onValueChange: (value: string) => void
onSelect: (municipality: PotentialMunicipalityOption) => void
}
/**
* S46 — o campo Município (design, detalhe do combobox): busca por nome a
* partir de 3 letras, accent-insensitive, só municípios da UF escolhida; sem
* texto livre — a consulta exige uma opção conhecida (o `code` TSE).
*/
export const MunicipalityCombobox = ({
uf,
value,
onValueChange,
onSelect,
}: MunicipalityComboboxProps) => {
const listId = useId()
const cache = useRef(new Map<string, PotentialMunicipalityOption[]>())
const [items, setItems] = useState<PotentialMunicipalityOption[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
let alive = true
const cached = cache.current.get(uf)
if (cached) {
setItems(cached)
setLoadState('ready')
return
}
setLoadState('loading')
fetch(`/api/potencial/municipios?uf=${uf}`, { headers: { accept: 'application/json' } })
.then((response) => {
if (!response.ok) throw new Error(`municipios:${response.status}`)
return response.json() as Promise<{
ok: boolean
municipalities?: PotentialMunicipalityOption[]
}>
})
.then((body) => {
if (!alive) return
if (!body.ok || !body.municipalities) throw new Error('municipios:body')
cache.current.set(uf, body.municipalities)
setItems(body.municipalities)
setLoadState('ready')
})
.catch(() => {
if (alive) setLoadState('error')
})
return () => {
alive = false
}
}, [uf])
const matches = useMemo(() => filterPotentialMunicipalities(items, value), [items, value])
const showList = open && loadState === 'ready' && value.trim().length >= 3
// Nome exato de um município carregado = seleção válida; a ajuda some (o
// "Escolha um município da lista." contradizia o campo preenchido).
const selectedExact =
value.trim().length >= 3 &&
items.some(
(municipality) => normalizeSearchPhrase(municipality.name) === normalizeSearchPhrase(value),
)
useEffect(() => {
setActiveIndex(0)
}, [value, uf])
const select = (municipality: PotentialMunicipalityOption) => {
onSelect(municipality)
setOpen(false)
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!showList || matches.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, matches.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
select(matches[activeIndex])
} else if (event.key === 'Escape') {
setOpen(false)
}
}
const helper =
loadState === 'error'
? POTENTIAL_MUNICIPALITY_ERROR
: selectedExact
? null
: value.trim().length < 3
? POTENTIAL_MUNICIPALITY_HELP
: showList && matches.length === 0
? POTENTIAL_MUNICIPALITY_EMPTY
: POTENTIAL_MUNICIPALITY_PICK
return (
<div className="relative">
<label className={POTENTIAL_FIELD_LABEL} htmlFor={`${listId}-input`}>
Município
</label>
<div className={POTENTIAL_FIELD_INPUT}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0 text-(--campaign-muted)"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id={`${listId}-input`}
type="text"
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={showList}
aria-controls={listId}
aria-activedescendant={
showList && matches[activeIndex] ? `${listId}-${matches[activeIndex].code}` : undefined
}
aria-describedby={`${listId}-help`}
value={value}
onChange={(event) => {
onValueChange(event.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)}
/>
</div>
{showList ? (
<ul
id={listId}
role="listbox"
aria-label="Municípios"
className="absolute z-20 mt-1.5 max-h-64 w-full list-none overflow-auto rounded-xl border border-(--campaign-line) bg-white p-0 shadow-[0_12px_32px_rgb(0_0_0/12%)]"
// Mantém o foco no input enquanto o clique seleciona a opção.
onMouseDown={(event) => event.preventDefault()}
>
{matches.map((municipality, index) => (
<li
key={municipality.code}
id={`${listId}-${municipality.code}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => select(municipality)}
className={cn(
'cursor-pointer px-3 py-2.5 text-sm',
index === activeIndex ? 'bg-(--campaign-cream) font-bold text-(--pt-red)' : '',
)}
>
{municipality.name}
</li>
))}
</ul>
) : null}
{helper ? (
<p
id={`${listId}-help`}
className={cn(POTENTIAL_NOTE, 'mt-1.5', loadState === 'error' && 'text-(--destructive)')}
>
{helper}
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,214 @@
'use client'
import {
POTENTIAL_UFS,
type PotentialMunicipalityOption,
type PotentialUf,
} from '@/lib/sectionPotential'
import { cn } from '@/lib/utils'
import { MunicipalityCombobox } from './MunicipalityCombobox'
import {
POTENTIAL_FIELD_INPUT,
POTENTIAL_FIELD_INPUT_ERROR,
POTENTIAL_FIELD_LABEL,
POTENTIAL_FOCUS,
POTENTIAL_NOTE,
POTENTIAL_OUTLINE_BUTTON,
POTENTIAL_PRIMARY_BUTTON,
} from './sectionPotentialClasses'
import { POTENTIAL_CHECK_LIST, TSE_SELF_SERVICE_URL } from './sectionPotentialCopy'
type SectionPotentialFormProps = {
uf: PotentialUf
onUfChange: (uf: PotentialUf) => void
municipalityText: string
onMunicipalityTextChange: (value: string) => void
onMunicipalitySelect: (municipality: PotentialMunicipalityOption) => void
zone: string
onZoneChange: (value: string) => void
section: string
onSectionChange: (value: string) => void
fieldsInvalid: boolean
submitLabel: string
onSubmit: () => void
}
/**
* S46 — o formulário do título (design, cena 3a): UF, município, zona e seção
* em duas colunas, com a lista de conferência e o caminho para o
* autoatendimento do TSE quando a seção não aparece. Zona e seção ficam
* marcadas quando a combinação não existe.
*/
export const SectionPotentialForm = ({
uf,
onUfChange,
municipalityText,
onMunicipalityTextChange,
onMunicipalitySelect,
zone,
onZoneChange,
section,
onSectionChange,
fieldsInvalid,
submitLabel,
onSubmit,
}: SectionPotentialFormProps) => (
<form
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={POTENTIAL_FIELD_LABEL} htmlFor="potencial-uf">
UF
</label>
<div className={POTENTIAL_FIELD_INPUT}>
<select
id="potencial-uf"
value={uf}
onChange={(event) => onUfChange(event.target.value as PotentialUf)}
className={cn(
'w-full min-w-0 appearance-none bg-transparent text-base',
POTENTIAL_FOCUS,
)}
>
{POTENTIAL_UFS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0 text-(--campaign-muted)"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
<MunicipalityCombobox
uf={uf}
value={municipalityText}
onValueChange={onMunicipalityTextChange}
onSelect={onMunicipalitySelect}
/>
<div>
<label className={POTENTIAL_FIELD_LABEL} htmlFor="potencial-zona">
Zona
</label>
<div className={cn(POTENTIAL_FIELD_INPUT, fieldsInvalid && POTENTIAL_FIELD_INPUT_ERROR)}>
<input
id="potencial-zona"
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={4}
aria-invalid={fieldsInvalid || undefined}
aria-describedby={fieldsInvalid ? 'potencial-fields-error' : undefined}
value={zone}
onChange={(event) => onZoneChange(event.target.value.replace(/\D/g, ''))}
className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)}
/>
</div>
</div>
<div>
<label className={POTENTIAL_FIELD_LABEL} htmlFor="potencial-secao">
Seção
</label>
<div className={cn(POTENTIAL_FIELD_INPUT, fieldsInvalid && POTENTIAL_FIELD_INPUT_ERROR)}>
<input
id="potencial-secao"
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={4}
aria-invalid={fieldsInvalid || undefined}
aria-describedby={fieldsInvalid ? 'potencial-fields-error' : undefined}
value={section}
onChange={(event) => onSectionChange(event.target.value.replace(/\D/g, ''))}
className={cn('w-full min-w-0 bg-transparent text-base', POTENTIAL_FOCUS)}
/>
</div>
</div>
</div>
{fieldsInvalid ? (
<p
id="potencial-fields-error"
className="mt-1.5 text-xs leading-relaxed text-(--destructive)"
>
Zona e seção não conferem entre si. Revise os números no título.
</p>
) : null}
<div className="mt-5 rounded-xl border border-(--campaign-line) bg-white p-4">
<p className="m-0 text-[13px] font-extrabold">No título de eleitor, confira:</p>
<ul className="mt-2 list-none space-y-1.5 p-0 text-[13px] leading-5 text-(--campaign-muted)">
{POTENTIAL_CHECK_LIST.map((item) => (
<li key={item}>· {item}</li>
))}
</ul>
</div>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<button type="submit" className={cn(POTENTIAL_PRIMARY_BUTTON, 'flex-1')}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
{submitLabel}
</button>
<a
href={TSE_SELF_SERVICE_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(POTENTIAL_OUTLINE_BUTTON, 'flex-1')}
>
Consultar meu título no TSE
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</a>
</div>
<p className={cn(POTENTIAL_NOTE, 'mt-3')}>
A base usa os dados do 1º turno de 2026 publicados pelo TSE. Seção recém-criada ou transferida
pode ainda não aparecer.
</p>
</form>
)

View file

@ -0,0 +1,191 @@
'use client'
import {
formatPotentialPercent,
formatPotentialPoints,
type PotentialSectionNumbers,
type SectionPotential,
} from '@/lib/sectionPotential'
import { SECTION_STORY_HYPOTHESIS } from '@/lib/sectionStoryRender'
import { cn } from '@/lib/utils'
import {
POTENTIAL_EYEBROW,
POTENTIAL_FOCUS,
POTENTIAL_GHOST_BUTTON,
POTENTIAL_NOTE,
POTENTIAL_STAT_LABEL,
POTENTIAL_STAT_VALUE,
} from './sectionPotentialClasses'
import {
POTENTIAL_PRIVACY_NOTE,
POTENTIAL_SOURCE_PREFIX,
TSE_RESULTS_URL,
} from './sectionPotentialCopy'
type SectionPotentialResultProps = {
numbers: PotentialSectionNumbers
potential: SectionPotential
onChangeSection: () => void
}
/**
* S46 — o painel de resultado (design, cenas 1/2/3c): número-herói do ganho
* total, os dois ganhos, as três leituras da mesma seção, o "Como calculamos"
* com a legenda X₁/X₂ e os guardrails (cenário hipotético + fonte TSE).
*/
export const SectionPotentialResult = ({
numbers,
potential,
onChangeSection,
}: SectionPotentialResultProps) => (
<div>
<div className="flex items-center justify-between gap-4 rounded-2xl border border-(--campaign-line) bg-white px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-(--campaign-cream) text-(--pt-red)">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>
</span>
<div className="min-w-0">
<p className="m-0 truncate text-sm font-bold">
<span className="sm:hidden">
{numbers.uf} · {numbers.municipalityName}
</span>
<span className="hidden sm:inline">
{numbers.uf} · {numbers.municipalityName} · Zona {numbers.zone} · Seção{' '}
{numbers.section}
</span>
</p>
<p className="m-0 text-xs text-(--campaign-muted)">
<span className="sm:hidden">
Zona {numbers.zone} · Seção {numbers.section}
</span>
<span className="hidden sm:inline">Dados do título de eleitor</span>
</p>
</div>
</div>
<button
type="button"
className={cn(POTENTIAL_GHOST_BUTTON, 'shrink-0')}
onClick={onChangeSection}
>
Trocar seção
</button>
</div>
<div className="mt-4 rounded-2xl border border-(--campaign-line) bg-(--campaign-cream) p-5 sm:p-7">
<p className={POTENTIAL_EYEBROW}>Potencial nesta seção</p>
<div className="mt-3 flex flex-wrap items-end gap-x-4 gap-y-1">
<p className="m-0 font-[family-name:var(--font-exo2)] text-[56px] leading-none font-black text-(--pt-red)">
{formatPotentialPoints(potential.gainTotalPp)}
</p>
<p className="m-0 pb-1 text-sm leading-5 text-(--campaign-muted)">
de ganho total entre turnos
<br />
no cenário com todos os votos disponíveis
</p>
</div>
<p className="m-0 mt-2 text-sm text-(--campaign-muted)">
Só com os votos imediatos (X₁):{' '}
<b className="text-(--campaign-ink)">{formatPotentialPoints(potential.gainImmediatePp)}</b>
</p>
<div className="mt-6 grid grid-cols-3 overflow-hidden rounded-xl border border-(--campaign-line) bg-white">
<div className="p-2.5 sm:p-4">
<p className={POTENTIAL_STAT_LABEL}>1º turno</p>
<p className={POTENTIAL_STAT_VALUE}>{formatPotentialPercent(potential.firstRoundPct)}</p>
<p className="mt-0.5 hidden text-[11px] text-(--campaign-muted) sm:block">
Lula na seção
</p>
</div>
<div className="border-l border-(--campaign-line) p-2.5 sm:p-4">
<p className={POTENTIAL_STAT_LABEL}>
<span className="sm:hidden">2º · imediato</span>
<span className="hidden sm:inline">2º turno · imediato</span>
</p>
<p className={POTENTIAL_STAT_VALUE}>{formatPotentialPercent(potential.immediatePct)}</p>
<p className="mt-0.5 hidden text-[11px] text-(--campaign-muted) sm:block">só X₁</p>
</div>
<div className="border-l border-(--campaign-line) p-2.5 sm:p-4">
<p className={POTENTIAL_STAT_LABEL}>
<span className="sm:hidden">2º · total</span>
<span className="hidden sm:inline">2º turno · total</span>
</p>
<p className={POTENTIAL_STAT_VALUE}>{formatPotentialPercent(potential.totalPct)}</p>
<p className="mt-0.5 hidden text-[11px] text-(--campaign-muted) sm:block">
X₁ + faltantes
</p>
</div>
</div>
<details className="group mt-4 rounded-xl border border-(--campaign-line) bg-white p-4">
<summary
className={cn(
'flex min-h-11 cursor-pointer items-center gap-2 text-sm font-extrabold',
POTENTIAL_FOCUS,
)}
>
Como calculamos
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="transition-transform duration-150 group-open:rotate-180 motion-reduce:transition-none"
>
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div className="mt-3 space-y-2 text-[13px] leading-5 text-(--campaign-muted)">
<p className="m-0">1º turno: % do Lula = votos do Lula ÷ votos válidos da seção × 100.</p>
<p className="m-0">2º turno: (LULA + X) ÷ (LULA + X + FLÁVIO) × 100.</p>
<p className="m-0">Ganho: 2º − 1º, em pontos percentuais.</p>
<p className="m-0 rounded-lg bg-(--campaign-band) px-3 py-2">
<b className="text-(--campaign-ink)">X₁</b> = nulos + brancos + votos de terceiros
(disponíveis já no 1º turno). <b className="text-(--campaign-ink)">X₂</b> = X₁ +
faltantes.
</p>
</div>
</details>
<p className={cn(POTENTIAL_NOTE, 'mt-4')}>
<b className="text-(--campaign-ink)">
Cenário hipotético — não é previsão nem promessa de resultado.
</b>{' '}
Hipótese: na seção, só Lula e Flávio disputam o 2º turno; {SECTION_STORY_HYPOTHESIS}
</p>
<p className={cn(POTENTIAL_NOTE, 'mt-2')}>
{POTENTIAL_SOURCE_PREFIX}{' '}
<a
href={TSE_RESULTS_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
'rounded-sm font-bold text-(--pt-red) underline-offset-4 hover:underline',
POTENTIAL_FOCUS,
)}
>
Arquivos de Urna
</a>
. {POTENTIAL_PRIVACY_NOTE}
</p>
</div>
</div>
)

View file

@ -0,0 +1,297 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
formatPotentialPercent,
formatPotentialPoints,
sectionStoryFileName,
type PotentialSectionNumbers,
type SectionPotential,
} from '@/lib/sectionPotential'
import { STORY_HEIGHT, STORY_WIDTH, type SectionStoryInput } from '@/lib/sectionStoryRender'
import { cn } from '@/lib/utils'
import {
POTENTIAL_CHIP,
POTENTIAL_NOTE,
POTENTIAL_OUTLINE_BUTTON,
POTENTIAL_PRIMARY_BUTTON,
} from './sectionPotentialClasses'
import {
POTENTIAL_STORY_EXPORT_FAILED,
POTENTIAL_STORY_PREVIEW_NOTE,
POTENTIAL_STORY_READY,
POTENTIAL_STORY_SHARE_NOTE,
} from './sectionPotentialCopy'
import {
canvasToPngBlob,
downloadBlob,
drawStoryOnCanvas,
ensureStoryFont,
} from './sectionStoryCanvas'
type SectionPotentialStoryPreviewProps = {
numbers: PotentialSectionNumbers
potential: SectionPotential
fontFamily: string
}
const DownloadIcon = () => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
)
const ShareIcon = () => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
</svg>
)
const storyInput = (
numbers: PotentialSectionNumbers,
potential: SectionPotential,
fontFamily: string,
): SectionStoryInput => ({
municipalityName: numbers.municipalityName,
uf: numbers.uf,
zone: numbers.zone,
section: numbers.section,
firstRound: formatPotentialPercent(potential.firstRoundPct),
immediate: formatPotentialPercent(potential.immediatePct),
total: formatPotentialPercent(potential.totalPct),
gainImmediate: formatPotentialPoints(potential.gainImmediatePp),
gainTotal: formatPotentialPoints(potential.gainTotalPp),
fontFamily,
})
/**
* S46 — a prévia do story (design, cenas 1/2/3d): o MESMO canvas que vira o
* PNG 1080×1920. "Baixar imagem" é o piso universal; "Compartilhar" abre a
* folha do sistema com o arquivo quando o aparelho suporta (cancelar não baixa)
* e cai no download quando não — o contrato do share de conteúdo. Uma falha de
* export nunca fica silenciosa: o visitante vê a mensagem honesta.
*/
export const SectionPotentialStoryPreview = ({
numbers,
potential,
fontFamily,
}: SectionPotentialStoryPreviewProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [previewReady, setPreviewReady] = useState(false)
const [generated, setGenerated] = useState(false)
const [exportFailed, setExportFailed] = useState(false)
const [busy, setBusy] = useState(false)
const fileName = sectionStoryFileName(numbers)
useEffect(() => {
let alive = true
setPreviewReady(false)
void (async () => {
// A fonte é o gate da medição do canvas; se o load falhar, desenha mesmo
// assim com o fallback da família (a página já carrega a Arimo).
await ensureStoryFont(fontFamily)
if (!alive || !canvasRef.current) return
drawStoryOnCanvas(canvasRef.current, storyInput(numbers, potential, fontFamily))
if (alive) setPreviewReady(true)
})()
return () => {
alive = false
}
}, [numbers, potential, fontFamily])
const exportBlob = async (): Promise<Blob | null> => {
const canvas = canvasRef.current
if (!canvas) return null
try {
return await canvasToPngBlob(canvas)
} catch {
return null
}
}
const download = async () => {
const blob = await exportBlob()
if (!blob) {
setExportFailed(true)
return
}
setExportFailed(false)
downloadBlob(blob, fileName)
setGenerated(true)
}
const onDownload = async () => {
if (busy) return
setBusy(true)
try {
await download()
} finally {
setBusy(false)
}
}
const onShare = async () => {
if (busy) return
setBusy(true)
let fellBack = false
try {
const blob = await exportBlob()
if (!blob) {
setExportFailed(true)
return
}
const file = new File([blob], fileName, { type: 'image/png' })
if (typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: 'Potencial de Lula na minha seção' })
setGenerated(true)
return
} catch (error) {
// Cancelar a folha não é falha: nunca baixa no lugar.
if (error instanceof DOMException && error.name === 'AbortError') return
}
}
fellBack = true
} finally {
setBusy(false)
}
if (fellBack) await onDownload()
}
const ariaLabel = [
`Prévia do story 1080 por 1920 da seção ${numbers.section} de ${numbers.municipalityName} (${numbers.uf}), cenário hipotético.`,
`Lula no 1º turno: ${formatPotentialPercent(potential.firstRoundPct)}.`,
`Lula no 2º turno só com os votos imediatos (X₁): ${formatPotentialPercent(potential.immediatePct)}.`,
`Lula no 2º turno com todos os votos (X₂): ${formatPotentialPercent(potential.totalPct)}.`,
`Ganho imediato: ${formatPotentialPoints(potential.gainImmediatePp)}; ganho total: ${formatPotentialPoints(potential.gainTotalPp)}.`,
].join(' ')
return (
<div className="rounded-2xl border border-(--campaign-line) bg-white p-5">
<div className="flex items-center justify-between gap-3">
<p className="m-0 text-sm font-bold">Prévia do story</p>
<span className={POTENTIAL_CHIP}>1080 × 1920</span>
</div>
{generated ? (
<div
role="status"
className="mt-4 flex items-center gap-2 rounded-xl bg-support-engaged px-4 py-3 text-[13px] font-black text-support-engaged-foreground"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{POTENTIAL_STORY_READY}
</div>
) : null}
<div className="relative mt-4">
<canvas
ref={canvasRef}
width={STORY_WIDTH}
height={STORY_HEIGHT}
role="img"
aria-label={ariaLabel}
aria-busy={!previewReady}
className="block h-auto w-full rounded border border-(--campaign-line)"
/>
{!previewReady ? (
<p className="absolute inset-0 grid place-items-center bg-white/70 text-xs font-bold text-(--campaign-muted)">
Gerando prévia…
</p>
) : null}
</div>
<p className={cn(POTENTIAL_NOTE, 'mt-3')}>{POTENTIAL_STORY_PREVIEW_NOTE}</p>
<div className="mt-4 flex flex-col gap-2">
{generated ? (
<>
<button
type="button"
className={POTENTIAL_PRIMARY_BUTTON}
onClick={onShare}
disabled={busy || !previewReady}
>
<ShareIcon />
Compartilhar
</button>
<button
type="button"
className={POTENTIAL_OUTLINE_BUTTON}
onClick={onDownload}
disabled={busy || !previewReady}
>
<DownloadIcon />
Baixar de novo
</button>
</>
) : (
<>
<button
type="button"
className={POTENTIAL_PRIMARY_BUTTON}
onClick={onDownload}
disabled={busy || !previewReady}
>
<DownloadIcon />
Baixar imagem
</button>
<button
type="button"
className={POTENTIAL_OUTLINE_BUTTON}
onClick={onShare}
disabled={busy || !previewReady}
>
<ShareIcon />
Compartilhar
</button>
</>
)}
</div>
{exportFailed ? (
<p role="alert" className="mt-3 text-xs leading-relaxed font-bold text-(--destructive)">
{POTENTIAL_STORY_EXPORT_FAILED}
</p>
) : null}
<p className={cn(POTENTIAL_NOTE, 'mt-3')}>{POTENTIAL_STORY_SHARE_NOTE}</p>
</div>
)
}

View file

@ -0,0 +1,307 @@
'use client'
import { useRef, useState } from 'react'
import {
computeSectionPotential,
type PotentialMunicipalityOption,
type PotentialSectionNumbers,
type PotentialUf,
type SectionPotential,
} from '@/lib/sectionPotential'
import { cn } from '@/lib/utils'
import { SectionPotentialForm } from './SectionPotentialForm'
import { SectionPotentialResult } from './SectionPotentialResult'
import { SectionPotentialStoryPreview } from './SectionPotentialStoryPreview'
import { POTENTIAL_NOTE } from './sectionPotentialClasses'
import {
POTENTIAL_FORM_VALIDATION_MESSAGE,
POTENTIAL_HEADLINE,
POTENTIAL_INTRO,
POTENTIAL_LOADING_MESSAGE,
POTENTIAL_LOADING_NOTE,
POTENTIAL_NOT_FOUND_EYEBROW,
POTENTIAL_NOT_FOUND_HINT,
POTENTIAL_NOT_FOUND_MESSAGE,
POTENTIAL_NOT_FOUND_TITLE,
POTENTIAL_NO_DATA_MESSAGE,
POTENTIAL_PAGE_EYEBROW,
POTENTIAL_THROTTLED_MESSAGE,
POTENTIAL_UNAVAILABLE_MESSAGE,
} from './sectionPotentialCopy'
type StudioState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; numbers: PotentialSectionNumbers; potential: SectionPotential }
| {
status: 'error'
reason: 'not-found' | 'no-data' | 'unavailable'
message: string
hint: string | null
fieldsInvalid: boolean
}
const SectionPotentialSkeleton = () => (
<div aria-hidden="true" className="animate-pulse motion-reduce:animate-none">
<div className="h-3 w-40 rounded bg-(--campaign-band)" />
<div className="mt-5 rounded-2xl border border-(--campaign-line) bg-white p-6">
<div className="h-3 w-40 rounded bg-(--campaign-band)" />
<div className="mt-4 h-12 w-52 rounded bg-(--campaign-band)" />
<div className="mt-3 flex gap-3">
<div className="h-4 w-32 rounded bg-(--campaign-band)" />
<div className="h-4 w-24 rounded bg-(--campaign-band)" />
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
<div className="h-24 rounded bg-(--campaign-band)" />
<div className="h-24 rounded bg-(--campaign-band)" />
<div className="h-24 rounded bg-(--campaign-band)" />
</div>
<div className="mt-4 h-12 w-full rounded bg-(--campaign-band)" />
</div>
</div>
)
/**
* S46 — a ilha do `/potencial`: formulário do título → consulta à API pública
* → painel + prévia do story. O cálculo vem do módulo puro compartilhado
* (`computeSectionPotential`), então a tela e a imagem usam os mesmos números.
* Nenhum dado pessoal é coletado; nada é persistido no navegador.
*/
export const SectionPotentialStudio = ({ fontFamily }: { fontFamily: string }) => {
const [uf, setUf] = useState<PotentialUf>('BA')
const [municipalityText, setMunicipalityText] = useState('')
const [municipality, setMunicipality] = useState<PotentialMunicipalityOption | null>(null)
const [zone, setZone] = useState('')
const [section, setSection] = useState('')
const [formMessage, setFormMessage] = useState<string | null>(null)
const [state, setState] = useState<StudioState>({ status: 'idle' })
const requestSeq = useRef(0)
const onUfChange = (nextUf: PotentialUf) => {
setUf(nextUf)
setMunicipality(null)
setMunicipalityText('')
}
const submit = async () => {
const zoneNumber = Number(zone)
const sectionNumber = Number(section)
if (
!municipality ||
!Number.isInteger(zoneNumber) ||
zoneNumber < 1 ||
!Number.isInteger(sectionNumber) ||
sectionNumber < 1
) {
setFormMessage(POTENTIAL_FORM_VALIDATION_MESSAGE)
return
}
setFormMessage(null)
setState({ status: 'loading' })
const requestId = ++requestSeq.current
try {
const response = await fetch(
`/api/potencial/secao?uf=${uf}&codigo=${municipality.code}&zona=${zoneNumber}&secao=${sectionNumber}`,
{ headers: { accept: 'application/json' } },
)
if (requestId !== requestSeq.current) return
if (response.status === 404) {
setState({
status: 'error',
reason: 'not-found',
message: POTENTIAL_NOT_FOUND_MESSAGE,
hint: POTENTIAL_NOT_FOUND_HINT,
fieldsInvalid: true,
})
return
}
if (response.status === 429) {
setState({
status: 'error',
reason: 'unavailable',
message: POTENTIAL_THROTTLED_MESSAGE,
hint: null,
fieldsInvalid: false,
})
return
}
if (!response.ok) throw new Error(`potencial:${response.status}`)
const body = (await response.json()) as {
ok: boolean
section?: PotentialSectionNumbers
}
if (!body.ok || !body.section) throw new Error('potencial:body')
const result = computeSectionPotential(body.section)
if (!result.available) {
setState({
status: 'error',
reason: 'no-data',
message: POTENTIAL_NO_DATA_MESSAGE,
hint: POTENTIAL_NOT_FOUND_HINT,
fieldsInvalid: false,
})
return
}
setState({ status: 'ready', numbers: body.section, potential: result.potential })
} catch {
if (requestId !== requestSeq.current) return
setState({
status: 'error',
reason: 'unavailable',
message: POTENTIAL_UNAVAILABLE_MESSAGE,
hint: null,
fieldsInvalid: false,
})
}
}
const isError = state.status === 'error'
const notFound = isError && state.reason === 'not-found'
// A validação local vence o alerta do estado: depois de um 404, um resubmit
// com campo vazio precisa mostrar o que falta, não a mensagem antiga.
const alertMessage = formMessage ?? (isError ? state.message : null)
return (
<div className="mx-auto w-full max-w-[1160px] px-5 pt-8 pb-12 sm:px-8 lg:px-10">
<div className="text-center">
<p className={POTENTIAL_PAGE_EYEBROW}>
{notFound ? POTENTIAL_NOT_FOUND_EYEBROW : POTENTIAL_PAGE_EYEBROW}
</p>
<h1
className={cn(
'm-0 mt-2 font-[family-name:var(--font-exo2)] leading-[1.12] font-black tracking-[-0.02em] text-balance',
notFound ? 'text-[28px] leading-[1.15]' : 'text-[27px] sm:text-[34px] lg:text-[40px]',
)}
>
{notFound ? POTENTIAL_NOT_FOUND_TITLE : POTENTIAL_HEADLINE}
</h1>
{state.status === 'idle' ? (
<p className="mx-auto mt-3 max-w-[680px] text-[15px] leading-6 text-(--campaign-muted)">
{POTENTIAL_INTRO}
</p>
) : null}
</div>
{state.status === 'loading' ? (
<div className="mt-8" role="status" aria-live="polite">
<p className="m-0 flex items-center gap-2 text-sm font-black">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="var(--pt-red)"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
className="animate-spin motion-reduce:animate-none"
>
<path d="M21 12a9 9 0 1 1-6.22-8.56" />
</svg>
{POTENTIAL_LOADING_MESSAGE}
</p>
<p className={cn(POTENTIAL_NOTE, 'mt-1')}>{POTENTIAL_LOADING_NOTE}</p>
<div className="mt-5 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_396px]">
<SectionPotentialSkeleton />
<div className="rounded-2xl border border-(--campaign-line) bg-white p-5">
<div className="h-4 w-32 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 aspect-[1080/1920] w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 h-12 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-2 h-12 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
</div>
</div>
</div>
) : null}
{state.status === 'idle' || isError ? (
<div className="mx-auto mt-6 w-full max-w-[680px]">
{alertMessage ? (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-[rgb(180_35_24/40%)] bg-[#fef3f2] p-4 text-(--destructive)"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mt-0.5 shrink-0"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<div>
<p className="m-0 text-sm font-black">{alertMessage}</p>
{isError && state.hint ? (
<p className="m-0 mt-1 text-[13px] leading-5">{state.hint}</p>
) : null}
</div>
</div>
) : null}
<div className="mt-6">
<SectionPotentialForm
uf={uf}
onUfChange={onUfChange}
municipalityText={municipalityText}
onMunicipalityTextChange={(value) => {
setMunicipalityText(value)
if (municipality && value !== municipality.name) setMunicipality(null)
}}
onMunicipalitySelect={(selected) => {
setMunicipality(selected)
setMunicipalityText(selected.name)
}}
zone={zone}
onZoneChange={setZone}
section={section}
onSectionChange={setSection}
fieldsInvalid={isError && state.fieldsInvalid}
submitLabel={isError ? 'Tentar de novo' : 'Ver o potencial da minha seção'}
onSubmit={() => {
void submit()
}}
/>
</div>
</div>
) : null}
{state.status === 'ready' ? (
<div
className="mt-8 grid grid-cols-1 items-start gap-8 lg:grid-cols-[minmax(0,1fr)_396px]"
aria-live="polite"
>
<SectionPotentialResult
numbers={state.numbers}
potential={state.potential}
onChangeSection={() => setState({ status: 'idle' })}
/>
<SectionPotentialStoryPreview
numbers={state.numbers}
potential={state.potential}
fontFamily={fontFamily}
/>
</div>
) : null}
{state.status === 'idle' || isError ? (
<p className={cn(POTENTIAL_NOTE, 'mt-6 text-center')}>
A consulta não pede nome, e-mail ou telefone — e nada fica salvo no seu aparelho.
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,49 @@
import { cn } from '@/lib/utils'
/** S46 — class contracts of the public `/potencial` surface (design cena 1–3d). */
/** Foco da campanha: amarelo + separador escuro (a superfície é branca/creme). */
export const POTENTIAL_FOCUS =
'focus-visible:outline-[3px] focus-visible:outline-offset-2 focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(16_42_67/90%)]'
export const POTENTIAL_PRIMARY_BUTTON = cn(
'inline-flex min-h-12 items-center justify-center gap-2 rounded-[10px] border-2 border-(--pt-red) bg-(--pt-red) px-[18px] font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white',
'transition-[filter] duration-150 ease-out hover:brightness-95 motion-reduce:transition-none',
POTENTIAL_FOCUS,
)
export const POTENTIAL_OUTLINE_BUTTON = cn(
'inline-flex min-h-12 items-center justify-center gap-2 rounded-[10px] border-2 border-[rgb(162_28_28/28%)] bg-white px-[18px] font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)',
'transition-colors duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
POTENTIAL_FOCUS,
)
export const POTENTIAL_GHOST_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center rounded-lg px-2.5 text-xs font-extrabold text-(--pt-red)',
'transition-colors duration-150 ease-out hover:bg-(--campaign-band) motion-reduce:transition-none',
POTENTIAL_FOCUS,
)
export const POTENTIAL_CHIP =
'inline-flex min-h-6 items-center gap-1.5 rounded-full bg-(--campaign-band) px-2.5 py-0.5 text-[11px] font-bold text-[#3f3a37]'
export const POTENTIAL_FIELD_LABEL = 'block text-[13px] font-bold text-(--campaign-ink)'
export const POTENTIAL_FIELD_INPUT = cn(
'mt-1.5 flex min-h-12 w-full items-center gap-2 rounded-[10px] border border-[rgb(0_0_0/18%)] bg-white px-3 text-base text-(--campaign-ink)',
'focus-within:border-(--pt-red) focus-within:shadow-[0_0_0_3px_rgb(162_28_28/18%)]',
)
export const POTENTIAL_FIELD_INPUT_ERROR =
'border-(--danger) shadow-[0_0_0_3px_rgb(180_35_24/12%)] focus-within:border-(--danger)'
export const POTENTIAL_NOTE = 'text-xs leading-relaxed text-(--campaign-muted)'
export const POTENTIAL_EYEBROW =
'font-[family-name:var(--font-exo2)] text-xs font-extrabold tracking-[0.1em] text-(--pt-red) uppercase'
export const POTENTIAL_STAT_LABEL =
'text-[9px] font-extrabold tracking-[0.04em] whitespace-nowrap text-(--campaign-muted) uppercase sm:text-[11px] sm:tracking-[0.06em]'
export const POTENTIAL_STAT_VALUE =
'mt-1 font-[family-name:var(--font-exo2)] text-lg leading-none font-extrabold whitespace-nowrap tabular-nums text-(--campaign-ink) sm:text-[26px]'

View file

@ -0,0 +1,63 @@
import { SECTION_STORY_TITLE } from '@/lib/sectionStoryRender'
/**
* S46 — copy e links do `/potencial` (pt-BR; a página não coleta dado pessoal).
* O link dos resultados aponta para o Portal de Dados Abertos do TSE — a mesma
* fonte do artefato commitado; o autoatendimento é o caminho honesto de quem
* não encontra a seção. O título e a hipótese são os MESMOS do story (dono:
* `sectionStoryRender`), para a tela e a imagem nunca divergirem.
*/
export const POTENTIAL_PATH = '/potencial'
export const POTENTIAL_PAGE_EYEBROW = '2º turno · Eleição 2026'
export const POTENTIAL_NOT_FOUND_EYEBROW = 'Potencial da sua seção'
export const POTENTIAL_HEADLINE = SECTION_STORY_TITLE
export const POTENTIAL_INTRO =
'Informe os dados do seu título de eleitor — UF, município, zona e seção — e veja o potencial de crescimento em pontos percentuais, do 1º para o 2º turno.'
export const TSE_RESULTS_URL = 'https://dadosabertos.tse.jus.br/dataset/resultados-2026'
export const TSE_SELF_SERVICE_URL =
'https://www.tse.jus.br/servicos-eleitorais/autoatendimento-eleitoral'
export const POTENTIAL_SOURCE_PREFIX = 'Fonte: dados oficiais do TSE — 1º turno 2026 ·'
export const POTENTIAL_PRIVACY_NOTE = 'Sem cadastro: a consulta não pede nome, e-mail ou telefone.'
export const POTENTIAL_NOT_FOUND_TITLE = 'Não encontramos essa seção'
export const POTENTIAL_NOT_FOUND_MESSAGE =
'Essa combinação de zona e seção não aparece nos dados do TSE.'
export const POTENTIAL_NOT_FOUND_HINT =
'Confira os quatro dados no seu título de eleitor — é comum trocar a zona pela seção.'
export const POTENTIAL_NO_DATA_MESSAGE =
'Essa seção não teve votos válidos para presidente no 1º turno de 2026.'
export const POTENTIAL_UNAVAILABLE_MESSAGE =
'Não foi possível consultar os dados agora. Tente de novo em instantes.'
export const POTENTIAL_THROTTLED_MESSAGE =
'Muitas consultas em sequência. Aguarde um instante e tente de novo.'
export const POTENTIAL_FORM_VALIDATION_MESSAGE =
'Preencha UF, município, zona e seção — os quatro dados estão no seu título de eleitor.'
export const POTENTIAL_MUNICIPALITY_HELP =
'Digite ao menos 3 letras. A lista mostra só municípios da UF escolhida.'
export const POTENTIAL_MUNICIPALITY_PICK = 'Escolha um município da lista.'
export const POTENTIAL_MUNICIPALITY_EMPTY = 'Nenhum município encontrado nesta UF.'
export const POTENTIAL_MUNICIPALITY_ERROR =
'Não foi possível carregar a lista de municípios. Recarregue a página.'
export const POTENTIAL_LOADING_MESSAGE = 'Consultando os dados oficiais do TSE…'
export const POTENTIAL_LOADING_NOTE = 'Pode levar alguns segundos. Não feche a página.'
export const POTENTIAL_STORY_PREVIEW_NOTE =
'A imagem final sai em 1080 × 1920 px (PNG), pronta para o story.'
export const POTENTIAL_STORY_SHARE_NOTE =
'No celular, o compartilhamento abre a folha do sistema (Instagram, WhatsApp e outros). No computador, a imagem vai para a pasta de downloads — poste pelo app do Instagram.'
export const POTENTIAL_STORY_READY = 'Imagem pronta — 1080 × 1920 px'
export const POTENTIAL_STORY_EXPORT_FAILED =
'Não foi possível gerar a imagem neste aparelho. Tente de novo.'
export const POTENTIAL_CHECK_LIST = [
'UF e município do seu local de votação;',
'zona eleitoral — 2 a 4 dígitos;',
'seção — até 4 dígitos;',
'se os números não estão invertidos (zona × seção).',
] as const

View file

@ -0,0 +1,40 @@
import { canvasToPngBlob, downloadBlob } from '@/components/cards/cardCanvas'
import {
drawSectionStory,
STORY_HEIGHT,
STORY_WIDTH,
type SectionStoryInput,
} from '@/lib/sectionStoryRender'
/**
* S46 — browser adapters of the story canvas: the font gate (the renderer
* measures with Arimo 400/500/600/700, so every weight must be loaded before
* the first draw) and the 1080×1920 draw itself. The export helpers are the
* card studio's (`cardCanvas`) — one PNG pipeline, not a second one.
*/
const STORY_FONT_WEIGHTS = [400, 500, 600, 700] as const
export const ensureStoryFont = async (fontFamily: string): Promise<boolean> => {
if (typeof document === 'undefined' || !document.fonts) return false
const primaryFamily = fontFamily.split(',')[0]?.trim() || fontFamily
try {
const faces = await Promise.all(
STORY_FONT_WEIGHTS.map((weight) => document.fonts.load(`${weight} 100px ${primaryFamily}`)),
)
return faces.every((face) => face.length > 0)
} catch {
return false
}
}
export const drawStoryOnCanvas = (canvas: HTMLCanvasElement, input: SectionStoryInput): boolean => {
canvas.width = STORY_WIDTH
canvas.height = STORY_HEIGHT
const context = canvas.getContext('2d')
if (!context) return false
drawSectionStory(context, input)
return true
}
export { canvasToPngBlob, downloadBlob }

View file

@ -0,0 +1,21 @@
import { z } from 'zod'
import { POTENTIAL_UFS } from '@/lib/sectionPotential'
/**
* S46 — wire contract of the public `/api/potencial` reads. Strict objects:
* an extra query key is malformed, never silently ignored. The section numbers
* follow the title's shapes (zona 2–4 dígitos, seção até 4) and are normalized
* by the callers; the município is the TSE code the combobox picked.
*/
export const potentialMunicipiosQuerySchema = z.strictObject({
uf: z.enum(POTENTIAL_UFS),
})
export const potentialSectionQuerySchema = z.strictObject({
uf: z.enum(POTENTIAL_UFS),
/** Código TSE do município (`CD_MUNICIPIO`) — não é slug de URL. */
codigo: z.coerce.number().int().positive(),
zona: z.coerce.number().int().min(1).max(9999),
secao: z.coerce.number().int().min(1).max(9999),
})

299
src/lib/sectionPotential.ts Normal file
View file

@ -0,0 +1,299 @@
/**
* S46 — the pure core of the public `/potencial` story: parse the committed
* per-UF TSE shard, find one section and compute the approved scenario.
*
* The shard (`public/dados/potencial-secao-2026/<UF>.json`, built by
* `scripts/build-potencial-secoes-2026.mjs`) stores one row per polling
* section — `[zona, seção, aptos, comparecimento, lula, flavio, válidos]` —
* sorted by (zona, seção) inside each município, municípios sorted by code.
* The numbers are raw; every percentage is computed here so the result panel
* and the story image can never drift.
*
* Scenario (aprovado no plano de intenção, corte "todos os votos disponíveis"):
* - 1º turno = Lula ÷ válidos × 100;
* - X₁ = comparecimento − Lula − Flávio (nulos + brancos + TODOS os terceiros);
* - X₂ = X₁ + faltantes = aptos − Lula − Flávio;
* - 2º turno = (Lula + X) ÷ (Lula + X + Flávio) × 100, para X₁ e X₂;
* - ganho = 2º − 1º, em pontos percentuais (nunca arredondado antes de subtrair).
*
* Fail-closed: a section without valid votes (or with impossible counts) is
* `no-data`, never a fabricated estimate.
*/
import { slugify } from '@/lib/slug'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
export const POTENTIAL_SHARD_VERSION = 1
export const POTENTIAL_UFS = [
'AC',
'AL',
'AP',
'AM',
'BA',
'CE',
'DF',
'ES',
'GO',
'MA',
'MT',
'MS',
'MG',
'PA',
'PB',
'PR',
'PE',
'PI',
'RJ',
'RN',
'RS',
'RO',
'RR',
'SC',
'SP',
'SE',
'TO',
] as const
export type PotentialUf = (typeof POTENTIAL_UFS)[number]
/** `[zona, seção, aptos, comparecimento, votos Lula, votos Flávio, válidos]`. */
type PotentialSectionRow = readonly [number, number, number, number, number, number, number]
type PotentialMunicipality = {
/** TSE `CD_MUNICIPIO`. */
code: number
name: string
sections: PotentialSectionRow[]
}
export type PotentialUfShard = {
version: number
uf: PotentialUf
municipalities: PotentialMunicipality[]
}
/** The identity + raw numbers of one found section — the API's wire shape. */
export type PotentialSectionNumbers = {
uf: PotentialUf
municipalityCode: number
municipalityName: string
zone: number
section: number
aptos: number
comparecimento: number
lula: number
flavio: number
validos: number
}
export type SectionPotential = {
/** 1º turno: Lula ÷ votos válidos × 100. */
firstRoundPct: number
/** 2º turno só com X₁ (votos disponíveis já no 1º turno). */
immediatePct: number
/** 2º turno com X₂ (X₁ + faltantes). */
totalPct: number
gainImmediatePp: number
gainTotalPp: number
x1: number
x2: number
}
export type SectionPotentialResult =
| { available: true; potential: SectionPotential }
| { available: false; reason: 'no-data' }
const isCount = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
const isPotentialUf = (value: string): value is PotentialUf =>
(POTENTIAL_UFS as readonly string[]).includes(value)
/**
* Structural validation of one shard. Runs once per UF per process (the loader
* caches the parsed result) and fails closed: a malformed row — or one out of
* the (code) / (zona, seção) order the binary searches assume — rejects the
* whole shard instead of leaking a partial one.
*/
export const parsePotentialShard = (value: unknown): PotentialUfShard | null => {
if (typeof value !== 'object' || value === null) return null
const shard = value as Record<string, unknown>
if (shard.version !== POTENTIAL_SHARD_VERSION) return null
if (typeof shard.uf !== 'string' || !isPotentialUf(shard.uf)) return null
if (!Array.isArray(shard.municipalities)) return null
const municipalities: PotentialMunicipality[] = []
let lastCode = -1
for (const entry of shard.municipalities) {
if (typeof entry !== 'object' || entry === null) return null
const municipality = entry as Record<string, unknown>
if (!isCount(municipality.code) || typeof municipality.name !== 'string') return null
if (municipality.code <= lastCode) return null
lastCode = municipality.code
if (!Array.isArray(municipality.sections)) return null
const sections: PotentialSectionRow[] = []
let lastZone = -1
let lastSection = -1
for (const row of municipality.sections) {
if (!Array.isArray(row) || row.length !== 7 || !row.every(isCount)) return null
const [zone, section] = row as unknown as PotentialSectionRow
if (zone < lastZone || (zone === lastZone && section <= lastSection)) return null
lastZone = zone
lastSection = section
sections.push(row as unknown as PotentialSectionRow)
}
municipalities.push({ code: municipality.code, name: municipality.name, sections })
}
return { version: POTENTIAL_SHARD_VERSION, uf: shard.uf, municipalities }
}
/** Município lookup by TSE code — the shard is sorted by code (binary search). */
const findPotentialMunicipality = (
shard: PotentialUfShard,
code: number,
): PotentialMunicipality | null => {
const municipalities = shard.municipalities
let low = 0
let high = municipalities.length - 1
while (low <= high) {
const middle = (low + high) >> 1
const current = municipalities[middle]
if (current.code === code) return current
if (current.code < code) low = middle + 1
else high = middle - 1
}
return null
}
/** Section row lookup inside one município — rows sorted by (zona, seção). */
const findPotentialSectionRow = (
municipality: PotentialMunicipality,
zone: number,
section: number,
): PotentialSectionRow | null => {
const rows = municipality.sections
let low = 0
let high = rows.length - 1
while (low <= high) {
const middle = (low + high) >> 1
const row = rows[middle]
if (row[0] === zone && row[1] === section) return row
if (row[0] < zone || (row[0] === zone && row[1] < section)) low = middle + 1
else high = middle - 1
}
return null
}
/** The full query: shard → município → section → raw numbers, or `null`. */
export const findPotentialSection = (
shard: PotentialUfShard,
query: { municipalityCode: number; zone: number; section: number },
): PotentialSectionNumbers | null => {
const municipality = findPotentialMunicipality(shard, query.municipalityCode)
if (!municipality) return null
const row = findPotentialSectionRow(municipality, query.zone, query.section)
if (!row) return null
return {
uf: shard.uf,
municipalityCode: municipality.code,
municipalityName: municipality.name,
zone: row[0],
section: row[1],
aptos: row[2],
comparecimento: row[3],
lula: row[4],
flavio: row[5],
validos: row[6],
}
}
/**
* The approved scenario over one section's raw numbers. `no-data` is the only
* alternative to a computed result: zero valid votes or impossible counts
* (Flávio + Lula above the comparecimento, válidos above it, turnout above the
* electorate) never render a percentage.
*/
export const computeSectionPotential = (
numbers: Pick<
PotentialSectionNumbers,
'aptos' | 'comparecimento' | 'lula' | 'flavio' | 'validos'
>,
): SectionPotentialResult => {
const { aptos, comparecimento, lula, flavio, validos } = numbers
if (validos <= 0) return { available: false, reason: 'no-data' }
if (
comparecimento > aptos ||
validos > comparecimento ||
lula + flavio > comparecimento ||
lula > validos ||
flavio > validos
) {
return { available: false, reason: 'no-data' }
}
const x1 = comparecimento - lula - flavio
const x2 = aptos - lula - flavio
const firstRoundPct = (lula / validos) * 100
// O denominador do 2º turno é (Lula + X + Flávio): X₁ fecha no comparecimento
// e X₂ fecha no eleitorado — a conta do cenário "só Lula × Flávio".
const immediatePct = ((lula + x1) / comparecimento) * 100
const totalPct = ((lula + x2) / aptos) * 100
return {
available: true,
potential: {
firstRoundPct,
immediatePct,
totalPct,
gainImmediatePp: immediatePct - firstRoundPct,
gainTotalPp: totalPct - firstRoundPct,
x1,
x2,
},
}
}
const percentFormatter = new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
/** `66,923…` → `66,9%` (one decimal, pt-BR). */
export const formatPotentialPercent = (value: number): string =>
`${percentFormatter.format(value)}%`
/** `6,864…` → `+6,9 p.p.`; the sign is derived, never hardcoded. */
export const formatPotentialPoints = (value: number): string =>
`${value < 0 ? '−' : '+'}${percentFormatter.format(Math.abs(value))} p.p.`
/** The downloadable PNG name: legible, ASCII, deterministic. */
export const sectionStoryFileName = (numbers: PotentialSectionNumbers): string =>
`potencial-lula-${numbers.uf.toLowerCase()}-${slugify(numbers.municipalityName)}-z${numbers.zone}-s${numbers.section}.png`
/** The municípios of one shard, lean and alphabetical — the combobox source. */
export type PotentialMunicipalityOption = { code: number; name: string }
export const listPotentialMunicipalities = (
shard: PotentialUfShard,
): PotentialMunicipalityOption[] =>
shard.municipalities
.map(({ code, name }) => ({ code, name }))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
/**
* The municípios whose normalized name matches `term` at a word start
* (accent/punctuation-insensitive — o mesmo normalizador dos pickers do repo);
* below three letters the list stays closed (design: "digite ao menos 3").
*/
export const filterPotentialMunicipalities = (
municipalities: PotentialMunicipalityOption[],
term: string,
): PotentialMunicipalityOption[] => {
const needle = normalizeSearchPhrase(term)
if (needle.length < 3) return []
return municipalities.filter((municipality) =>
matchesNormalizedAtWordStart(normalizeSearchPhrase(municipality.name), needle),
)
}

View file

@ -0,0 +1,456 @@
/**
* S46 — the pure canvas renderer of the story 1080×1920. The design artifact
* (`docs/plans/potencial-secao-story-ui-design.html`, cena 4) defines the
* geometry in container-query units over a 1080-wide story; every constant
* below is that value × 10.8. The caller passes the numbers already formatted
* by `sectionPotential`, so the image and the page can never disagree.
*
* The context is a structural subset of `CanvasRenderingContext2D` so unit
* tests drive a fake recorder instead of a real canvas.
*/
export const STORY_WIDTH = 1080
export const STORY_HEIGHT = 1920
/**
* The story's own copy, shared with the page where the sentence is the same
* (the headline and the hypothesis): a single source, so the screen and the
* image can never disagree on what the campaign is saying.
*/
export const SECTION_STORY_TITLE = 'Quanto o Lula pode crescer na sua seção?'
export const SECTION_STORY_HYPOTHESIS =
'todos os votos disponíveis vão para o Lula e o Flávio mantém os votos dele.'
const COLORS = {
background: '#f7f6f3',
ink: '#1b1b1b',
title: '#141414',
muted: '#57534c',
kicker: '#8c8880',
rowNumber: '#a39e95',
rowSmall: '#837e76',
legend: '#6e6a63',
hairline: '#dcd9d2',
linkBorder: '#cbc7bf',
} as const
/** cqw → px on the 1080-wide story (1cqw = 10.8px). */
const PX = {
padX: 84,
padTop: 118,
padBottom: 108,
kickerSize: 23,
kickerSpacing: 2.5,
titleSize: 58,
titleLineHeight: 1.16,
titleSpacing: -0.5,
titleMarginTop: 26,
subSize: 27,
subLineHeight: 1.45,
subMarginTop: 22,
fieldsMarginTop: 39,
fieldPaddingY: 14,
fieldGap: 20,
numWidth: 38,
numSize: 27,
nameSize: 29,
nameLineHeight: 1.18,
smallSize: 21,
smallLineHeight: 1.3,
smallMarginTop: 3,
valueSize: 34,
valueSmallSize: 27,
valueHeroSize: 48,
formulaMarginTop: 48,
formulaTitleSize: 22,
formulaTitleSpacing: 2,
calcSize: 25,
calcLineHeight: 1.45,
calcMarginTop: 13,
legendMarginTop: 26,
legendSize: 21,
legendLineHeight: 1.5,
legendPaddingTop: 18,
linkHeight: 100,
linkRadius: 10,
hairline: 1.5,
} as const
export type SectionStoryDrawContext = {
font: string
fillStyle: string | CanvasGradient | CanvasPattern
strokeStyle: string | CanvasGradient | CanvasPattern
lineWidth: number
textAlign: CanvasTextAlign
textBaseline: CanvasTextBaseline
letterSpacing: string
fillRect(x: number, y: number, width: number, height: number): void
fillText(text: string, x: number, y: number): void
measureText(text: string): { width: number }
save(): void
restore(): void
beginPath(): void
moveTo(x: number, y: number): void
lineTo(x: number, y: number): void
stroke(): void
setLineDash(segments: number[]): void
roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]): void
}
export type SectionStoryInput = {
municipalityName: string
uf: string
zone: number
section: number
/** Já formatados por `sectionPotential` (1 casa, pt-BR). */
firstRound: string
immediate: string
total: string
gainImmediate: string
gainTotal: string
fontFamily: string
}
type StoryWord = { text: string; weight: number; color: string }
const setFont = (ctx: SectionStoryDrawContext, weight: number, size: number, family: string) => {
ctx.font = `${weight} ${size}px ${family}`
}
const wordsOf = (text: string, weight: number, color: string): StoryWord[] =>
text.split(' ').map((word) => ({ text: word, weight, color }))
const richWords = (segments: { text: string; weight?: number; color?: string }[], color: string) =>
segments.flatMap((segment) =>
wordsOf(segment.text, segment.weight ?? 400, segment.color ?? color),
)
/** Greedy word wrap over styled words; the caller sets the size/family. */
const wrapWords = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
maxWidth: number,
size: number,
family: string,
): StoryWord[][] => {
const lines: StoryWord[][] = []
let line: StoryWord[] = []
let lineWidth = 0
const space = () => {
setFont(ctx, 400, size, family)
return ctx.measureText(' ').width
}
for (const word of words) {
setFont(ctx, word.weight, size, family)
const width = ctx.measureText(word.text).width
const next = line.length === 0 ? width : lineWidth + space() + width
if (line.length > 0 && next > maxWidth) {
lines.push(line)
line = [word]
lineWidth = width
} else {
line.push(word)
lineWidth = next
}
}
if (line.length > 0) lines.push(line)
return lines
}
const drawWords = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
x: number,
y: number,
size: number,
family: string,
) => {
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
let cursor = x
for (const [index, word] of words.entries()) {
setFont(ctx, word.weight, size, family)
ctx.fillStyle = word.color
if (index > 0) cursor += ctx.measureText(' ').width
ctx.fillText(word.text, cursor, y)
cursor += ctx.measureText(word.text).width
}
}
const drawWrapped = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
x: number,
y: number,
maxWidth: number,
size: number,
lineHeight: number,
family: string,
): number => {
const lines = wrapWords(ctx, words, maxWidth, size, family)
for (const [index, line] of lines.entries()) {
drawWords(ctx, line, x, y + index * size * lineHeight, size, family)
}
return y + lines.length * size * lineHeight
}
/** Truncates a wrapped text to `maxLines`, adding an ellipsis to the last one. */
const drawWrappedClamped = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
x: number,
y: number,
maxWidth: number,
size: number,
lineHeight: number,
maxLines: number,
family: string,
): number => {
const lines = wrapWords(ctx, words, maxWidth, size, family)
const visible = lines.slice(0, maxLines)
if (lines.length > maxLines && visible.length > 0) {
const last = visible[visible.length - 1]
setFont(ctx, 400, size, family)
let text = last.map((word) => word.text).join(' ')
while (text.length > 0 && ctx.measureText(`${text}…`).width > maxWidth) {
text = text.slice(0, -1).trimEnd()
}
visible[visible.length - 1] = [{ ...last[0], text: `${text}…` }]
}
for (const [index, line] of visible.entries()) {
drawWords(ctx, line, x, y + index * size * lineHeight, size, family)
}
return y + visible.length * size * lineHeight
}
const drawHairline = (ctx: SectionStoryDrawContext, y: number, x0: number, x1: number) => {
ctx.save()
ctx.beginPath()
ctx.strokeStyle = COLORS.hairline
ctx.lineWidth = PX.hairline
ctx.setLineDash([])
ctx.moveTo(x0, y)
ctx.lineTo(x1, y)
ctx.stroke()
ctx.restore()
}
type StoryField = {
number: string
name: string
small: string
value: string
valueKind: 'small' | 'default' | 'hero'
}
/**
* Draws the whole story on a 1080×1920 context. Pure: no DOM, no fonts API —
* the caller must have loaded `fontFamily` (weights 400/500/600/700) first.
*/
export const drawSectionStory = (ctx: SectionStoryDrawContext, input: SectionStoryInput): void => {
const family = input.fontFamily
const left = PX.padX
const right = STORY_WIDTH - PX.padX
const maxWidth = right - left
ctx.save()
ctx.fillStyle = COLORS.background
ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT)
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
let y: number = PX.padTop
ctx.letterSpacing = `${PX.kickerSpacing}px`
setFont(ctx, 500, PX.kickerSize, family)
ctx.fillStyle = COLORS.kicker
ctx.fillText('2º TURNO · 25/10', left, y)
y += PX.kickerSize * 1.3
ctx.letterSpacing = `${PX.titleSpacing}px`
y += PX.titleMarginTop
y = drawWrapped(
ctx,
wordsOf(SECTION_STORY_TITLE, 600, COLORS.title),
left,
y,
maxWidth,
PX.titleSize,
PX.titleLineHeight,
family,
)
ctx.letterSpacing = '0px'
y += PX.subMarginTop
y = drawWrapped(
ctx,
richWords(
[
{ text: 'Cenário só ' },
{ text: 'Lula × Flávio', weight: 600, color: '#2b2b2b' },
{ text: ` nesta seção: ${SECTION_STORY_HYPOTHESIS}` },
],
COLORS.muted,
),
left,
y,
maxWidth,
PX.subSize,
PX.subLineHeight,
family,
)
const fields: StoryField[] = [
{
number: '1.',
name: 'Sua seção',
small: `${input.municipalityName} · ${input.uf}`,
value: `Zona ${input.zone} · Seção ${input.section}`,
valueKind: 'small',
},
{
number: '2.',
name: 'Lula no 1º turno',
small: 'dos votos válidos na seção',
value: input.firstRound,
valueKind: 'default',
},
{
number: '3.',
name: 'Lula no 2º turno',
small: 'só votos imediatos (X₁)',
value: input.immediate,
valueKind: 'default',
},
{
number: '4.',
name: 'Lula no 2º turno',
small: 'todos os votos (X₂)',
value: input.total,
valueKind: 'default',
},
{
number: '5.',
name: 'Ganho imediato',
small: '2º − 1º, em pontos percentuais',
value: input.gainImmediate,
valueKind: 'default',
},
{
number: '6.',
name: 'Ganho total',
small: '2º − 1º, em pontos percentuais',
value: input.gainTotal,
valueKind: 'hero',
},
]
y += PX.fieldsMarginTop
drawHairline(ctx, y, left, right)
const labelX = left + PX.numWidth + PX.fieldGap
const labelMaxWidth = right - labelX - 220
for (const field of fields) {
const valueSize =
field.valueKind === 'hero'
? PX.valueHeroSize
: field.valueKind === 'small'
? PX.valueSmallSize
: PX.valueSize
const valueWeight = field.valueKind === 'hero' ? 700 : field.valueKind === 'small' ? 500 : 600
const rowTop = y + PX.fieldPaddingY
setFont(ctx, 500, PX.numSize, family)
ctx.fillStyle = COLORS.rowNumber
ctx.fillText(field.number, left, rowTop + 2)
const nameBottom = drawWrapped(
ctx,
wordsOf(field.name, 500, COLORS.ink),
labelX,
rowTop,
labelMaxWidth,
PX.nameSize,
PX.nameLineHeight,
family,
)
const smallBottom = drawWrappedClamped(
ctx,
wordsOf(field.small, 400, COLORS.rowSmall),
labelX,
nameBottom + PX.smallMarginTop,
labelMaxWidth,
PX.smallSize,
PX.smallLineHeight,
2,
family,
)
setFont(ctx, valueWeight, valueSize, family)
ctx.fillStyle = COLORS.title
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const valueMiddle = rowTop + Math.max(nameBottom - rowTop, PX.nameSize * PX.nameLineHeight) / 2
ctx.fillText(field.value, right, valueMiddle)
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
const rowHeight = Math.max(smallBottom - rowTop, valueSize) + 2 * PX.fieldPaddingY
y += rowHeight
drawHairline(ctx, y, left, right)
}
y += PX.formulaMarginTop
ctx.letterSpacing = `${PX.formulaTitleSpacing}px`
setFont(ctx, 500, PX.formulaTitleSize, family)
ctx.fillStyle = COLORS.kicker
ctx.fillText('A CONTA (COMO NO TSE: % = VOTOS ÷ VOTOS VÁLIDOS)', left, y)
ctx.letterSpacing = '0px'
y += PX.formulaTitleSize * 1.4
for (const line of [
'2º turno (imediato) = (LULA + X₁) ÷ (LULA + X₁ + FLÁVIO) × 100',
'2º turno (total) = (LULA + X₂) ÷ (LULA + X₂ + FLÁVIO) × 100',
'ganho = 2º − 1º',
]) {
y += PX.calcMarginTop
setFont(ctx, 400, PX.calcSize, family)
ctx.fillStyle = COLORS.ink
ctx.fillText(line, left, y)
y += PX.calcSize * PX.calcLineHeight
}
y += PX.legendMarginTop
drawHairline(ctx, y, left, right)
y += PX.legendPaddingTop
drawWrapped(
ctx,
richWords(
[
{ text: 'X₁', weight: 600, color: COLORS.ink },
{ text: ' = nulos + brancos + votos de terceiros · ' },
{ text: 'X₂', weight: 600, color: COLORS.ink },
{ text: ' = X₁ + faltantes' },
],
COLORS.legend,
),
left,
y,
maxWidth,
PX.legendSize,
PX.legendLineHeight,
family,
)
const linkTop = STORY_HEIGHT - PX.padBottom - PX.linkHeight
ctx.save()
ctx.beginPath()
ctx.strokeStyle = COLORS.linkBorder
ctx.lineWidth = PX.hairline
ctx.setLineDash([8, 8])
ctx.roundRect(left, linkTop, maxWidth, PX.linkHeight, PX.linkRadius)
ctx.stroke()
ctx.restore()
ctx.restore()
}

View file

@ -16,6 +16,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
'jingles',
'conteudos',
'fotos',
'potencial',
'api',
'admin',
'noticia',

View file

@ -0,0 +1,31 @@
import 'server-only'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parsePotentialShard, type PotentialUfShard } from '@/lib/sectionPotential'
/**
* S46 — the server-side reader of the committed TSE shards. The artifact lives
* under `public/` (copied into the standalone image by the Dockerfile) and is
* immutable per deploy, so one parse per UF per process is enough; a missing or
* malformed file answers `null` and every caller fails closed.
*/
const SHARD_DIR = join(process.cwd(), 'public', 'dados', 'potencial-secao-2026')
const shardCache = new Map<string, Promise<PotentialUfShard | null>>()
export const loadPotentialUfShard = (uf: string): Promise<PotentialUfShard | null> => {
const normalized = uf.trim().toUpperCase()
if (!/^[A-Z]{2}$/.test(normalized)) return Promise.resolve(null)
const cached = shardCache.get(normalized)
if (cached) return cached
const promise = readFile(join(SHARD_DIR, `${normalized}.json`), 'utf8')
.then((text) => parsePotentialShard(JSON.parse(text)))
.catch(() => null)
shardCache.set(normalized, promise)
return promise
}

View file

@ -0,0 +1,82 @@
import type { Page } from '@playwright/test'
import { expect, test } from './fixtures/e2eTest'
/**
* S46 — the public `/potencial` flow over real HTTP: the form queries the
* committed TSE artifact through `/api/potencial/*`, the panel shows the real
* Serrinha/BA ZE 150 seção 50 readings (conferidas contra o CSV do TSE) and
* the story canvas exports the 1080×1920 PNG with the same numbers.
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const consultSerrinha = async (page: Page, section = '50') => {
await page.getByLabel('Município').fill('serr')
await page.getByRole('option', { name: 'Serrinha' }).click()
await page.getByLabel('Zona').fill('150')
await page.getByLabel('Seção').fill(section)
await page.getByRole('button', { name: 'Ver o potencial da minha seção' }).click()
}
test.describe('potencial da seção (S46)', () => {
test('consulta a seção real de Serrinha e baixa o story 1080×1920', async ({ page }) => {
await page.goto(`${BASE_URL}/potencial`)
await expect(
page.getByRole('heading', { name: 'Quanto o Lula pode crescer na sua seção?' }),
).toBeVisible()
await consultSerrinha(page)
await expect(page.getByText('Potencial nesta seção')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('66,9%').first()).toBeVisible()
await expect(page.getByText('73,7%').first()).toBeVisible()
await expect(page.getByText('79,8%').first()).toBeVisible()
await expect(page.getByText('+12,9 p.p.').first()).toBeVisible()
await expect(page.getByText('+6,8 p.p.').first()).toBeVisible()
const canvas = page.locator('canvas')
await expect(canvas).toHaveAttribute('width', '1080')
await expect(canvas).toHaveAttribute('height', '1920')
const download = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Baixar imagem' }).click(),
]).then(([event]) => event)
expect(download.suggestedFilename()).toBe('potencial-lula-ba-serrinha-z150-s50.png')
await expect(page.getByText('Imagem pronta — 1080 × 1920 px')).toBeVisible()
})
// A probe da seção inexistente provoca o 404 da API de propósito; o browser
// loga o recurso falho mesmo com o app tratando — declarado só neste bloco,
// para uma falha inesperada da rota no caminho feliz continuar sendo falha.
test.describe('seção inexistente', () => {
test.use({ expectedRequestFailurePaths: [/\/api\/potencial\/secao$/] })
test('não inventa número para seção inexistente', async ({ page }) => {
await page.goto(`${BASE_URL}/potencial`)
await consultSerrinha(page, '9999')
await expect(page.getByRole('heading', { name: 'Não encontramos essa seção' })).toBeVisible({
timeout: 20_000,
})
await expect(page.getByRole('alert').filter({ hasText: 'dados do TSE' })).toContainText(
'Essa combinação de zona e seção não aparece nos dados do TSE.',
)
await expect(page.getByLabel('Zona')).toHaveAttribute('aria-invalid', 'true')
await expect(page.getByLabel('Seção')).toHaveAttribute('aria-invalid', 'true')
})
})
test('mobile 390 empilha o fluxo sem estourar a largura', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${BASE_URL}/potencial`)
await consultSerrinha(page)
await expect(page.getByText('Potencial nesta seção')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('canvas')).toBeVisible()
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
)
expect(overflow).toBeLessThanOrEqual(0)
})
})

View file

@ -62,6 +62,10 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/convite/e2e-prewarm',
'/',
'/cards',
// S46 — the public story page (form + the two anonymous JSON reads).
'/potencial',
'/api/potencial/municipios?uf=BA',
'/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50',
]) {
await prewarmGet(path)
}

View file

@ -0,0 +1,102 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { GET as getMunicipios } from '@/app/(frontend)/api/potencial/municipios/route'
import { GET as getSecao } from '@/app/(frontend)/api/potencial/secao/route'
/**
* S46 — the two public reads of `/potencial` over the committed TSE artifact:
* the município list of a UF and the raw numbers of one real section
* (Serrinha/BA ZE 150 seção 50, conferida contra o CSV do TSE). Guards:
* same-origin, strict query, rate limit and the honest 404.
*/
const request = (path: string, headers: Record<string, string> = {}): Request =>
new Request(`http://localhost${path}`, { headers })
describe('GET /api/potencial/municipios', () => {
it('lists the municípios of a UF, alphabetically, with a day cache', async () => {
const response = await getMunicipios(request('/api/potencial/municipios?uf=BA'))
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('max-age=86400')
const body = (await response.json()) as {
ok: boolean
municipalities: { code: number; name: string }[]
}
expect(body.ok).toBe(true)
expect(body.municipalities).toHaveLength(417)
expect(body.municipalities).toContainEqual({ code: 39098, name: 'Serrinha' })
const names = body.municipalities.map((municipality) => municipality.name)
expect([...names].sort((left, right) => left.localeCompare(right, 'pt-BR'))).toEqual(names)
})
it('rejects a malformed query and a cross-origin request', async () => {
expect((await getMunicipios(request('/api/potencial/municipios?uf=ba'))).status).toBe(400)
expect((await getMunicipios(request('/api/potencial/municipios'))).status).toBe(400)
expect((await getMunicipios(request('/api/potencial/municipios?uf=BA&extra=1'))).status).toBe(
400,
)
expect(
(
await getMunicipios(
request('/api/potencial/municipios?uf=BA', { origin: 'https://evil.example' }),
)
).status,
).toBe(403)
})
})
describe('GET /api/potencial/secao', () => {
it('answers the raw numbers of a real section', async () => {
const response = await getSecao(
request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50'),
)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('max-age=86400')
expect(await response.json()).toEqual({
ok: true,
section: {
uf: 'BA',
municipalityCode: 39098,
municipalityName: 'Serrinha',
zone: 150,
section: 50,
aptos: 376,
comparecimento: 289,
lula: 174,
flavio: 76,
validos: 260,
},
})
})
it('answers 404 for an unknown section and 400 for a malformed query', async () => {
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=9999')))
.status,
).toBe(404)
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=1&zona=150&secao=50'))).status,
).toBe(404)
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=0&secao=50'))).status,
).toBe(400)
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=150'))).status,
).toBe(400)
})
it('throttles a client that exceeds the window budget', async () => {
const headers = { 'x-forwarded-for': '203.0.113.9' }
for (let index = 0; index < 120; index += 1) {
const response = await getSecao(
request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50', headers),
)
expect(response.status).toBe(200)
}
const throttled = await getSecao(
request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50', headers),
)
expect(throttled.status).toBe(429)
})
})

View file

@ -553,6 +553,10 @@ describe('src/utilities top-level is pinned', () => {
'payloadTransaction.ts',
'postgresTransactionLocks.ts',
'posts.ts',
// S46 — server-side reader of the committed TSE section shards under
// `public/dados/potencial-secao-2026` (fs + parse cache, fail-closed);
// the sibling of `electionCache.ts` for the `/potencial` public route.
'potencialSections.ts',
'recentVisits.ts',
'revalidateRequest.ts',
'sameOriginRequest.ts',

View file

@ -0,0 +1,198 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
buildColinhaBeats,
buildColinhaTimeline,
COLINHA_BEAT_DURATIONS,
COLINHA_CANDIDATES,
COLINHA_REEL,
COLINHA_URNA_ORDER,
getColinhaCandidate,
} from '../../scripts/lib/colinhaReel.mjs'
import { colinhaReelHtml } from '../../scripts/lib/colinhaReelTemplate.mjs'
// The colinha chat reel — the script is the single source of the piece: the
// urna order, the ballot numbers, the beat timing and the two end screens are
// pinned here, and the template must render every beat of the script.
const ASSETS_DIR = join(process.cwd(), 'scripts/reels/assets', 'colinha-time-1313')
const REQUIRED_ASSETS = [
'solla.webp',
'julio.webp',
'wagner.webp',
'rui.webp',
'jeronimo.webp',
'lula.webp',
'logo-o.webp',
'fonts/inter-latin.woff2',
]
describe('colinhaReel', () => {
it('keeps the ticket in urna order with the official ballot numbers', () => {
expect(COLINHA_URNA_ORDER).toEqual(['solla', 'julio', 'wagner', 'rui', 'jeronimo', 'lula'])
expect(COLINHA_CANDIDATES.map((candidate) => candidate.number)).toEqual([
'1313',
'13999',
'130',
'133',
'13',
'13',
])
expect(COLINHA_CANDIDATES.map((candidate) => candidate.office)).toEqual([
'DEPUTADO FEDERAL',
'DEPUTADO ESTADUAL',
'SENADOR (1ª VAGA)',
'SENADOR (2ª VAGA)',
'GOVERNADOR',
'PRESIDENTE',
])
expect(COLINHA_CANDIDATES.map((candidate) => candidate.ask)).toEqual([
'Deputado Federal',
'Deputado Estadual',
'Senador (1ª vaga)',
'Senador (2ª vaga)',
'Governador',
'Presidente',
])
expect(COLINHA_REEL.slug).toBe('colinha-time-1313')
})
it('ships the committed photo for every candidate and the star badge', () => {
for (const candidate of COLINHA_CANDIDATES) {
expect(existsSync(join(ASSETS_DIR, candidate.photo))).toBe(true)
}
for (const asset of REQUIRED_ASSETS) {
expect(existsSync(join(ASSETS_DIR, asset))).toBe(true)
}
expect(getColinhaCandidate('solla')?.name).toBe('JORGE SOLLA')
expect(getColinhaCandidate('nobody')).toBeUndefined()
})
it('asks the full question once, then the short "e pra …?" for every candidate', () => {
const beats = buildColinhaBeats()
const asks = beats.filter((beat) => beat.id.startsWith('ask-'))
expect(asks).toHaveLength(COLINHA_CANDIDATES.length)
expect(asks[0].text).toBe('pra Deputado Federal, quem você escolheu?')
expect(asks.slice(1).map((beat) => beat.text)).toEqual([
'e pra Deputado Estadual?',
'e pra Senador (1ª vaga)?',
'e pra Senador (2ª vaga)?',
'e pra Governador?',
'e pra Presidente?',
])
})
it('walks every candidate through ask → number → card and ends on the assembling slip', () => {
const beats = buildColinhaBeats({ finale: 'motion' })
const kinds = beats.map((beat) => beat.kind)
expect(kinds[0]).toBe('voter')
expect(beats.at(-1)?.kind).toBe('brand')
const reveals = beats.filter((beat) => beat.kind === 'reveal')
expect(reveals.map((beat) => beat.candidateId)).toEqual(COLINHA_URNA_ORDER)
expect(beats.filter((beat) => beat.kind === 'end')).toHaveLength(1)
expect(beats.find((beat) => beat.kind === 'end')?.screen).toBe('colinha')
const staticBeats = buildColinhaBeats({ finale: 'static' })
expect(staticBeats.filter((beat) => beat.kind === 'reveal')).toHaveLength(0)
expect(staticBeats.at(-1)?.kind).toBe('brand')
expect(staticBeats.at(-2)?.kind).toBe('end')
const staticEnd = staticBeats.find((beat) => beat.kind === 'end')
expect((staticEnd?.durationMs ?? 0) > 4000).toBe(true)
for (const candidate of COLINHA_CANDIDATES) {
const start = beats.findIndex((beat) => beat.id === `ask-${candidate.id}`)
expect(start).toBeGreaterThan(-1)
expect(beats[start + 1]).toMatchObject({
id: `number-${candidate.id}`,
kind: 'number',
text: candidate.number,
candidateId: candidate.id,
})
expect(beats[start + 2]).toMatchObject({ id: `card-${candidate.id}`, kind: 'card' })
}
const voterNumbers = beats.filter((beat) => beat.kind === 'number')
expect(voterNumbers.map((beat) => beat.text)).toEqual(
COLINHA_CANDIDATES.map((candidate) => candidate.number),
)
})
it('derives the timeline cumulatively and hides each typing beat when the reply lands', () => {
const beats = buildColinhaBeats()
const timeline = buildColinhaTimeline(beats, 30)
const expectedTotal = beats.reduce((sum, beat) => sum + beat.durationMs, 0)
expect(timeline.totalMs).toBe(expectedTotal)
expect(timeline.frameCount).toBe(Math.round((expectedTotal / 1000) * 30))
expect(timeline.entries[0].atMs).toBe(0)
timeline.entries.forEach((entry, index) => {
if (index > 0) expect(entry.atMs).toBe(timeline.entries[index - 1].endsAtMs)
if (entry.kind === 'typing') {
expect(entry.hideAtMs).toBe(timeline.entries[index + 1]?.atMs)
}
})
for (const [id, entry] of timeline.byId) {
expect(entry.id).toBe(id)
}
expect(COLINHA_BEAT_DURATIONS.flip).toBeGreaterThan(0)
expect(timeline.totalMs).toBeGreaterThan(20_000)
})
it('renders every beat, card and the assembling end screen in the template', () => {
const timeline = buildColinhaTimeline(buildColinhaBeats(), 30)
const reveals = timeline.entries.flatMap((entry) =>
entry.kind === 'reveal' && entry.candidateId
? [{ candidateId: entry.candidateId, atMs: entry.atMs, durationMs: entry.durationMs }]
: [],
)
const html = colinhaReelHtml({
beats: timeline.entries,
candidates: COLINHA_CANDIDATES,
reveals,
brandAtMs: timeline.byId.get('brand')?.atMs ?? 0,
drawnRow: COLINHA_CANDIDATES[1].id,
totalMs: timeline.totalMs,
flipMs: COLINHA_BEAT_DURATIONS.flip,
fontCss: '',
assets: { waveClip: 'polygon(0px 0px,540px 0px)' },
})
for (const entry of timeline.entries) {
if (entry.kind === 'end') continue
expect(html).toContain(`data-beat="${entry.id}"`)
}
for (const candidate of COLINHA_CANDIDATES) {
expect(html).toContain(candidate.name)
expect(html).toContain(candidate.office)
expect(html).toContain(`>${candidate.number[0]}</span>`)
}
for (const candidate of COLINHA_CANDIDATES) {
expect(html).toContain(`data-figure="${candidate.id}"`)
expect(html).toContain(`data-logo="${candidate.id}"`)
expect(html).toContain(`data-row="${candidate.id}"`)
}
expect(html).toContain('id="screen-colinha"')
expect(html).toContain('id="title-patch"')
expect(html).toContain('>Time 1313</span>')
expect(html).not.toContain('⭐')
expect(html).toContain('prontinho! colinha completa. bom voto! ✅')
expect(html).toContain('window.__colinhaConfig=')
const cardHtml = colinhaReelHtml({
beats: timeline.entries,
candidates: COLINHA_CANDIDATES,
reveals,
brandAtMs: timeline.byId.get('brand')?.atMs ?? 0,
drawnRow: COLINHA_CANDIDATES[1].id,
totalMs: timeline.totalMs,
flipMs: COLINHA_BEAT_DURATIONS.flip,
fontCss: '',
assets: { siteCard: 'data:image/png;base64,AAAA' },
cardFinale: true,
})
expect(cardHtml).toContain('data:image/png;base64,AAAA')
expect(cardHtml).not.toContain('group--sharp')
})
})

View file

@ -0,0 +1,206 @@
import { describe, expect, it } from 'vitest'
import {
computeSectionPotential,
filterPotentialMunicipalities,
findPotentialSection,
formatPotentialPercent,
formatPotentialPoints,
parsePotentialShard,
sectionStoryFileName,
type PotentialSectionNumbers,
type PotentialUfShard,
} from '@/lib/sectionPotential'
const numbers = (overrides: Partial<PotentialSectionNumbers> = {}): PotentialSectionNumbers => ({
uf: 'BA',
municipalityCode: 39098,
municipalityName: 'Serrinha',
zone: 150,
section: 50,
aptos: 376,
comparecimento: 289,
lula: 174,
flavio: 76,
validos: 260,
...overrides,
})
describe('computeSectionPotential', () => {
it('refuses the plan illustrative reference (válidos above the comparecimento)', () => {
// O exemplo do plano ("Seção 0051 Curitiba/PR: Lula 91, Flávio 81, válidos
// 190, X₁=13, X₂=69") é uma referência de forma, marcada como "não usar na
// UI como dado real" — e é aritmeticamente impossível: comp = Lula+Flávio+X₁
// = 185 < válidos = 190. O cálculo falha fechado em vez de renderizar %.
const result = computeSectionPotential({
aptos: 241,
comparecimento: 185,
lula: 91,
flavio: 81,
validos: 190,
})
expect(result).toEqual({ available: false, reason: 'no-data' })
})
it('computes the real Serrinha/BA ZE 150 section 50 row', () => {
const result = computeSectionPotential(numbers())
expect(result.available).toBe(true)
if (!result.available) return
// X₁ = 289 − 174 − 76 = 39 (nulos + brancos + todos os terceiros);
// X₂ = 376 − 174 − 76 = 126 (X₁ + faltantes).
expect(result.potential.x1).toBe(39)
expect(result.potential.x2).toBe(126)
expect(formatPotentialPercent(result.potential.firstRoundPct)).toBe('66,9%')
expect(formatPotentialPercent(result.potential.immediatePct)).toBe('73,7%')
expect(formatPotentialPercent(result.potential.totalPct)).toBe('79,8%')
expect(formatPotentialPoints(result.potential.gainImmediatePp)).toBe('+6,8 p.p.')
expect(formatPotentialPoints(result.potential.gainTotalPp)).toBe('+12,9 p.p.')
})
it('never fabricates a percentage for a section without valid votes', () => {
expect(
computeSectionPotential({ aptos: 100, comparecimento: 0, lula: 0, flavio: 0, validos: 0 }),
).toEqual({ available: false, reason: 'no-data' })
})
it.each([
[
'Lula + Flávio above the comparecimento',
{ aptos: 400, comparecimento: 100, lula: 60, flavio: 60, validos: 100 },
],
[
'válidos above the comparecimento',
{ aptos: 400, comparecimento: 100, lula: 40, flavio: 40, validos: 120 },
],
[
'turnout above the electorate',
{ aptos: 90, comparecimento: 100, lula: 40, flavio: 40, validos: 80 },
],
[
'a candidate above the valid votes',
{ aptos: 400, comparecimento: 300, lula: 250, flavio: 40, validos: 200 },
],
])('fails closed on impossible counts: %s', (_label, row) => {
expect(computeSectionPotential(row)).toEqual({ available: false, reason: 'no-data' })
})
it('proves the gain is never negative under the approved hypothesis', () => {
let seed = 7
const random = (max: number) => {
seed = (seed * 1103515245 + 12345) % 2147483648
return seed % (max + 1)
}
for (let index = 0; index < 500; index += 1) {
const aptos = 50 + random(950)
const comparecimento = random(aptos)
const validos = random(comparecimento)
const lula = random(validos)
const flavio = random(validos - lula)
const result = computeSectionPotential({ aptos, comparecimento, lula, flavio, validos })
if (!result.available) continue
const { gainImmediatePp, gainTotalPp, firstRoundPct, immediatePct, totalPct } =
result.potential
expect(gainImmediatePp).toBeGreaterThanOrEqual(0)
expect(gainTotalPp).toBeGreaterThanOrEqual(gainImmediatePp)
expect(firstRoundPct).toBeGreaterThanOrEqual(0)
expect(totalPct).toBeLessThanOrEqual(100)
expect(immediatePct).toBeLessThanOrEqual(100)
}
})
})
describe('parsePotentialShard', () => {
const valid = {
version: 1,
uf: 'BA',
municipalities: [
{ code: 39098, name: 'Serrinha', sections: [[150, 50, 376, 289, 174, 76, 260]] },
],
}
it('accepts a well-formed shard', () => {
expect(parsePotentialShard(valid)).toEqual(valid)
})
it.each([
['a wrong version', { ...valid, version: 2 }],
['an unknown UF', { ...valid, uf: 'XX' }],
['a municipality without a name', { ...valid, municipalities: [{ code: 1, sections: [] }] }],
[
'a row with the wrong width',
{ ...valid, municipalities: [{ code: 1, name: 'X', sections: [[1, 2, 3]] }] },
],
[
'a row with a negative count',
{ ...valid, municipalities: [{ code: 1, name: 'X', sections: [[1, 2, -3, 4, 5, 6, 7]] }] },
],
['a non-object', null],
])('rejects %s', (_label, value) => {
expect(parsePotentialShard(value)).toBeNull()
})
})
describe('findPotentialSection', () => {
const shard: PotentialUfShard = {
version: 1,
uf: 'BA',
municipalities: [
{ code: 39098, name: 'Serrinha', sections: [[150, 50, 376, 289, 174, 76, 260]] },
{ code: 40000, name: 'Outro', sections: [[1, 1, 10, 8, 4, 2, 7]] },
],
}
it('finds a section through the binary searches', () => {
expect(
findPotentialSection(shard, { municipalityCode: 39098, zone: 150, section: 50 }),
).toEqual({
uf: 'BA',
municipalityCode: 39098,
municipalityName: 'Serrinha',
zone: 150,
section: 50,
aptos: 376,
comparecimento: 289,
lula: 174,
flavio: 76,
validos: 260,
})
})
it('answers null for an unknown município or section', () => {
expect(findPotentialSection(shard, { municipalityCode: 1, zone: 1, section: 1 })).toBeNull()
expect(findPotentialSection(shard, { municipalityCode: 39098, zone: 1, section: 1 })).toBeNull()
})
})
describe('formatters and helpers', () => {
it('formats percentages with one pt-BR decimal', () => {
expect(formatPotentialPercent(66.923)).toBe('66,9%')
expect(formatPotentialPercent(0)).toBe('0,0%')
})
it('derives the sign of the gain instead of hardcoding "+"', () => {
expect(formatPotentialPoints(6.864)).toBe('+6,9 p.p.')
expect(formatPotentialPoints(0)).toBe('+0,0 p.p.')
expect(formatPotentialPoints(-1.24)).toBe('−1,2 p.p.')
})
it('names the story file from the section identity', () => {
expect(sectionStoryFileName(numbers())).toBe('potencial-lula-ba-serrinha-z150-s50.png')
})
it('filters municípios accent-insensitively from three letters', () => {
const municipalities = [
{ code: 1, name: 'Serrinha' },
{ code: 2, name: 'São Gonçalo dos Campos' },
{ code: 3, name: 'Feira de Santana' },
]
expect(filterPotentialMunicipalities(municipalities, 'ser')).toEqual([
{ code: 1, name: 'Serrinha' },
])
expect(filterPotentialMunicipalities(municipalities, 'sao goncalo')).toEqual([
{ code: 2, name: 'São Gonçalo dos Campos' },
])
expect(filterPotentialMunicipalities(municipalities, 'se')).toEqual([])
})
})

View file

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
drawSectionStory,
STORY_HEIGHT,
STORY_WIDTH,
type SectionStoryDrawContext,
type SectionStoryInput,
} from '@/lib/sectionStoryRender'
type FillCall = { text: string; x: number; y: number; font: string; fillStyle: string }
type Recorder = SectionStoryDrawContext & {
texts: FillCall[]
rects: { x: number; y: number; width: number; height: number; fillStyle: string }[]
strokes: { dash: number[]; strokeStyle: string }[]
lineDash: number[]
}
const createRecorder = () => {
const texts: FillCall[] = []
const rects: { x: number; y: number; width: number; height: number; fillStyle: string }[] = []
const strokes: { dash: number[]; strokeStyle: string }[] = []
const ctx: Recorder = {
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
textAlign: 'left',
textBaseline: 'top',
letterSpacing: '',
fillRect(x, y, width, height) {
rects.push({ x, y, width, height, fillStyle: String(ctx.fillStyle) })
},
fillText(text, x, y) {
texts.push({ text, x, y, font: ctx.font, fillStyle: String(ctx.fillStyle) })
},
// Largura proporcional ao tamanho da fonte corrente — suficiente para o wrap.
measureText(text) {
const size = Number(/(\d+(?:\.\d+)?)px/.exec(ctx.font)?.[1] ?? '16')
return { width: text.length * size * 0.52 }
},
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {
strokes.push({ dash: ctx.lineDash, strokeStyle: String(ctx.strokeStyle) })
},
setLineDash(segments) {
ctx.lineDash = [...segments]
},
roundRect() {},
lineDash: [] as number[],
texts,
rects,
strokes,
}
return ctx
}
const input = (overrides: Partial<SectionStoryInput> = {}): SectionStoryInput => ({
municipalityName: 'Serrinha',
uf: 'BA',
zone: 150,
section: 50,
firstRound: '66,9%',
immediate: '73,7%',
total: '79,8%',
gainImmediate: '+6,8 p.p.',
gainTotal: '+12,9 p.p.',
fontFamily: 'Arimo, Arial, sans-serif',
...overrides,
})
describe('drawSectionStory', () => {
it('paints the 1080×1920 off-white background', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
expect(ctx.rects[0]).toEqual({
x: 0,
y: 0,
width: STORY_WIDTH,
height: STORY_HEIGHT,
fillStyle: '#f7f6f3',
})
})
it('draws the section identity, the three readings and both gains', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
const drawn = ctx.texts.map((call) => call.text)
const joined = drawn.join(' ')
expect(joined).toContain('2º TURNO · 25/10')
expect(joined).toContain('Quanto o Lula pode crescer na sua seção?')
expect(joined).toContain('Serrinha · BA')
expect(joined).toContain('Zona 150 · Seção 50')
expect(joined).toContain('66,9%')
expect(joined).toContain('73,7%')
expect(joined).toContain('79,8%')
expect(joined).toContain('+6,8 p.p.')
expect(joined).toContain('+12,9 p.p.')
expect(joined).toContain('X₁')
expect(joined).toContain('X₂')
})
it('draws the hero gain with the 700 48px face and keeps every text inside the canvas', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
const hero = ctx.texts.find((call) => call.text === '+12,9 p.p.')
expect(hero?.font).toContain('700 48px')
for (const call of ctx.texts) {
expect(call.y).toBeGreaterThanOrEqual(0)
expect(call.y).toBeLessThan(STORY_HEIGHT)
expect(call.x).toBeGreaterThanOrEqual(0)
expect(call.x).toBeLessThanOrEqual(STORY_WIDTH)
}
})
it('reserves the bottom link area with a dashed border', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
expect(ctx.strokes.some((stroke) => stroke.dash.length === 2)).toBe(true)
})
it('clamps a long município name to two lines with an ellipsis', () => {
const ctx = createRecorder()
drawSectionStory(
ctx,
input({
municipalityName:
'São José do Vale do Rio Preto do Norte do Sertão do Cariri Paraibano da Serra do Mar e do Vale do Rio Doce de Minas Gerais do Oeste Baiano',
}),
)
expect(ctx.texts.some((call) => call.text.endsWith('…'))).toBe(true)
})
})

View file

@ -57,6 +57,7 @@ describe('shareLink slug', () => {
'jingles',
'mandato-no-whatsapp',
'noticia',
'potencial',
'privacidade',
].sort(),
)