feat(S34): colinha usa a arte exata com a linha do estadual preenchida (Closes #1298)

This commit is contained in:
Francisco Solla 2026-09-24 01:46:56 -03:00
parent 9069d23d88
commit 2c8b38f2be
14 changed files with 624 additions and 784 deletions

View file

@ -22,7 +22,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`/`cardCutout.ts`/`cardPhotoHarmonyCanvas.ts` (S17). Geometria/catálogo/tom puros em `src/lib/card{Models,NameFit,PhotoTransform,Render,Harmony}.ts`.
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`) e do roster estático das 53 dobradinhas (`stateDeputyCatalog`, S30 — snapshot em `tests/fixtures/state-deputy-catalog.snapshot.json`; artes derivadas por `scripts/build-state-deputy-card-assets.mjs`), além da geometria/linhas da colinha (`cardColinha`, S31 — topo/linha legal/corpo medidos do gate, `colinhaVoteRows` e o fit do candidato).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`) e do roster estático das 53 dobradinhas (`stateDeputyCatalog`, S30 — snapshot em `tests/fixtures/state-deputy-catalog.snapshot.json`; artes derivadas por `scripts/build-state-deputy-card-assets.mjs`), além da geometria da linha do estadual da colinha (`cardColinha`, S34 — a arte aprovada é a base e só a linha `DEPUTADO ESTADUAL` é desenhada por cima, com `fitColinhaRowName`).
- `src/migrations/` — história congelada; nunca editar retroativamente.
- `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`).

View file

@ -0,0 +1 @@
**S34 — Minha colinha: a arte exata entregue pelo humano + a linha do estadual preenchida (2026-09-24):** a prévia e o PNG 1080×1920 do modelo `minha-colinha` deixam de ser uma recriação do zero e passam a ser **a arte aprovada** (`public/cards/modelo-colinha.jpeg`, 900×1600, o arquivo entregue pelo humano, cópia byte-a-byte do tile, sha256 `76505492…`) desenhada inteira no canvas com ampliação exata de 1,2× (`drawImage` em 1080×1920 + `imageSmoothingQuality='high'`); o único desenho novo é a 2ª linha, `DEPUTADO ESTADUAL`: máscara branca sobre o rótulo queimado, rótulo reposto à esquerda, nome de urna em caixa alta à direita e os cinco dígitos do catálogo S30, um por caixa impressa. Sem estadual escolhido, a arte aparece exatamente como entregue (caixas vazias e selos plenos) e o download segue desabilitado pelo `canAdvance` existente. A composição antiga foi **removida, não duplicada**: `COLINHA_LAYOUT`, `drawColinhaTop/Legal/Digit/Confirm`, `fitColinhaCandidate`, `colinhaVoteRows` e os literais legais/selos saíram; o modelo `minha-colinha` passa a ter `assetSrc` = a arte (sem `overlaySrc`/`lockupSrc`/`previewSrc`, e o campo `lockupSrc` de `CardModel` foi deletado), o tile da galeria cai no fallback `assetSrc` (mesma imagem) e a `Linha conferida` deriva `${COLINHA_ESTADUAL_LABEL} · NOME · 5 dígitos`. O subset `CardDrawContext` ganhou `imageSmoothingEnabled`/`imageSmoothingQuality` (extensão aditiva, só a colinha seta). A crítica final do `designer` (trigger c) reprovou a primeira passada e os ajustes entraram antes do push: o rótulo subiu de 23,22 para **26,9px** (cap height 19,2px igual aos rótulos impressos da arte), a prévia vazia foi de 145 para **248/236px** (cenas 01–02) e o desktop preenchido virou **grid de 2 colunas num dialog de 960px** (`data-colinha-filled` + `has-[[…]]:sm:max-w-[60rem]`, prévia com teto `max-h-[calc(92dvh-14rem)]` para o 9:16 nunca ser cortado pelo footer) — **certificado em tier primário** (`Design tier: openai/gpt-5.6-sol`) na segunda passada. Verificação: unit (`cardColinha` com os pins da geometria do gate/crítica, `fitColinhaRowName` e a varredura das 53 entradas provando `pairLeft ≥ mask.x`, tile sha256/900×1600; `cardRender` com ordem arte→máscara→rótulo→nome→dígitos, suavização, vazio sem um pixel desenhado; `cardModels` com o modelo repontado), e2e (`frontend`: vazio com a tinta queimada em `firstDarkX > 500` sem vermelho e caixas vazias, preenchido com rótulo e nome vermelho dentro da máscara + dígito na caixa + download/evento S32, pior nome `arthur` dentro da máscara, troca), `pnpm gate:fast`, e2e local do spec `frontend` (41/41) e `pnpm push`.

View file

@ -0,0 +1,162 @@
# Impl: S34 — Minha colinha: a arte exata entregue pelo humano + a linha do estadual preenchida
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1298
Intenção: docs/plans/cards-colinha-arte-exata.md
Appetite restante: herdado — ~0,5–1 dia eng; 4 fases somam ~1 dia, sem migration, sem asset novo, sem dependência nova
## Leitura da intenção
- **Outcome:** a prévia e o PNG 1080×1920 do modelo `minha-colinha` passam a ser o JPEG aprovado (`modelo-colinha.jpeg`, 900×1600) ampliado exatamente 1,2×, como entregue; só a linha `DEPUTADO ESTADUAL` (2ª) é preenchida — nome de urna em caixa alta + os cinco dígitos, um por caixa existente. Sem estadual, a arte aparece intacta e o download fica desabilitado.
- **O que NÃO negociar:** nenhum outro pixel redesenhado (topo, cinco linhas fixas, legal e selos vêm da arte); a linha preenchida lê como parte da arte (mesma tipografia/peso/cor/tamanho/linha de base, dígitos centrados, nome sem colidir com rótulo nem borda); 100% no aparelho; um só editor/fluxo (`#cards`, `/cards`, `?model=minha-colinha`); o JPEG continua tile da galeria; `StateDeputySelect`/`stateDeputyCatalog` (S30) e o analytics de S32 intocados; sem CMS/migration; sem PDF/A4; sem nome/foto do visitante; sem superfície nova.
- **O que reavaliar (hipóteses da direção):**
- "`renderColinhaCard` redesenha a arte inteira" — confirmado: `COLINHA_LAYOUT` + `drawColinhaTop/Legal/Digit/Confirm` compõem tudo do zero (`src/lib/cardRender.ts:338-591`); a maior parte morre (Decisão 3/C).
- "`cardModels` muda `assetSrc`/`lockupSrc`" — a entrada passa a ter a arte como `assetSrc` e perde `overlaySrc`/`lockupSrc`/`previewSrc`; o tile segue a mesma arte via `previewSrc ?? assetSrc` (`CardModelTile.tsx:58`), agora pelo fallback (Decisão 4/D).
- "o branch da colinha carrega a base" — confirmado; o efeito de carga já lê `model.assetSrc` (`CardComposer.tsx:280-295`); o branch só enxuga (sai `lockupImage`, sai o desenho composto).
- "o gate do download sem estadual já existe" — confirmado: `canAdvance` (`CardComposer.tsx:596-598`) e `disabled` (`:1186`) já tratam a colinha; nenhum código novo (Decisão 5/E).
- Geometria do design confere com a arte: máscara 55,9%→2,9% = y 1073,28→1128,96; cópia 56,46% = y 1084,03; dígitos top 58,4% = 1121,28, largura 6,1% = 65,88, d1 left 25,9% = 279,72, passo 6,9% = 74,52; escala 1,2 exata do JPEG. Sondagem no JPEG (sharp): o rótulo queimado ocupa x≈571–856 / y≈1088–1108 e as cinco caixas vazias (círculos, interior branco puro) vão de y≈1128 a 1195, centradas em x≈312,6/387,6/462,0/536,4/610,8 — a máscara cobre o rótulo e para no topo das caixas.
## Abordagem recomendada
```mermaid
flowchart LR
C["CardComposer — branch kind:'colinha'"] --> RC["renderColinhaCard — cardRender.ts (dono do render)"]
ART["/cards/modelo-colinha.jpeg — arte aprovada"] --> RC
RC --> PV["prévia canvas 1080×1920"]
PV --> DL["Baixar minha colinha — PNG 1080×1920"]
SEL["StateDeputySelect (S30)"] --> C
CAT["stateDeputyCatalog — 53 entradas"] --> SEL
GEO["COLINHA_ROW_LAYOUT + fitColinhaRowName — cardColinha.ts"] --> RC
RC -->|"deputy = null"| EMPTY["arte intocada; CTA desabilitado (canAdvance)"]
```
**Opções consideradas:** A) canvas — `drawImage(jpeg, 0, 0, 1080, 1920)` com `imageSmoothingQuality='high'` e a linha desenhada por cima (máscara branca + rótulo + nome + dígitos) | B) sobrepor a linha em DOM/CSS sobre um `<img>` e rasterizar (html2canvas) | C) montar SVG/foreignObject → `Image` → canvas.
**Recomendação:** A — é exatamente o caminho do design (as cenas usam esses elementos), mantém o dono único do render (`cardRender.ts`), a prévia e o download já saem do mesmo canvas, e não entra dependência nova.
**Rejeitadas:** B porque exigiria dependência nova e uma segunda via de rasterização (fidelidade de fonte/anti-aliasing fora do nosso controle); C porque duplicaria o pipeline de desenho em outro formato (fontes/imagens embutidas, diferenças de browser) sem ganho.
### Decisões de engenharia
**1. Composição da linha (A) — canvas por cima do JPEG.**
**Opções:** A) desenhar o JPEG ampliado e, por cima, máscara branca + rótulo + nome + dígitos no `renderColinhaCard` | B) renderizar a linha em DOM/CSS e rasterizar | C) offscreen canvas só para a linha e `drawImage` do resultado.
**Recomendação:** A — o design já é um overlay de 6 elementos sobre a imagem; o render dono (`cardRender.ts`) desenha na mesma ordem do design (arte → máscara → rótulo → nome → dígitos) e o recorder falso prova a geometria; zero dependência e zero segundo pipeline.
**Rejeitadas:** B porque introduz html2canvas e uma rasterização que não controlamos; C porque o offscreen não muda o resultado nem o custo, só adiciona um segundo contexto a manter.
**2. Fonte do nome/dígitos (B) — face mais próxima do sistema + prova lado a lado.**
**Opções:** A) usar os stacks do design (`Arial, Helvetica, sans-serif` no nome/rótulo; `Arial Black, Arial, Helvetica, sans-serif` nos dígitos), com o artefato lado a lado no PR como aceite | B) embarcar uma face Black nova no repo (novo asset licenciado + pipeline de carga) | C) manter `COLINHA_FONT_FAMILY` atual para tudo e só aumentar o peso.
**Recomendação:** A — é a resposta assumida da intenção e o NEEDS ASSET explícito do design ("o port deve provar a equivalência lado a lado; este gate decide composição e geometria"); menor custo e nenhum asset novo.
**Rejeitadas:** B porque a face exata da arte não foi licenciada/entregue e `ensureCardFont` só carrega 1 família/peso (mexeria no load compartilhado e adicionaria ~100KB+licença por um item que o gate não pediu — vira gatilho de follow-up se a prova reprovar); C porque o design separa peso 400 (rótulo) de 900 (nome) e Arial Black nos dígitos.
**3. Código morto (C) — remover o que morre; `Linha conferida` derivada.**
**Opções:** A) remover `COLINHA_LAYOUT`, `drawColinhaTop/Legal/Digit/Confirm`, `capAt`, `fitColinhaCandidate`, `COLINHA_FIXED_ROWS`, `colinhaVoteRows`, `COLINHA_ESTADUAL_PLACEHOLDER`, `COLINHA_LEGAL_TEXT` e `COLINHA_CONFIRM_LABEL`; a UI deriva `DEPUTADO ESTADUAL · <NOME> · <5 dígitos>` do `COLINHA_ESTADUAL_LABEL` + entrada do catálogo | B) manter `COLINHA_LAYOUT` e os `draw*` como via antiga | C) manter só `colinhaVoteRows` para a UI (tabela fixa).
**Recomendação:** A — a intenção manda "a composição do zero deve ser REMOVIDA, não duplicada (sem segunda via morta)"; a linha de conferência não precisa da tabela das 6 linhas (a arte já carrega as 5 fixas); o literal do cargo fica uma vez em `cardColinha.ts` (dono) e é usado pelo renderer e pela UI.
**Rejeitadas:** B porque knip acusa órfãos e a via morta convida a divergir da arte; C porque manteria a tabela de linhas fixas só para montar um texto — a fonte da verdade das linhas fixas passa a ser o JPEG, não a tabela.
**4. Modelo `minha-colinha` (D) — `assetSrc` é a arte; campos da composição antiga saem.**
**Opções:** A) `assetSrc: '/cards/modelo-colinha.jpeg'` e remover `overlaySrc`/`lockupSrc`/`previewSrc` da entrada (e o campo `lockupSrc` de `CardModel`, sem outro consumidor) | B) manter `assetSrc` antigo (foto do grupo) e carregar a arte via `previewSrc` | C) criar um campo novo (`artSrc`).
**Recomendação:** A — a arte é a base da prévia e do download; o tile continua mostrando a mesma imagem pelo fallback `previewSrc ?? assetSrc`; o composer genérico já carrega `assetSrc`; o campo `lockupSrc` fica órfão e sai junto (sem twinning).
**Rejeitadas:** B porque deixaria a arte real num campo de "galeria" e a prévia dependente de semântica de placeholder; C porque cria campo/parallel path para um só modelo ("edite o dono, não gêmeo").
**5. Gate sem estadual (E) — nenhum código novo.**
**Opções:** A) confiar no `canAdvance`/`disabled` existentes e provar o estado vazio com a arte intocada | B) adicionar flag/estado de download desabilitado ou esmaecer a prévia.
**Recomendação:** A — `canAdvance` já é `selectedDeputy !== null` para a colinha; o renderer com `deputy = null` desenha só a arte; o e2e pina CTA desabilitado + arte como está.
**Rejeitadas:** B porque duplicaria estado e o gate não esmaece a arte (a cena 01 mostra o JPEG pleno).
**6. Escala 1,2× e suavização (F) — estender o subset com os membros reais.**
**Opções:** A) `CardDrawContext` ganha `imageSmoothingEnabled`/`imageSmoothingQuality`; `renderColinhaCard` seta `true`/`'high'` antes de desenhar; o único fake de teste é atualizado | B) aceitar o default do browser (`'low'`) | C) setar a suavização no composer, antes de chamar o renderer.
**Recomendação:** A — o renderer é dono do seu requisito de composição; o subset ganha 2 membros estruturais (só um fake consome o tipo); nenhum renderer S13–S30 seta esses membros, então nada muda para eles.
**Rejeitadas:** B porque a ampliação 1,2× é o entregável — blur aparece no type pequeno da arte; C porque vaza um detalhe de desenho para o caller e deixa o comportamento fora do teste puro do renderer.
**7. Fit do nome (G) — uma linha, encolhe para caber na caixa de cópia.**
**Opções:** A) `fitColinhaRowName(texto, measure, maxWidth)` → `{ fontSize, text }` com `fontSize = min(ideal 36,72, ideal × maxWidth / width)`, uma linha (sem wrap), par rótulo+nome alinhado à direita em `copy.right`; `maxWidth = (copy.right − mask.x) − officeWidth − gap` (a faixa branca da máscara é a fronteira dura; o próprio CSS do gate deixa o par transbordar `copy.left`) | B) manter 36,72 fixo e deixar nomes longos vazarem para a esquerda sobre a arte | C) quebrar em duas linhas (comportamento S31) ou truncar.
**Recomendação:** A — garante "nome cabendo sem colidir com o rótulo nem com a borda do desenho" (por construção, `pairLeft ≥ mask.x`), mantém o par legível e o domínio é o catálogo congelado; a varredura unit das 53 entradas com medida conservadora prova o invariante e o e2e pina o pior nome (`arthur`).
**Rejeitadas:** B porque viola o aceite e desenha texto sobre pixels não mascarados da arte; C porque o design é `nowrap` (duas linhas colidem com as caixas/círculos na faixa de 55,68px) e truncar esconderia o nome do candidato.
### Componentes / mudanças
- **`src/lib/cardColinha.ts`** (reescrever; segue o dono da geometria da colinha): `COLINHA_FONT_FAMILY` (mantida), `COLINHA_DIGIT_FONT_FAMILY` (nova), `COLINHA_ESTADUAL_LABEL='DEPUTADO ESTADUAL'`, `COLINHA_ROW_LAYOUT` com os números do design em 1080×1920 — `mask { x:264,6, y:1073,28, width:619,92, height:55,68, fill:'#ffffff' }`; `copy { left:290,52, top:1084,03, width:570,24, right:860,76 }`; `office { fontSize:26,9, weight:400, color:'#202020' }` (o 2,15cqw=23,22 do gate foi corrigido na crítica (c) para o cap height 19,2px da arte); `name { fontSize:36,72, weight:900, color:'#e4102f', letterSpacingEm:-0,04 }`; `gap:16,2`; `digit { left:279,72, step:74,52, width:65,88, height:83,52, top:1121,28, fontSize:45,9, weight:900, color:'#171717' }` — e `fitColinhaRowName(text, measure, maxWidth)`. Baseline do par: `copy.top + measure('X', nameSize).actualBoundingBoxAscent` (cap do nome no topo da faixa; rótulo compartilha a baseline). `letterSpacingEm` vira `ctx.letterSpacing` em px (`-0,04 × fontSize`).
- **`src/lib/cardRender.ts`** (editar): `CardDrawContext` ganha `imageSmoothingEnabled`/`imageSmoothingQuality`; `ColinhaCardRenderArgs` vira `{ image, deputy, fontFamily, measure }`; `renderColinhaCard` reescrito — `drawImage(image, 0, 0, model.width, model.height)` → `deputy ? máscara + rótulo + nome + dígitos : nada`; removem-se `drawColinhaTop/Legal/Digit/Confirm`, `capAt` e os imports órfãos; `drawCardName`/`renderNameCard`/`renderTeamCard` intocados.
- **`src/lib/cardModels.ts`** (editar): entrada `minha-colinha` com `assetSrc:'/cards/modelo-colinha.jpeg'`, sem `overlaySrc`/`lockupSrc`/`previewSrc`; remove o campo `lockupSrc` de `CardModel`; doc-comments S31→arte exata. Sem migration (config estática no código).
- **`src/components/cards/CardComposer.tsx`** (editar): branch de desenho passa a exigir só `baseImage` e chama `renderColinhaCard(ctx, model, { image: baseImage, deputy: selectedDeputy, fontFamily: COLINHA_FONT_FAMILY, measure: createCardMeasure(ctx, COLINHA_FONT_FAMILY, 900) })`; remove o estado/carregamento de `lockupImage` e as deps; `Linha conferida` deriva `${COLINHA_ESTADUAL_LABEL} · ${selectedDeputy.name.toLocaleUpperCase('pt-BR')} · ${selectedDeputy.ballotNumber}` (sem `colinhaVoteRows`); prévias 248/236px (vazio, crítica (c)) e 300/405px (preenchido), com o estado preenchido desktop em grid de 2 colunas num dialog de 960px (crítica (c); `data-colinha-filled`); copy, CTA, `canAdvance` e o evento S32 (`sendCardDownloadEvent`) ficam como estão; `ensureCardFont`/carregamento compartilhado de fonte intocados (sem webfont nova; gatilho = Decisão 2/B).
- **Testes** (ver seção própria): `tests/unit/cardColinha.unit.spec.ts` (reescrever; manter o describe do tile), `tests/unit/cardRender.unit.spec.ts` (substituir o describe S31 + fake com os 2 membros novos), `tests/unit/cardModels.unit.spec.ts` (pin novo), `tests/e2e/frontend.e2e.spec.ts` (describe S34).
- **`docs/changelog/<YYYY-MM-DD>-s34-colinha-arte-exata.md`** (novo).
- **`scripts/lib/e2e-affected-manifest.mjs`**: **sem mudança** — `src/components/cards` + `src/lib/card` já mapeiam para o spec `frontend`.
- **Migration:** sem migration — nenhuma collection/global/field no schema; `push:false` intocado.
- **Access / Consent:** não se aplica — nenhuma superfície Payload, nenhum opt-in; Fluxo 100% client-side e a escolha não sai do aparelho.
- **UI:** Impeccable C — shape já fixado no gate (cenas 01–06); craft = port das medidas/classes; critique = prova lado a lado da cena 06 (linha fixa × preenchida) + pior nome; polish se a prova pedir. Shells/tokens reusados: `CardComposer`, `CardPreviewCanvas`, `StateDeputySelect`, `--pt-red`/`--campaign-*`; nada de superfície nova.
### Dados → forma (se aplicável)
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a "forma" é a arte + a linha. A escolha do estadual segue insumo local da imagem (nunca coletada/apresentada/enviada); a medição de uso é S32 e fica intocada.
## Fases verificáveis
1. **Base + escala (tracer)** — quota ~0,25 dia. `cardModels` com a arte como `assetSrc` (Decisão 4); `renderColinhaCard` desenhando só a arte em 1080×1920 com smoothing (Decisão 6); branch do composer enxuto; `COLINHA_ROW_LAYOUT` + `fitColinhaRowName` nascem.
Prova: `pnpm gate:fast`; `?model=minha-colinha` no browser com a prévia = JPEG intacto (rótulo queimado visível) e CTA desabilitado; probe do e2e do vazio atualizado (varredura da faixa com a arte intacta); unit do tile (sha256 + 900×1600) verde.
2. **Linha preenchida** — quota ~0,3 dia. Máscara + rótulo + nome (fit) + cinco dígitos na ordem do design; pins de geometria em `cardColinha`/`cardRender`; e2e do preenchido e do pior nome.
Prova: `pnpm gate:fast`; unit verdes; e2e do describe verde; prova lado a lado (cena 06) com JULIO PINHEIRO e com `Artur Barachisio Lisbôa` anexada ao PR.
3. **Vazio/gate + remoção do código morto** — quota ~0,2 dia. Estado vazio provado (arte + CTA off, Decisão 5); remoção de `COLINHA_LAYOUT`/`draw*`/`capAt`/`fitColinhaCandidate`/`COLINHA_FIXED_ROWS`/`colinhaVoteRows`/placeholder/legal/confirm; remoção de `lockupSrc` + estado/carregamento no composer; `Linha conferida` derivada (Decisão 3).
Prova: `pnpm gate:fast` (knip sem órfãos e `tsc --noEmit` limpo); grep de `COLINHA_LAYOUT|colinhaVoteRows|fitColinhaCandidate|drawColinha|lockupSrc` sem referências de produção; e2e do fluxo segue verde.
4. **Testes/gates + prova + changelog** — quota ~0,25 dia. e2e completo do describe (download/evento/troca), changelog, prova final no PR e `pnpm push`.
Prova: `pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Minha colinha"` verde; `pnpm push` (pre-push roda o gate de CI).
### Testes previstos por camada
- **unit (node + sharp) — `tests/unit/cardColinha.unit.spec.ts` (reescrever):** pins de `COLINHA_ROW_LAYOUT` (máscara/cópia/office/name/gap/dígito), stacks de fonte e `COLINHA_ESTADUAL_LABEL`; `fitColinhaRowName` (ideal mantém; nome longo encolhe em 1 linha; invariante `pairLeft ≥ mask.x` varrendo as 53 entradas do catálogo com medida conservadora); **manter** o describe do tile (sha256 `76505492…` + 900×1600).
- **unit (jsdom + recorder falso) — `tests/unit/cardRender.unit.spec.ts` (substituir o describe S31):** ordem arte→máscara→rótulo→nome→dígitos; `drawImage(arte, 0, 0, 1080, 1920)` com `imageSmoothingQuality='high'`; geometria da máscara (264,6; 1073,28; 619,92×55,68); rótulo e nome à direita (`textAlign 'right'`, x = `copy.right` e `copy.right − nameWidth − 16,2`); cinco dígitos centrados em 312,66/387,18/461,70/536,22/610,74 (y 1163,04); `deputy = null` desenha **só** a imagem (zero `fillText`/`fillRect`); `globalAlpha` = 1; `letterSpacing` restaurado a `0px`. O fake ganha `imageSmoothingEnabled`/`imageSmoothingQuality` e registra `fillStyle` por chamada.
- **unit — `tests/unit/cardModels.unit.spec.ts` (atualizar):** pin do modelo com `assetSrc:'/cards/modelo-colinha.jpeg'`, sem `overlaySrc`/`lockupSrc`/`previewSrc`, mantendo `kind:'colinha'`, `stateDeputyPicker`, `badge:'NOVO'` e `1080×1920`.
- **int:** não se aplica — sem Payload/DB/access/escrita multi-collection; fluxo client-side + assets estáticos (manifesto e2e também não muda).
- **e2e — `tests/e2e/frontend.e2e.spec.ts` (reescrever o describe S31 → S34):** vazio (CTA desabilitado; varredura da faixa do rótulo derivada de `COLINHA_ROW_LAYOUT` acha a tinta do rótulo queimado em `firstDarkX > 500`, sem tinta vermelha e com as caixas vazias); seleção por teclado (helper existente); preenchido (`firstDarkX` e `redFirstX` ≥ `mask.x` e `firstDarkX < 450` — rótulo reposto à esquerda e nome vermelho dentro da máscara — e o interior do círculo d1, região 34×34 no centro impresso, com tinta); `Linha conferida` literal; download `card-jorge-solla-minha-colinha.png` + assinatura PNG + evento S32 com slug; troca mantém habilitado; pior nome (`arthur`) com o par dentro da máscara. Sem pin de RGB exato (anti-aliasing de fonte varia) — contagem/luminância em regiões estáveis.
## Rabbit holes / Não escopo (engenharia)
- Redesenhar o topo/linhas fixas/legal/selos, "melhorar" a arte ou vetorizá-la (intenção, rabbit holes de produto).
- Editar/re-encodar o JPEG (`modelo-colinha.jpeg` é cópia byte-a-byte pinada; nunca tocar).
- Segunda via de composição (SVG/DOM/html2canvas) ou segundo editor/rota/`?estadual=`.
- Embarcar face tipográfica nova — gatilho: reprovação da prova lado a lado (Decisão 2/B).
- PDF/A4; nome/foto do visitante; qualquer medição da escolha (S32 intocado).
- CMS/collection/migration; ler `stateDeputy`/`Contact`; qualquer superfície Payload.
- Tocar em `StateDeputySelect`, `stateDeputyCatalog`, `CardModelTile`, `CardModelGallery`, `CardsStudio`, `CardPreviewCanvas`, `cardCanvas.ts` (só o `CardDrawContext` muda, em `cardRender.ts`).
- Fatiar o `CardComposer` (#1277, OPEN/blocked/P3): não iniciar aqui; se mergear antes, rebase (o branch toca só o branch da colinha).
## Riscos e mitigação
- **Fonte NEEDS ASSET (face exata não existe no repo):** stack do design (`Arial Black, Arial, Helvetica, sans-serif`) + prova lado a lado na cena 06 anexada ao PR (o e2e não pina pixels de glifo); gatilho: reprovação no gate → adotar a Decisão 2/B (embarcar face) como follow-up.
- **Pins antigos quebram (unit/render/models/e2e e o probe de pixel (426,1102)):** reescrever no mesmo PR; `pnpm gate:fast` (knip) + grep sem referências mortas; a remoção da via antiga é o teste de que não sobrou twin.
- **`#1277` no mesmo arquivo (OPEN/blocked/P3):** branch isolado no `if (isColinhaModel)`; rebase se mergear antes; não antecipar a fatia.
- **`CardDrawContext` compartilhado:** a extensão é aditiva; só a colinha seta `imageSmoothing*`; nenhum call site S13–S30 muda; o único fake ganha os 2 membros.
- **Nomes longos do catálogo:** fit de 1 linha sem floor (domínio = catálogo congelado); varredura das 53 entradas no unit + e2e com `arthur`; se um futuro catálogo trouxer nome absurdo, revisitar a Decisão 7 (não cortar o nome em silêncio).
- **Nitidez da ampliação 1,2×:** `imageSmoothingQuality='high'` + conferência do PNG baixado no PR/UAT; sem re-render/reescala parcial.
- **Baseline/top da linha vs design (sub-pixel):** baseline compartilhada ancorada no cap do nome em `copy.top`; a prova lado a lado da cena 06 é o aceite e só `copy.top` se move se divergir.
- **Probes e2e flaky:** regiões (não pixel único) + limiar de luminância; as áreas sondadas são branco puro na arte (conferido no JPEG: interior dos círculos = 255; faixa da máscara = 255).
- **Tile/`previewSrc` removido:** `CardModelTile` cai no fallback `assetSrc` — mesma imagem; unit pina e nenhum visual muda.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: prévia/download = JPEG aprovado ampliado 1,2× com só a linha `DEPUTADO ESTADUAL` preenchida (nome caixa alta + 5 dígitos, um por caixa); sem nada mais redesenhado; vazio = arte como está + download desabilitado; tipografia/peso/cor/linha de base da linha; tile segue o JPEG; um só fluxo (`#cards`, `/cards`, `?model=minha-colinha`); 100% no aparelho; S30/S32 intocados.
- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent; `src/lib` puro/client-safe; copy pt-BR e identificadores em inglês; sem `as never`; knip sem órfãos; arte intocada; `pnpm gate:fast` + e2e da superfície + `pnpm push` verdes.
- [ ] Testes de domínio previstos (unit/int): unit de `cardColinha` (geometria, fit, catálogo, tile), `cardRender` (ordem/geometria/vazio/smoothing) e `cardModels` (pin do modelo) obrigatórios; e2e do fluxo no spec `frontend` (vazio + CTA off, seleção, preenchido, pior nome, download com assinatura PNG e evento, troca); **int não se aplica** — sem Payload/DB/access nem escrita multi-collection.
## Self-score decision-quality: 5/5
1. **Decisões caras com rejeitadas:** composição (canvas × DOM/SVG), fonte (próxima × embarcar × peso só), código morto, modelo/campos, gate vazio, suavização/`CardDrawContext` e fit do nome — todas com Opções/Recomendação/Rejeitadas.
2. **Cabe no appetite:** 4 fases ~1 dia, sem migration/asset/dependência; o maior peso é reescrever testes que já existem.
3. **Rabbit holes nomeados:** redesenho/vetorização da arte, editar o JPEG, segunda via de composição/editor, embarcar fonte (com gatilho), PDF/A4, CMS, S30/S32, `#1277`.
4. **Depth check:** reusa o dono `renderColinhaCard`, o composer/seletor/catálogo/prévia/download existentes, o padrão de pins unit + e2e do estúdio e o manifesto e2e; nada de abstração nova — as constantes/funções novas substituem as antigas no mesmo dono (`cardColinha.ts`).
5. **Intenção preservada:** o outcome ("a arte exata + a linha do estadual preenchida") é intocado; a engenharia só resolveu forma (remover a recriação, repontar o modelo, desenhar o overlay no canvas).
## Addendum — crítica final do designer (c), 2026-09-24
Primeira passada: **AJUSTES NECESSÁRIOS**; segunda passada: **CERTIFICADO** (tier `openai/gpt-5.6-sol`). Ajustes aplicados sobre o plano original:
1. **Rótulo (P1):** `office.fontSize` 23,22 → **26,9px** (peso 400 mantido), cap height ≈ 19,2px igual aos rótulos impressos da arte; a exceção ao gate está documentada em `cardColinha.ts`.
2. **Prévia vazia (P2):** 145px → **248px mobile / 236px desktop** (`max-w-[15.5rem] sm:max-w-[14.75rem]`), cenas 01–02 do gate S34.
3. **Desktop preenchido (P2):** grid de 2 colunas (`sm:grid-cols-[430px_1fr]`, gap 32px) dentro de um dialog de 960px (`data-colinha-filled` + `has-[[...]]:sm:max-w-[60rem]` no `CardsStudio`); o vazio permanece no dialog compacto de 512px. A prévia tem teto de altura (`max-h-[calc(92dvh-14rem)]`) para o 9:16 inteiro nunca ser cortado pelo footer — ~340px de largura em viewport 1280×900, chegando aos 405px da cena 03 em viewports altos.
4. **Fit do nome:** a fronteira dura do par passou a ser a máscara (`maxWidth = (copy.right − mask.x) − officeWidth − gap`), como o CSS do gate faz; o e2e pina `firstDarkX`/`redFirstX ≥ mask.x` e o nome vermelho (não só o rótulo escuro).
## Débitos deferidos com gatilho (/simplify, 2026-09-24)
- **`imageSmoothing` sem `save/restore` no ctx compartilhado** (`cardRender.ts`): hoje inalcançável (cada modelo remonta o canvas); gatilho — o ctx passar a ser reusado entre desenhos/escalas no mesmo canvas.
- **Contrato `data-colinha-filled` → `has-[[…]]:sm:max-w-[60rem]` sem asserção no e2e:** só comentários nos dois lados; gatilho — próxima mudança no dialog da colinha/atributo (ou nova passada de e2e no estúdio).
- **Sweep de fit com medida sintética (0,84em/caractere):** o e2e cobre o pior nome (`arthur`) e o sweep é conservador; gatilho — entrada nova no catálogo mais larga que `arthur` ou queixa visual de corte.
- Absorvidos em `#1277` (`escala-dry-pos-s30.md`): D1 (uppercase duplicado) e D2 (`ensureCardFont` gateando a colinha).

View file

@ -48,6 +48,13 @@ A entrega S30 ([cards-estadual-dobradinha-impl.md](cards-estadual-dobradinha-imp
- **P8 — `identitySha256` redundante após o `toEqual`**: **descartar** — precedente idêntico no `municipalityCatalog`; o hash é o guard de regeneração do fixture.
- **P9 — `!` em `tests/unit/cardRender.unit.spec.ts`**: **descartar** — convenção do próprio arquivo (test-only, pré-existente).
## Absorvidos do S34 (2026-09-24)
Débitos que o /simplify do S34 ([cards-colinha-arte-exata-impl.md](cards-colinha-arte-exata-impl.md)) mandou para este lote (mesma superfície):
- **S34-D1 — `name.toLocaleUpperCase('pt-BR')` duplicado** entre a `Linha conferida` do `CardComposer` e o nome desenhado em `renderColinhaCard`: a derivação do texto do nome entra em **F2** (função pura no dono `cardColinha.ts`), consumida pelos dois.
- **S34-D2 — `ensureCardFont(fontFamily)` gateia o fluxo da colinha** (que desenha com o stack Arial, não com a display do site): o efeito de carga é reestruturado em **F1/F2**; hoje uma falha da fonte display derruba um fluxo que não a usa (pré-existente do S31).
## Rabbit holes / Não escopo
- Extrair o listbox compartilhado agora (P2) — três superfícies divergentes (admin/omnibox, chips de relação, estúdio público) e gatilho documentado; abstração prematura.

View file

@ -31,7 +31,7 @@ import { StateDeputySelect } from '@/components/cards/StateDeputySelect'
import { useCardCutout } from '@/components/cards/useCardCutout'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
import { COLINHA_FONT_FAMILY, colinhaVoteRows } from '@/lib/cardColinha'
import { COLINHA_ESTADUAL_LABEL, COLINHA_FONT_FAMILY } from '@/lib/cardColinha'
import { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels'
import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
import {
@ -90,12 +90,12 @@ const STATE_DEPUTY_PREVIEW_WIDTH: Record<StateDeputyPreviewStage, string> = {
}
/**
* S31 — the colinha stages of the design gate: compact while the estadual is
* unchosen (scene 2, 145px) and the scene 3/5 stage after the pick (405px
* desktop, 300px mobile).
* S31/S34 — the colinha stages of the design gate: the delivered art while the
* estadual is unchosen (scene 1: 236px desktop / scene 2: 248px mobile) and the
* scene 3/5 stage after the pick (405px desktop, 300px mobile).
*/
const COLINHA_PREVIEW_WIDTH: Record<'idle' | 'selected', string> = {
idle: 'max-w-[9.0625rem]',
idle: 'max-w-[15.5rem] sm:max-w-[14.75rem]',
selected: 'max-w-[18.75rem] sm:max-w-[25.3125rem]',
}
@ -194,8 +194,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null)
const [overlayImage, setOverlayImage] = useState<HTMLImageElement | null>(null)
/** S31 — the colinha brand lockup of the top box (`lockupSrc`). */
const [lockupImage, setLockupImage] = useState<HTMLImageElement | null>(null)
const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
@ -228,8 +226,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const selectedDeputy = selectedDeputySlug
? (getStateDeputyCard(selectedDeputySlug) ?? null)
: null
/** S31 — the estadual row owns the label/case of the `Linha conferida` copy. */
const estadualRow = colinhaVoteRows(selectedDeputy)[1]
const deputyPairReady = selectedDeputy !== null && deputyImages?.slug === selectedDeputy.slug
// S30 — the tone reference is the selected deputy's group art, frozen when the
// cutout starts (switching the deputy later keeps the processed photo).
@ -268,7 +264,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
setLoadState('loading')
setBaseImage(null)
setOverlayImage(null)
setLockupImage(null)
setPreviewImage(null)
const load = async () => {
@ -277,22 +272,18 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
if (!fontReady) throw new Error('card-font-unavailable')
// S30 — the dobradinha model loads its art pair per selection (below);
// the static defaults stay unused so no illustrative JULIO art flashes
// before the choice. S31 — the colinha composes the top from its three
// official assets and never draws the tile art (`previewSrc`).
const [image, overlay, lockup, preview] = await Promise.all([
// before the choice. S34 — the colinha draws the approved art as the
// base and carries no `previewSrc` (the tile falls back to `assetSrc`).
const [image, overlay, preview] = await Promise.all([
isStateDeputyModel ? Promise.resolve(null) : loadCardImage(model.assetSrc),
!isStateDeputyModel && model.overlaySrc
? loadCardImage(model.overlaySrc)
: Promise.resolve(null),
model.lockupSrc ? loadCardImage(model.lockupSrc) : Promise.resolve(null),
model.previewSrc && !isColinhaModel
? loadCardImage(model.previewSrc)
: Promise.resolve(null),
model.previewSrc ? loadCardImage(model.previewSrc) : Promise.resolve(null),
])
if (cancelled) return
setBaseImage(image)
setOverlayImage(overlay)
setLockupImage(lockup)
setPreviewImage(preview)
setLoadState('ready')
} catch {
@ -309,7 +300,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
isColinhaModel,
model.assetSrc,
model.overlaySrc,
model.lockupSrc,
model.previewSrc,
fontFamily,
])
@ -358,15 +348,12 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
if (isColinhaModel) {
// S31 — the slip: official top composition + legal + the six rows; the
// estadual row follows the shared selection and the fixed rows never
// change. No name, photo or cutout enters this flow.
if (!baseImage || !overlayImage || !lockupImage) return
// S34 — the approved art is the base; only the estadual row is drawn over
// it (or nothing, before a pick). No name, photo or cutout enters this flow.
if (!baseImage) return
ctx.clearRect(0, 0, model.width, model.height)
renderColinhaCard(ctx, model, {
group: baseImage,
lockup: lockupImage,
band: overlayImage,
image: baseImage,
deputy: selectedDeputy,
fontFamily: COLINHA_FONT_FAMILY,
measure: createCardMeasure(ctx, COLINHA_FONT_FAMILY, 900),
@ -467,7 +454,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}, [
baseImage,
overlayImage,
lockupImage,
previewImage,
loadState,
isNameModel,
@ -711,6 +697,20 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
: isTeamModel
? TEAM_PREVIEW_WIDTH[teamPreviewStage]
: 'max-w-[22rem]'
/**
* S34 designer critique (c) — the filled desktop state follows the gate's
* scene 3 composition: the full 9:16 preview beside the controls (two-column
* grid, wider dialog), never clipped by the footer. The empty state keeps the
* compact dialog of scene 1. The `data-colinha-filled` attribute below is the
* contract that widens the dialog in `CardsStudio` (`has-[[...]]`).
*/
const colinhaFilledDialog = isColinhaModel && shell === 'dialog' && selectedDeputy !== null
/**
* S34 — caps the colinha preview by the viewport so the whole 9:16 fits
* between the dialog chrome (`max-h-[92dvh]` in `CardsStudio` minus ~224px of
* header/footer); on tall viewports the width reaches the gate's 405px.
*/
const colinhaPreviewMaxHeight = 'max-h-[calc(92dvh-14rem)]'
const nameInputClassName = `mt-2 h-11 w-full rounded-lg bg-white px-3 text-base text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
nameError
@ -796,10 +796,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
// team notice leaves the viewport by the end of the scroll.
const bodyScrollClassName = `min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 ${
teamBodyNotice && shell === 'drawer' ? 'pb-14' : 'pb-5'
}`
}${colinhaFilledDialog ? ' sm:grid sm:grid-cols-[430px_1fr] sm:items-start sm:gap-8' : ''}`
return (
<div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)">
<div
data-colinha-filled={colinhaFilledDialog ? 'true' : undefined}
className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)"
>
<div className="flex items-start gap-3 px-5 pt-5">
{header}
<button
@ -828,7 +831,9 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
) : null}
<div
className={`relative mx-auto w-full ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}`}
className={`relative mx-auto w-full ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}${
colinhaFilledDialog ? ' sm:col-start-1 sm:row-start-1' : ''
}`}
tabIndex={adjustable ? 0 : undefined}
role={adjustable ? 'group' : undefined}
aria-label={adjustable ? 'Ajuste da foto' : undefined}
@ -854,7 +859,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
model={model}
canvasRef={canvasRef}
className={`mx-auto block h-auto w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${
isColinhaModel ? '' : 'max-h-[38dvh] '
isColinhaModel ? `${colinhaPreviewMaxHeight} ` : 'max-h-[38dvh] '
}${loadState === 'loading' ? 'opacity-60' : 'opacity-100'}`}
onPointerDown={adjustable ? handlePointerDown : undefined}
onPointerMove={adjustable ? handlePointerMove : undefined}
@ -886,13 +891,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
) : null}
{step === 'compose' && isColinhaModel ? (
<div>
<div className={colinhaFilledDialog ? 'sm:col-start-2 sm:row-start-1' : undefined}>
<StateDeputySelect selected={selectedDeputy} onSelect={handleSelectDeputy} />
{selectedDeputy ? (
<div className="mt-4 rounded-lg bg-white p-4 ring-1 ring-(--campaign-line)">
<p className="text-sm font-bold text-(--campaign-ink)">Linha conferida</p>
<p className="mt-1 text-sm text-(--campaign-muted)">
{estadualRow.officeLines.join(' ')} · {estadualRow.candidate} ·{' '}
{COLINHA_ESTADUAL_LABEL} · {selectedDeputy.name.toLocaleUpperCase('pt-BR')} ·{' '}
{selectedDeputy.ballotNumber}
</p>
</div>

View file

@ -6,12 +6,12 @@ import type { CardModel } from '@/lib/cardModels'
import { cn } from '@/lib/utils'
/**
* S13/S15/S31 — artwork preview of one card model, shared by the home section
* and the `/cards` gallery. The name tile reuses the empty master with a
* S13/S15/S31/S34 — artwork preview of one card model, shared by the home
* section and the `/cards` gallery. The name tile reuses the empty master with a
* `SEU NOME` preview drawn by the real composer pipeline (S14); the photo tiles
* show the official transparent overlay above a neutral photo slot; the team
* and colinha tiles show `previewSrc` (the filled example / the approved model
* art) with the `NOVO` badge.
* tiles show `previewSrc` (the filled example) and the colinha tile shows the
* approved art itself (`assetSrc`, S34), with the `NOVO` badge.
*/
export const CardModelTile = ({
model,

View file

@ -82,7 +82,10 @@ export const CardsStudio = ({
// `flex flex-col` (over the kit's `grid`) constrains the composer
// body so its scrollable area shrinks and the CTA footer stays
// pinned — required by the longer state-deputy selector (S30).
className="flex max-h-[92dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg sm:p-0"
// S34: the filled colinha state widens the dialog for the gate's
// scene 3 two-column composition (the empty state stays compact);
// `data-colinha-filled` is set by the composer (see CardComposer).
className="flex max-h-[92dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg sm:p-0 has-[[data-colinha-filled]]:sm:max-w-[60rem]"
>
<CardComposer
model={selectedModel}

View file

@ -1,204 +1,78 @@
/**
* S31 — the pure layout of the `Minha colinha` voting slip: the six vote rows
* (five fixed + the state deputy picked from the S30 catalog) and the pixel
* geometry of the approved design gate (`docs/plans/cards-colinha-ui-design.html`)
* measured at the real 1080×1920 output. No DOM and no drawing here — the
* renderer (`renderColinhaCard` in `cardRender.ts`) consumes these constants,
* so the model stays unit-testable with a deterministic fake.
*
* The gate is the canonical source of the numbers (the human-approved model
* `modelo-colinha.jpeg` is the conference reference and the gallery tile).
* S34 — the drawn overlay of the `Minha colinha` voting slip. The approved art
* (`public/cards/modelo-colinha.jpeg`, the byte-for-byte 900×1600 file the human
* delivered) is the base of the preview and the PNG; the only drawn element is
* the state-deputy row (2nd line of the art) — the burned label wiped by a white
* mask, the label redrawn at the left, the picked name at the right and one
* digit per printed box. The geometry is the approved design gate
* (`docs/plans/cards-colinha-arte-exata-ui-design.html`) measured at the real
* 1080×1920 output (the exact 1.2× of the art); the label size was corrected by
* the designer critique (c) to the art's printed cap height. No DOM and no
* drawing here — the renderer (`renderColinhaCard` in `cardRender.ts`) consumes
* these constants, so the model stays unit-testable with a deterministic fake.
*/
import type { StateDeputyCatalogEntry } from './stateDeputyCatalog'
export type ColinhaRow = {
/** Office label lines, e.g. `['DEPUTADO', 'FEDERAL']` (already uppercase). */
readonly officeLines: readonly string[]
/** Candidate display name: Title Case on the fixed rows, uppercase on the estadual. */
readonly candidate: string
/** One glyph per digit box, in reading order. */
readonly digits: readonly string[]
/** The estadual row before a deputy is picked: empty boxes + a dimmed CONFIRMA. */
readonly empty?: boolean
export type ColinhaRowNameFit = {
readonly fontSize: number
readonly text: string
}
/** The five fixed choices of the slip, in the gate's order (estadual goes second). */
export const COLINHA_FIXED_ROWS: readonly ColinhaRow[] = [
{ officeLines: ['DEPUTADO', 'FEDERAL'], candidate: 'Jorge Solla', digits: ['1', '3', '1', '3'] },
{ officeLines: ['SENADOR'], candidate: 'Jaques Wagner', digits: ['1', '3', '0'] },
{ officeLines: ['SENADOR'], candidate: 'Rui Costa', digits: ['1', '3', '3'] },
{ officeLines: ['GOVERNADOR'], candidate: 'Jerônimo', digits: ['1', '3'] },
{ officeLines: ['PRESIDENTE'], candidate: 'Lula', digits: ['1', '3'] },
]
const COLINHA_ESTADUAL_OFFICE_LINES = ['DEPUTADO', 'ESTADUAL'] as const
export const COLINHA_ESTADUAL_PLACEHOLDER = 'Escolha abaixo'
const COLINHA_ESTADUAL_DIGIT_COUNT = 5
/**
* S31 — the slip typography of the approved gate (`Arial, Helvetica, sans-serif`,
* the gate's card body face), not the site display family the rest of the
* studio draws with. The designer's critique (c) fixed this as gate parity.
* S34 — the slip typography of the approved gate: the body face of the art
* (label at weight 400, name at 900) and the digit face (`Arial Black` first).
* The exact heavy face of the art is not shipped in the repo (the gate marks it
* NEEDS ASSET); the port proves the closest face side by side and the gate
* decides composition and geometry.
*/
export const COLINHA_FONT_FAMILY = 'Arial, Helvetica, sans-serif'
export const COLINHA_DIGIT_FONT_FAMILY = 'Arial Black, Arial, Helvetica, sans-serif'
/** The vertical legal line of the left edge (literal of the intent). */
export const COLINHA_LEGAL_TEXT =
'FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL (PT-PC DO B - PV) | CNPJ CANDIDATO: 68.430.467/0001-05'
/** The green seal every row carries. */
export const COLINHA_CONFIRM_LABEL = 'CONFIRMA'
/** The 2nd line of the art: the office label redrawn over the white mask. */
export const COLINHA_ESTADUAL_LABEL = 'DEPUTADO ESTADUAL'
/**
* The six rows in the gate's order: federal, the estadual (filled from the
* catalog entry, or the empty placeholder), then the remaining four fixed rows.
* S34 — pixel geometry at the 1080×1920 output, measured from the approved
* design gate (the gate's percentages resolved against the 1080×1920 stage).
* The mask wipes the burned label of the art and stops at the top of the five
* printed boxes; `copy` is the right-aligned pair band and the name baseline is
* anchored on the cap top of the name.
*/
export const colinhaVoteRows = (deputy: StateDeputyCatalogEntry | null): ColinhaRow[] => {
const estadual: ColinhaRow = deputy
? {
officeLines: COLINHA_ESTADUAL_OFFICE_LINES,
candidate: deputy.name.toLocaleUpperCase('pt-BR'),
digits: [...deputy.ballotNumber],
}
: {
officeLines: COLINHA_ESTADUAL_OFFICE_LINES,
candidate: COLINHA_ESTADUAL_PLACEHOLDER,
digits: Array.from({ length: COLINHA_ESTADUAL_DIGIT_COUNT }, () => ''),
empty: true,
}
return [COLINHA_FIXED_ROWS[0], estadual, ...COLINHA_FIXED_ROWS.slice(1)]
}
/**
* Pixel geometry at the 1080×1920 output, measured from the rendered gate. The
* top composes the official assets (group photo, brand box, band); the body is
* the measured CSS grid of the gate (`30% | 1fr | auto`, rows of 108).
*/
export const COLINHA_LAYOUT = {
background: '#ffffff',
top: {
height: 710.4,
background: '#148fc2',
photoWidth: 1080,
photoHeight: 1440,
photoOffsetY: -335.62,
lockup: {
x: 32.4,
y: 21.29,
width: 529.19,
height: 134.97,
padding: 27,
gradientAngleDeg: 125,
gradientSplit: 0.52,
gradientFrom: '#e4102f',
gradientTo: '#184e92',
imageWidth: 1037,
imageHeight: 595,
},
band: {
y: 504.39,
height: 206,
background: '#e4102f',
sourceY: 1232.36,
imageWidth: 1080,
imageHeight: 1440,
},
},
legal: {
x: 7.55,
y: 748.79,
width: 15.66,
height: 1132.81,
fontSize: 15.66,
letterSpacing: 0.31,
color: '#333333',
},
body: {
x: 51.83,
y: 710.39,
width: 1028.17,
height: 1209.61,
paddingTop: 34.56,
paddingX: 35.64,
paddingBottom: 32.4,
rowGap: 34.56,
},
row: {
minHeight: 108,
officeWidthRatio: 0.3,
columnGap: 21.6,
},
office: {
fontSize: 27,
lineHeight: 27.54,
color: '#171717',
},
candidate: {
fontSize: 26.46,
minFontSize: 18,
color: '#e4102f',
gap: 9.6,
},
export const COLINHA_ROW_LAYOUT = {
mask: { x: 264.6, y: 1073.28, width: 619.92, height: 55.68, fill: '#ffffff' },
copy: { left: 290.52, top: 1084.03, width: 570.24, right: 860.76 },
// The design gate's 2.15cqw (23.22px) sat below the art's printed labels;
// the designer critique (c) set 26.9px so the cap height matches the 19.2px
// of the fixed rows at weight 400.
office: { fontSize: 26.9, weight: 400, color: '#202020' },
name: { fontSize: 36.72, weight: 900, color: '#e4102f', letterSpacingEm: -0.04 },
gap: 16.2,
digit: {
width: 59.39,
height: 73.44,
borderWidth: 5.94,
radius: 6.48,
fontSize: 46.44,
gap: 8.1,
borderColor: '#111111',
color: '#141414',
background: '#ffffff',
emptyBorderColor: '#9a9a9a',
emptyBackground: '#fafafa',
},
confirm: {
fontSize: 21.6,
paddingX: 21.6,
paddingY: 17.28,
background: '#009647',
color: '#ffffff',
emptyOpacity: 0.3,
left: 279.72,
step: 74.52,
width: 65.88,
height: 83.52,
top: 1121.28,
fontSize: 45.9,
weight: 900,
color: '#171717',
},
} as const
type ColinhaCandidateFit = {
readonly fontSize: number
readonly lines: readonly string[]
}
/**
* S31 — the candidate shrinks inside the office column down to `minFontSize`
* (the gate's CSS would wrap freely, but the body keeps its fixed row rhythm).
* A name still too long at the floor wraps by word into two lines: the row
* grows to ~101 of its 108 and the six rows never reflow.
* S34 — the picked name shrinks on one line to fit the white mask band left of
* the label (`maxWidth` already discounts the label and the gap), so the pair
* never leaves the mask and the name is never cut nor wrapped. The gate's own
* CSS keeps the pair right-aligned and lets it use the whole band (the declared
* `copy` box is narrower than a long pair).
*/
export const fitColinhaCandidate = (
export const fitColinhaRowName = (
text: string,
measure: (text: string, fontSize: number) => { width: number },
maxWidth: number,
): ColinhaCandidateFit => {
const { fontSize: ideal, minFontSize } = COLINHA_LAYOUT.candidate
): ColinhaRowNameFit => {
const ideal = COLINHA_ROW_LAYOUT.name.fontSize
const width = measure(text, ideal).width
if (width <= maxWidth || width <= 0) return { fontSize: ideal, lines: [text] }
if (width <= maxWidth || width <= 0) return { fontSize: ideal, text }
const shrunk = Math.floor((ideal * maxWidth) / width)
if (shrunk >= minFontSize) return { fontSize: shrunk, lines: [text] }
const lines: string[] = []
let current = ''
for (const word of text.split(' ')) {
const candidate = current ? `${current} ${word}` : word
if (current && measure(candidate, minFontSize).width > maxWidth) {
lines.push(current)
current = word
} else {
current = candidate
}
}
if (current) lines.push(current)
return { fontSize: minFontSize, lines: lines.length > 1 ? lines : [text] }
return { fontSize: Math.floor((ideal * maxWidth) / width), text }
}

View file

@ -52,14 +52,9 @@ export type CardModel = {
photoPosition?: CardPhotoPosition
/** Team model: the master front overlay drawn above the cutout photo. */
overlaySrc?: string
/**
* S31 — colinha model: the brand lockup of the top block, loaded besides the
* `assetSrc` (group photo) and `overlaySrc` (band art) of the composition.
*/
lockupSrc?: string
/**
* Gallery tile art: the filled example shown by the team models before a
* cutout is ready, and the approved model art of the colinha tile.
* cutout is ready.
*/
previewSrc?: string
/**
@ -133,13 +128,10 @@ export const CARD_MODELS: readonly CardModel[] = [
id: 'minha-colinha',
kind: 'colinha',
label: 'Minha colinha',
// S31 — the top block composes the official kit (group photo + brand box +
// band); `previewSrc` is the exact approved model art of the gallery tile
// (byte-for-byte copy) and `lockupSrc` is the brand drawn in the top box.
assetSrc: '/cards/team-card-base.png',
overlaySrc: '/cards/team-card-front.png',
lockupSrc: '/campaign-kit/marca-negativa-completa.png',
previewSrc: '/cards/modelo-colinha.jpeg',
// S34 — the approved art delivered by the human is the base of the preview
// and the download (drawn whole at 1.2×); the only drawn element is the
// estadual row overlay. The gallery tile shows the same art via `assetSrc`.
assetSrc: '/cards/modelo-colinha.jpeg',
stateDeputyPicker: true,
badge: 'NOVO',
width: 1080,

View file

@ -8,11 +8,10 @@
*/
import {
COLINHA_CONFIRM_LABEL,
COLINHA_LAYOUT,
COLINHA_LEGAL_TEXT,
colinhaVoteRows,
fitColinhaCandidate,
COLINHA_DIGIT_FONT_FAMILY,
COLINHA_ESTADUAL_LABEL,
COLINHA_ROW_LAYOUT,
fitColinhaRowName,
} from './cardColinha'
import {
BRAND_FALLBACK_FONT,
@ -47,6 +46,8 @@ export type CardDrawContext = {
fillStyle: string | CanvasGradient | CanvasPattern
textAlign: CanvasTextAlign
textBaseline: CanvasTextBaseline
imageSmoothingEnabled: boolean
imageSmoothingQuality: ImageSmoothingQuality
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void
fillRect(x: number, y: number, width: number, height: number): void
fillText(text: string, x: number, y: number): void
@ -354,256 +355,74 @@ export const renderTeamCard = (
}
export type ColinhaCardRenderArgs = {
/** Group photo of the top block (`team-card-base.png`). */
group: CanvasImageSource
/** Brand lockup of the top-left box (`marca-negativa-completa.png`). */
lockup: CanvasImageSource
/** Front art cropped to the red band (`team-card-front.png`). */
band: CanvasImageSource
/** The chosen state deputy; `null` draws the empty estadual row. */
/** The approved art (`/cards/modelo-colinha.jpeg`), drawn whole at 1.2×. */
image: CanvasImageSource
/** The chosen state deputy; `null` leaves the art exactly as delivered. */
deputy: StateDeputyCatalogEntry | null
fontFamily: string
/** The 900-weight measure the colinha draws with. */
/** The 900-weight measure the estadual name is fitted and placed with. */
measure: CardMeasureText
}
/** The cap top of the drawn font at `fontSize` (baseline placement of the rows). */
const capAt = (measure: CardMeasureText, fontSize: number): number =>
measure('X', fontSize).actualBoundingBoxAscent
/**
* S31 — the top block of the colinha: the group photo cover-cropped, the
* diagonal two-color brand box with the contained lockup, and the red band
* cropped from the official front art. Every rect comes from `COLINHA_LAYOUT`
* (measured from the approved gate).
*/
const drawColinhaTop = (
ctx: CardDrawContext,
model: CardModel,
args: Pick<ColinhaCardRenderArgs, 'group' | 'lockup' | 'band'>,
): void => {
const { top } = COLINHA_LAYOUT
const { lockup, band } = top
ctx.save()
ctx.beginPath()
ctx.rect(0, 0, model.width, top.height)
ctx.clip()
ctx.fillStyle = top.background
ctx.fillRect(0, 0, model.width, top.height)
ctx.drawImage(args.group, 0, top.photoOffsetY, top.photoWidth, top.photoHeight)
// CSS `linear-gradient(125deg, …)`: the gradient line passes through the box
// center with the CSS length `|w·sinθ| + |h·cosθ|`.
const angle = (lockup.gradientAngleDeg * Math.PI) / 180
const dirX = Math.sin(angle)
const dirY = -Math.cos(angle)
const gradientLength = Math.abs(lockup.width * dirX) + Math.abs(lockup.height * dirY)
const centerX = lockup.x + lockup.width / 2
const centerY = lockup.y + lockup.height / 2
const gradient = ctx.createLinearGradient(
centerX - (dirX * gradientLength) / 2,
centerY - (dirY * gradientLength) / 2,
centerX + (dirX * gradientLength) / 2,
centerY + (dirY * gradientLength) / 2,
)
gradient.addColorStop(0, lockup.gradientFrom)
gradient.addColorStop(lockup.gradientSplit, lockup.gradientFrom)
gradient.addColorStop(lockup.gradientSplit, lockup.gradientTo)
gradient.addColorStop(1, lockup.gradientTo)
ctx.fillStyle = gradient
ctx.fillRect(lockup.x, lockup.y, lockup.width, lockup.height)
// `object-fit: contain` inside the box padding (the gate's CSS resolves the
// percentage padding against the top block, so it is already in pixels here).
const innerX = lockup.x + lockup.padding
const innerY = lockup.y + lockup.padding
const innerWidth = lockup.width - lockup.padding * 2
const innerHeight = lockup.height - lockup.padding * 2
const lockupScale = Math.min(innerWidth / lockup.imageWidth, innerHeight / lockup.imageHeight)
const lockupWidth = lockup.imageWidth * lockupScale
const lockupHeight = lockup.imageHeight * lockupScale
ctx.drawImage(
args.lockup,
innerX + (innerWidth - lockupWidth) / 2,
innerY + (innerHeight - lockupHeight) / 2,
lockupWidth,
lockupHeight,
)
ctx.save()
ctx.beginPath()
ctx.rect(0, band.y, model.width, band.height)
ctx.clip()
ctx.fillStyle = band.background
ctx.fillRect(0, band.y, model.width, band.height)
ctx.drawImage(args.band, 0, band.y - band.sourceY, band.imageWidth, band.imageHeight)
ctx.restore()
ctx.restore()
}
/** S31 — the vertical legal line of the left edge (reads bottom-to-top). */
const drawColinhaLegal = (ctx: CardDrawContext, fontFamily: string): void => {
const { legal } = COLINHA_LAYOUT
ctx.save()
ctx.translate(legal.x + legal.width / 2, legal.y + legal.height)
ctx.rotate(-Math.PI / 2)
ctx.font = `700 ${legal.fontSize}px ${fontFamily}`
ctx.letterSpacing = `${legal.letterSpacing}px`
ctx.fillStyle = legal.color
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.fillText(COLINHA_LEGAL_TEXT, 0, 0)
ctx.restore()
}
/** S31 — one digit box: the border is the outer rounded rect, the fill the inner one. */
const drawColinhaDigit = (
ctx: CardDrawContext,
x: number,
y: number,
empty: boolean,
digit: string,
fontFamily: string,
): void => {
const { digit: box } = COLINHA_LAYOUT
ctx.fillStyle = empty ? box.emptyBorderColor : box.borderColor
ctx.beginPath()
ctx.roundRect(x, y, box.width, box.height, box.radius)
ctx.fill()
ctx.fillStyle = empty ? box.emptyBackground : box.background
ctx.beginPath()
ctx.roundRect(
x + box.borderWidth,
y + box.borderWidth,
box.width - box.borderWidth * 2,
box.height - box.borderWidth * 2,
box.radius - box.borderWidth,
)
ctx.fill()
if (empty || !digit) return
ctx.fillStyle = box.color
ctx.font = `900 ${box.fontSize}px ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(digit, x + box.width / 2, y + box.height / 2)
}
/** S31 — the green `CONFIRMA` pill, right-aligned on the row. */
const drawColinhaConfirm = (
ctx: CardDrawContext,
x: number,
y: number,
height: number,
empty: boolean,
fontFamily: string,
measure: CardMeasureText,
): void => {
const { confirm } = COLINHA_LAYOUT
const width = measure(COLINHA_CONFIRM_LABEL, confirm.fontSize).width + confirm.paddingX * 2
const previousAlpha = ctx.globalAlpha
if (empty) ctx.globalAlpha = confirm.emptyOpacity
ctx.fillStyle = confirm.background
ctx.beginPath()
ctx.roundRect(x - width, y, width, height, height / 2)
ctx.fill()
ctx.fillStyle = confirm.color
ctx.font = `900 ${confirm.fontSize}px ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(COLINHA_CONFIRM_LABEL, x - width / 2, y + height / 2)
ctx.globalAlpha = previousAlpha
}
/**
* S31 — the `Minha colinha` slip: white base → top composition (official
* assets) → vertical legal line → the six vote rows of the gate's grid
* (`30% | 1fr | auto`, rows of 108, body centered). The estadual row comes from
* the S30 catalog entry (or the empty placeholder); the five fixed rows are
* template content and never change.
* S34 — the `Minha colinha` slip: the approved art drawn whole (the 900×1600
* JPEG scaled 1.2× to the 1080×1920 output) and, only when a state deputy is
* picked, the 2nd line: the burned label wiped by the white mask, the label
* redrawn at the left, the name at the right (right-aligned pair) and one digit
* per printed box. Without a pick not a single pixel is added — the art is the
* empty state.
*/
export const renderColinhaCard = (
ctx: CardDrawContext,
model: CardModel,
args: ColinhaCardRenderArgs,
): void => {
const { body, row: rowLayout, office, candidate, digit, confirm } = COLINHA_LAYOUT
const rows = colinhaVoteRows(args.deputy)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(args.image, 0, 0, model.width, model.height)
ctx.fillStyle = COLINHA_LAYOUT.background
ctx.fillRect(0, 0, model.width, model.height)
const { deputy } = args
if (!deputy) return
drawColinhaTop(ctx, model, args)
drawColinhaLegal(ctx, args.fontFamily)
const { mask, copy, office, name, gap, digit } = COLINHA_ROW_LAYOUT
const nameText = deputy.name.toLocaleUpperCase('pt-BR')
const contentX = body.x + body.paddingX
const contentWidth = body.width - body.paddingX * 2
const contentTop = body.y + body.paddingTop
const contentHeight = body.height - body.paddingTop - body.paddingBottom
const rowsHeight = rows.length * rowLayout.minHeight + (rows.length - 1) * body.rowGap
const startY = contentTop + (contentHeight - rowsHeight) / 2
const officeWidth = contentWidth * rowLayout.officeWidthRatio
const officeCap = capAt(args.measure, office.fontSize)
const confirmHeight = confirm.fontSize + confirm.paddingY * 2
// The label keeps its own face/weight; the pair (label + gap + name) is
// right-aligned on `copy.right` and may use the whole white mask band, so the
// name only shrinks when it would leave the mask — the gate's own CSS does
// the same (its `nowrap` pair overflows the declared copy width to the left).
ctx.font = `${office.weight} ${office.fontSize}px ${args.fontFamily}`
const officeWidth = ctx.measureText(COLINHA_ESTADUAL_LABEL).width
const nameFit = fitColinhaRowName(nameText, args.measure, copy.right - mask.x - officeWidth - gap)
const baseline = copy.top + args.measure('X', nameFit.fontSize).actualBoundingBoxAscent
// The label sits just left of the drawn name: measure with the same tracking.
ctx.letterSpacing = `${name.letterSpacingEm * nameFit.fontSize}px`
const nameWidth = args.measure(nameFit.text, nameFit.fontSize).width
ctx.letterSpacing = '0px'
rows.forEach((voteRow, index) => {
const rowY = startY + index * (rowLayout.minHeight + body.rowGap)
const candidateFit = fitColinhaCandidate(voteRow.candidate, args.measure, officeWidth)
const candidateCap = capAt(args.measure, candidateFit.fontSize)
const officeHeight = voteRow.officeLines.length * office.lineHeight
const blockHeight =
officeHeight + candidate.gap + candidateFit.lines.length * candidateFit.fontSize
const blockTop = rowY + (rowLayout.minHeight - blockHeight) / 2
ctx.fillStyle = mask.fill
ctx.fillRect(mask.x, mask.y, mask.width, mask.height)
ctx.fillStyle = office.color
ctx.textAlign = 'left'
ctx.textBaseline = 'alphabetic'
ctx.font = `900 ${office.fontSize}px ${args.fontFamily}`
voteRow.officeLines.forEach((line, lineIndex) => {
ctx.fillText(line, contentX, blockTop + lineIndex * office.lineHeight + officeCap)
})
ctx.fillStyle = office.color
ctx.font = `${office.weight} ${office.fontSize}px ${args.fontFamily}`
ctx.textAlign = 'right'
ctx.textBaseline = 'alphabetic'
ctx.letterSpacing = '0px'
ctx.fillText(COLINHA_ESTADUAL_LABEL, copy.right - nameWidth - gap, baseline)
ctx.fillStyle = candidate.color
ctx.font = `900 ${candidateFit.fontSize}px ${args.fontFamily}`
candidateFit.lines.forEach((line, lineIndex) => {
ctx.fillText(
line,
contentX,
blockTop + officeHeight + candidate.gap + candidateCap + lineIndex * candidateFit.fontSize,
)
})
ctx.fillStyle = name.color
ctx.font = `${name.weight} ${nameFit.fontSize}px ${args.fontFamily}`
ctx.letterSpacing = `${name.letterSpacingEm * nameFit.fontSize}px`
ctx.fillText(nameFit.text, copy.right, baseline)
ctx.letterSpacing = '0px'
const boxY = rowY + (rowLayout.minHeight - digit.height) / 2
voteRow.digits.forEach((glyph, digitIndex) => {
drawColinhaDigit(
ctx,
contentX + officeWidth + rowLayout.columnGap + digitIndex * (digit.width + digit.gap),
boxY,
voteRow.empty === true,
glyph,
args.fontFamily,
)
})
drawColinhaConfirm(
ctx,
contentX + contentWidth,
rowY + (rowLayout.minHeight - confirmHeight) / 2,
confirmHeight,
voteRow.empty === true,
args.fontFamily,
args.measure,
)
ctx.fillStyle = digit.color
ctx.font = `${digit.weight} ${digit.fontSize}px ${COLINHA_DIGIT_FONT_FAMILY}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const centerY = digit.top + digit.height / 2
const digits = [...deputy.ballotNumber]
digits.forEach((glyph, index) => {
ctx.fillText(glyph, digit.left + digit.width / 2 + index * digit.step, centerY)
})
}

View file

@ -1,6 +1,7 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { COLINHA_ROW_LAYOUT } from '@/lib/cardColinha'
import { TEAM_CARD_NAME_BANNER } from '@/lib/cardModels'
import { adminHeaders } from '../helpers/adminApi'
@ -2374,22 +2375,52 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => {
})
})
test.describe('Cards personalizados (S31 — Minha colinha)', () => {
test.describe('Cards personalizados (S34 — Minha colinha)', () => {
/**
* Canvas probe at 1080×1920: the center of the estadual row's first digit box
* — content x (51.83 + 35.64) + the 30% office column (287.07) + the 21.6 gap
* + half box = 396.14 + 29.7 ≈ 426; row 1 (estadual) starts at 905.88 + 142.56
* and its box center is +17.28 + 36.72 ≈ 1102.
* Canvas probe at 1080×1920 over the estadual row, derived from the layout the
* renderer draws with: the label band reports the first dark-ink column and
* the first red column (the delivered art burns the label at x 571–856; the
* filled state wipes it and redraws the pair from the mask's left edge), and
* the first printed box interior reports glyph ink.
*/
const colinhaBoxPixel = (canvas: Locator) =>
canvas.evaluate((element) => {
const node = element as HTMLCanvasElement
return [...node.getContext('2d')!.getImageData(426, 1102, 1, 1).data]
})
const colinhaRowProbe = (canvas: Locator) =>
canvas.evaluate((element, layout) => {
const ctx = (element as HTMLCanvasElement).getContext('2d')!
const { mask, copy, digit } = layout
const bandX = Math.round(mask.x)
const bandY = Math.round(copy.top)
const bandWidth = Math.round(copy.right) - bandX
const bandHeight = Math.round(mask.y + mask.height) - bandY
const band = ctx.getImageData(bandX, bandY, bandWidth, bandHeight).data
let firstDarkX = -1
let redFirstX = -1
for (let x = 0; x < bandWidth && (firstDarkX < 0 || redFirstX < 0); x += 1) {
for (let y = 0; y < bandHeight; y += 1) {
const offset = (y * bandWidth + x) * 4
const r = band[offset]!
const g = band[offset + 1]!
const b = band[offset + 2]!
if (firstDarkX < 0 && r + g + b < 240) firstDarkX = bandX + x
if (redFirstX < 0 && r > 150 && g < 100 && b < 100) redFirstX = bandX + x
}
}
const boxX = Math.round(digit.left + digit.width / 2 - 17)
const boxY = Math.round(digit.top + digit.height / 2 - 17)
const box = ctx.getImageData(boxX, boxY, 34, 34).data
let boxInk = 0
for (let i = 0; i < box.length; i += 4) {
if (box[i]! + box[i + 1]! + box[i + 2]! < 240) boxInk += 1
}
return { firstDarkX, redFirstX, boxInk }
}, COLINHA_ROW_LAYOUT)
test('picks the estadual, fills the slip row and downloads the 1080×1920 PNG', async ({
page,
}) => {
const maskLeft = Math.round(COLINHA_ROW_LAYOUT.mask.x)
const filledRowProbe = async (canvas: Locator) => {
const { firstDarkX, redFirstX, boxInk } = await colinhaRowProbe(canvas)
return firstDarkX >= maskLeft && firstDarkX < 450 && redFirstX >= maskLeft && boxInk > 0
}
test('shows the delivered art with the download disabled before the pick', async ({ page }) => {
await page.goto('/cards?model=minha-colinha')
const dialog = page.getByRole('dialog')
@ -2403,11 +2434,26 @@ test.describe('Cards personalizados (S31 — Minha colinha)', () => {
await expect(canvas).toHaveAttribute('width', '1080')
await expect(canvas).toHaveAttribute('height', '1920')
// The empty estadual row: five empty boxes (#fafafa), no glyph ink.
await expect.poll(() => colinhaBoxPixel(canvas)).toEqual([250, 250, 250, 255])
// The art as delivered: the burned label ink stays at the right, no red
// name and the printed boxes are empty.
await expect
.poll(async () => {
const { firstDarkX, redFirstX, boxInk } = await colinhaRowProbe(canvas)
return firstDarkX > 500 && redFirstX < 0 && boxInk === 0
})
.toBe(true)
await expect(dialog.getByRole('button', { name: 'Baixar minha colinha' })).toBeDisabled()
})
test('picks the estadual, fills the slip row and downloads the 1080×1920 PNG', async ({
page,
}) => {
await page.goto('/cards?model=minha-colinha')
const dialog = page.getByRole('dialog')
const canvas = dialog.locator('canvas')
const primary = dialog.getByRole('button', { name: 'Baixar minha colinha' })
await expect(primary).toBeDisabled()
await pickJulioByKeyboard(dialog)
@ -2424,8 +2470,10 @@ test.describe('Cards personalizados (S31 — Minha colinha)', () => {
).toBeVisible()
await expect(primary).toBeEnabled()
// The picked row is filled: the first box carries the `1` glyph ink.
await expect.poll(() => colinhaBoxPixel(canvas)).toEqual([20, 20, 20, 255])
// The picked row: the burned label is wiped and the redrawn pair (dark
// label + red name) starts at the mask's left edge; the first printed box
// carries the `1` glyph ink.
await expect.poll(() => filledRowProbe(canvas)).toBe(true)
const [download, downloadEvent] = await Promise.all([
page.waitForEvent('download'),
@ -2449,6 +2497,20 @@ test.describe('Cards personalizados (S31 — Minha colinha)', () => {
).toBe(true)
})
test('shrinks the longest catalog name inside the mask', async ({ page }) => {
await page.goto('/cards?model=minha-colinha')
const dialog = page.getByRole('dialog')
const search = dialog.getByRole('combobox', { name: 'Buscar estadual' })
await search.fill('arthur')
await search.press('Enter')
const trigger = dialog.getByRole('button', { name: /Seu estadual/ })
await expect(trigger).toContainText('Artur Barachisio Lisbôa')
// The shrunk red name still starts inside the mask (never over the art).
await expect.poll(() => filledRowProbe(dialog.locator('canvas'))).toBe(true)
})
test('switching the estadual keeps the slip ready for download', async ({ page }) => {
await page.goto('/cards?model=minha-colinha')

View file

@ -8,14 +8,13 @@ import sharp from 'sharp'
import { describe, expect, it } from 'vitest'
import {
COLINHA_ESTADUAL_PLACEHOLDER,
COLINHA_FIXED_ROWS,
COLINHA_LAYOUT,
COLINHA_LEGAL_TEXT,
colinhaVoteRows,
fitColinhaCandidate,
COLINHA_DIGIT_FONT_FAMILY,
COLINHA_ESTADUAL_LABEL,
COLINHA_FONT_FAMILY,
COLINHA_ROW_LAYOUT,
fitColinhaRowName,
} from '@/lib/cardColinha'
import { getStateDeputyCard } from '@/lib/stateDeputyCatalog'
import { stateDeputyCatalog } from '@/lib/stateDeputyCatalog'
const publicFile = (src: string) => fileURLToPath(new URL(`../../public${src}`, import.meta.url))
@ -26,160 +25,127 @@ const APPROVED_TILE_SRC = fileURLToPath(
/** Frozen identity of the approved model art (byte-for-byte copy of the tile). */
const TILE_SHA256 = '76505492fb243c67313d237e58926afbd4d0847c00ab3bb43c090859cbdf18c8'
describe('colinha vote rows (S31)', () => {
it('keeps the five fixed choices in the gate order and literals', () => {
expect(
COLINHA_FIXED_ROWS.map((row) => [
row.officeLines.join(' '),
row.candidate,
row.digits.join(''),
]),
).toEqual([
['DEPUTADO FEDERAL', 'Jorge Solla', '1313'],
['SENADOR', 'Jaques Wagner', '130'],
['SENADOR', 'Rui Costa', '133'],
['GOVERNADOR', 'Jerônimo', '13'],
['PRESIDENTE', 'Lula', '13'],
])
describe('colinha estadual row layout (S34 — measured from the design gate)', () => {
it('resolves the gate percentages against the 1080×1920 output (the 1.2× of the art)', () => {
const { mask, copy, office, name, gap, digit } = COLINHA_ROW_LAYOUT
expect(mask.fill).toBe('#ffffff')
expect(mask.x).toBeCloseTo(0.245 * 1080, 5)
expect(mask.y).toBeCloseTo(0.559 * 1920, 5)
expect(mask.width).toBeCloseTo(0.574 * 1080, 5)
expect(mask.height).toBeCloseTo(0.029 * 1920, 5)
expect(copy.left).toBeCloseTo(0.269 * 1080, 5)
expect(copy.top).toBeCloseTo(0.5646 * 1920, 2)
expect(copy.width).toBeCloseTo(0.528 * 1080, 5)
expect(copy.right).toBeCloseTo(copy.left + copy.width, 5)
// The label is sized to the art's printed cap height (19.2px), not to the
// design gate's 2.15cqw — designer critique (c) adjustment.
expect(office.fontSize).toBeCloseTo(26.9, 5)
expect(name.fontSize).toBeCloseTo(0.034 * 1080, 5)
expect(gap).toBeCloseTo(0.015 * 1080, 5)
expect(digit.left).toBeCloseTo(0.259 * 1080, 5)
expect(digit.step).toBeCloseTo(0.069 * 1080, 5)
expect(digit.width).toBeCloseTo(0.061 * 1080, 5)
expect(digit.height).toBeCloseTo(0.0435 * 1920, 5)
expect(digit.top).toBeCloseTo(0.584 * 1920, 5)
expect(digit.fontSize).toBeCloseTo(0.0425 * 1080, 5)
})
it('builds the six rows with the estadual second, filled from the catalog entry', () => {
const rows = colinhaVoteRows(getStateDeputyCard('julio')!)
expect(rows).toHaveLength(6)
expect(rows[1]).toMatchObject({
officeLines: ['DEPUTADO', 'ESTADUAL'],
candidate: 'JULIO PINHEIRO',
digits: ['1', '3', '9', '9', '9'],
it('pins the mask, the copy band and the row typography of the gate', () => {
expect(COLINHA_ROW_LAYOUT.mask).toEqual({
x: 264.6,
y: 1073.28,
width: 619.92,
height: 55.68,
fill: '#ffffff',
})
expect(rows[1]!.empty).toBeUndefined()
expect(rows[0]).toEqual(COLINHA_FIXED_ROWS[0])
expect(rows.slice(2)).toEqual(COLINHA_FIXED_ROWS.slice(1))
})
it('marks the estadual row empty (placeholder + five empty boxes) without a pick', () => {
const rows = colinhaVoteRows(null)
expect(rows).toHaveLength(6)
expect(rows[1]).toMatchObject({
officeLines: ['DEPUTADO', 'ESTADUAL'],
candidate: COLINHA_ESTADUAL_PLACEHOLDER,
digits: ['', '', '', '', ''],
empty: true,
expect(COLINHA_ROW_LAYOUT.copy).toEqual({
left: 290.52,
top: 1084.03,
width: 570.24,
right: 860.76,
})
expect(rows.slice(2)).toEqual(COLINHA_FIXED_ROWS.slice(1))
})
it('uppercases the picked deputy display name (gate literal JULIO PINHEIRO)', () => {
expect(colinhaVoteRows(getStateDeputyCard('leninha')!)[1]!.candidate).toBe('LENÍNHA VALENTE')
expect(colinhaVoteRows(getStateDeputyCard('adriana')!)[1]!.candidate).toBe(
'COLETIVO DE ENFERMAGEM',
)
})
})
describe('colinha layout (S31 — measured from the design gate at 1080×1920)', () => {
it('pins the top composition geometry', () => {
expect(COLINHA_LAYOUT.top).toMatchObject({
height: 710.4,
background: '#148fc2',
photoWidth: 1080,
photoHeight: 1440,
photoOffsetY: -335.62,
expect(COLINHA_ROW_LAYOUT.office).toEqual({
fontSize: 26.9,
weight: 400,
color: '#202020',
})
expect(COLINHA_LAYOUT.top.lockup).toMatchObject({
x: 32.4,
y: 21.29,
width: 529.19,
height: 134.97,
gradientAngleDeg: 125,
gradientSplit: 0.52,
gradientFrom: '#e4102f',
gradientTo: '#184e92',
imageWidth: 1037,
imageHeight: 595,
expect(COLINHA_ROW_LAYOUT.name).toEqual({
fontSize: 36.72,
weight: 900,
color: '#e4102f',
letterSpacingEm: -0.04,
})
expect(COLINHA_LAYOUT.top.band).toMatchObject({
y: 504.39,
height: 206,
background: '#e4102f',
sourceY: 1232.36,
})
})
it('pins the legal line, body and row rhythm of the gate', () => {
expect(COLINHA_LAYOUT.legal).toMatchObject({
x: 7.55,
y: 748.79,
width: 15.66,
height: 1132.81,
fontSize: 15.66,
color: '#333333',
})
expect(COLINHA_LAYOUT.body).toMatchObject({
x: 51.83,
y: 710.39,
width: 1028.17,
height: 1209.61,
paddingTop: 34.56,
paddingX: 35.64,
paddingBottom: 32.4,
rowGap: 34.56,
})
expect(COLINHA_LAYOUT.row).toMatchObject({
minHeight: 108,
officeWidthRatio: 0.3,
columnGap: 21.6,
})
// 30% of the body content is the office column (gate measured 287.07).
expect(
(COLINHA_LAYOUT.body.width - COLINHA_LAYOUT.body.paddingX * 2) *
COLINHA_LAYOUT.row.officeWidthRatio,
).toBeCloseTo(287.07, 2)
})
it('pins the digit boxes, the candidate and the CONFIRMA pill', () => {
expect(COLINHA_LAYOUT.office).toMatchObject({
fontSize: 27,
lineHeight: 27.54,
expect(COLINHA_ROW_LAYOUT.digit).toEqual({
left: 279.72,
step: 74.52,
width: 65.88,
height: 83.52,
top: 1121.28,
fontSize: 45.9,
weight: 900,
color: '#171717',
})
expect(COLINHA_LAYOUT.candidate).toMatchObject({
fontSize: 26.46,
minFontSize: 18,
color: '#e4102f',
gap: 9.6,
})
expect(COLINHA_LAYOUT.digit).toMatchObject({
width: 59.39,
height: 73.44,
borderWidth: 5.94,
radius: 6.48,
fontSize: 46.44,
gap: 8.1,
borderColor: '#111111',
background: '#ffffff',
emptyBorderColor: '#9a9a9a',
emptyBackground: '#fafafa',
})
expect(COLINHA_LAYOUT.confirm).toMatchObject({
fontSize: 21.6,
paddingX: 21.6,
paddingY: 17.28,
background: '#009647',
color: '#ffffff',
emptyOpacity: 0.3,
})
})
it('keeps the legal literal of the intent (federation + candidate CNPJ)', () => {
expect(COLINHA_LEGAL_TEXT).toBe(
'FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL (PT-PC DO B - PV) | CNPJ CANDIDATO: 68.430.467/0001-05',
)
it('keeps the label literal and the gate font stacks (NEEDS ASSET proof on the PR)', () => {
expect(COLINHA_ESTADUAL_LABEL).toBe('DEPUTADO ESTADUAL')
expect(COLINHA_FONT_FAMILY).toBe('Arial, Helvetica, sans-serif')
expect(COLINHA_DIGIT_FONT_FAMILY).toBe('Arial Black, Arial, Helvetica, sans-serif')
})
})
describe('colinha tile asset (S31)', () => {
describe('fitColinhaRowName (S34)', () => {
// Same conservative face as the render unit fake: 0.84em per char, above the
// real Arial/Arial Black uppercase average.
const measure = (text: string, fontSize: number) => ({ width: text.length * fontSize * 0.84 })
const officeWidth = measure(COLINHA_ESTADUAL_LABEL, COLINHA_ROW_LAYOUT.office.fontSize).width
const pairMaxWidth = COLINHA_ROW_LAYOUT.copy.right - COLINHA_ROW_LAYOUT.mask.x
const nameMaxWidth = pairMaxWidth - officeWidth - COLINHA_ROW_LAYOUT.gap
it('keeps the ideal size on one line while the name fits the mask band', () => {
expect(fitColinhaRowName('JACÓ', measure, nameMaxWidth)).toEqual({
fontSize: COLINHA_ROW_LAYOUT.name.fontSize,
text: 'JACÓ',
})
})
it('shrinks the canonical name on one line instead of wrapping or cutting it', () => {
const fit = fitColinhaRowName('JULIO PINHEIRO', measure, nameMaxWidth)
expect(fit.fontSize).toBeLessThan(COLINHA_ROW_LAYOUT.name.fontSize)
expect(fit.text).toBe('JULIO PINHEIRO')
expect(measure(fit.text, fit.fontSize).width).toBeLessThanOrEqual(nameMaxWidth)
})
it('shrinks a long name on one line instead of wrapping or cutting it', () => {
const fit = fitColinhaRowName('ARTUR BARACHISIO LISBÔA', measure, nameMaxWidth)
expect(fit.fontSize).toBeLessThan(COLINHA_ROW_LAYOUT.name.fontSize)
expect(fit.text).toBe('ARTUR BARACHISIO LISBÔA')
expect(measure(fit.text, fit.fontSize).width).toBeLessThanOrEqual(nameMaxWidth)
})
it('keeps the right-aligned pair (label + gap + name) inside the mask for the whole catalog', () => {
for (const deputy of stateDeputyCatalog) {
const fit = fitColinhaRowName(deputy.name.toLocaleUpperCase('pt-BR'), measure, nameMaxWidth)
const pairWidth = officeWidth + COLINHA_ROW_LAYOUT.gap + measure(fit.text, fit.fontSize).width
expect(fit.fontSize).toBeGreaterThan(0)
expect(fit.fontSize).toBeLessThanOrEqual(COLINHA_ROW_LAYOUT.name.fontSize)
expect(pairWidth).toBeLessThanOrEqual(pairMaxWidth)
// The pair starts at or after the mask's left edge.
expect(COLINHA_ROW_LAYOUT.copy.right - pairWidth).toBeGreaterThanOrEqual(
COLINHA_ROW_LAYOUT.mask.x,
)
}
})
})
describe('colinha tile asset (S34)', () => {
it('ships the exact approved model art (byte-for-byte, 900×1600)', async () => {
const shipped = readFileSync(publicFile('/cards/modelo-colinha.jpeg'))
const approved = readFileSync(APPROVED_TILE_SRC)
@ -193,39 +159,3 @@ describe('colinha tile asset (S31)', () => {
expect(`${metadata.width}x${metadata.height}`).toBe('900x1600')
})
})
describe('fitColinhaCandidate (S31)', () => {
const measure = (text: string, fontSize: number) => ({ width: text.length * fontSize * 0.84 })
const officeColumn = 287.07
it('keeps the ideal size while the name fits the office column', () => {
expect(fitColinhaCandidate('Lula', measure, officeColumn)).toEqual({
fontSize: COLINHA_LAYOUT.candidate.fontSize,
lines: ['Lula'],
})
})
it('shrinks a long name on one line instead of wrapping (fixed row rhythm)', () => {
const fit = fitColinhaCandidate('Julio Pinheiro', measure, officeColumn)
expect(fit.fontSize).toBeLessThan(COLINHA_LAYOUT.candidate.fontSize)
expect(fit.fontSize).toBe(24)
expect(fit.lines).toEqual(['Julio Pinheiro'])
})
it('wraps by word into two lines at the floor when shrinking is not enough', () => {
const fit = fitColinhaCandidate('Marlene do Sindicato', measure, officeColumn)
expect(fit.fontSize).toBe(COLINHA_LAYOUT.candidate.minFontSize)
expect(fit.lines).toEqual(['Marlene do', 'Sindicato'])
})
it('wraps the longest catalog names into two lines that fit the column', () => {
const fit = fitColinhaCandidate('COLETIVO DE ENFERMAGEM', measure, officeColumn)
expect(fit.lines).toEqual(['COLETIVO DE', 'ENFERMAGEM'])
for (const line of fit.lines) {
expect(measure(line, fit.fontSize).width).toBeLessThanOrEqual(officeColumn)
}
})
})

View file

@ -110,20 +110,19 @@ describe('card model catalog (S13/S15)', () => {
expect(getCardModel('minha-colinha')?.photoPosition).toBeUndefined()
})
it('pins the S31 colinha model: new kind, official top assets, picker flag and the approved tile', () => {
it('pins the S34 colinha model: the approved art as base, picker flag and no composed assets', () => {
expect(getCardModel('minha-colinha')).toMatchObject({
kind: 'colinha',
label: 'Minha colinha',
assetSrc: '/cards/team-card-base.png',
overlaySrc: '/cards/team-card-front.png',
lockupSrc: '/campaign-kit/marca-negativa-completa.png',
previewSrc: '/cards/modelo-colinha.jpeg',
assetSrc: '/cards/modelo-colinha.jpeg',
stateDeputyPicker: true,
badge: 'NOVO',
width: 1080,
height: 1920,
})
expect(getCardModel('minha-colinha')?.photoWindow).toBeUndefined()
expect(getCardModel('minha-colinha')?.overlaySrc).toBeUndefined()
expect(getCardModel('minha-colinha')?.previewSrc).toBeUndefined()
// The `NOVO` badge marks the newest model only (design gate scene 1).
expect(getCardModel('time-do-estadual')?.badge).toBeUndefined()
})

View file

@ -1,11 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
COLINHA_CONFIRM_LABEL,
COLINHA_ESTADUAL_PLACEHOLDER,
COLINHA_LAYOUT,
COLINHA_LEGAL_TEXT,
} from '@/lib/cardColinha'
import { COLINHA_ESTADUAL_LABEL, COLINHA_ROW_LAYOUT } from '@/lib/cardColinha'
import {
NAME_CARD_SLOT,
TEAM_CARD_LABEL,
@ -36,6 +31,7 @@ import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDep
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
type TextCall = { text: string; x: number; y: number }
type TextState = { align: CanvasTextAlign; fill: string; font: string; letterSpacing: string }
type RectCall = { x: number; y: number; width: number; height: number }
type OpCall = { op: string; args: number[] }
@ -47,6 +43,7 @@ const fontSizeFrom = (font: string): number => {
const createFakeContext = () => {
const drawCalls: DrawCall[] = []
const textCalls: TextCall[] = []
const textStates: TextState[] = []
const rectCalls: RectCall[] = []
const ops: OpCall[] = []
@ -57,6 +54,8 @@ const createFakeContext = () => {
fillStyle: '',
textAlign: 'start',
textBaseline: 'alphabetic',
imageSmoothingEnabled: false,
imageSmoothingQuality: 'low',
drawImage: (image, dx, dy, dw, dh) => {
drawCalls.push({ image, dx, dy, dw, dh })
},
@ -65,6 +64,12 @@ const createFakeContext = () => {
},
fillText: (text, x, y) => {
textCalls.push({ text, x, y })
textStates.push({
align: ctx.textAlign,
fill: typeof ctx.fillStyle === 'string' ? ctx.fillStyle : '',
font: ctx.font,
letterSpacing: ctx.letterSpacing,
})
},
measureText: (text) => {
const size = fontSizeFrom(ctx.font)
@ -120,7 +125,7 @@ const createFakeContext = () => {
},
}
return { ctx, drawCalls, textCalls, rectCalls, ops }
return { ctx, drawCalls, textCalls, textStates, rectCalls, ops }
}
const nameModel = getCardModel('eu-sou-solla')!
@ -477,19 +482,15 @@ describe('renderPhotoCard', () => {
})
})
describe('renderColinhaCard (S31)', () => {
describe('renderColinhaCard (S34)', () => {
const colinhaModel = getCardModel('minha-colinha')!
const group = { id: 'group' } as unknown as CanvasImageSource
const lockup = { id: 'lockup' } as unknown as CanvasImageSource
const band = { id: 'band' } as unknown as CanvasImageSource
const art = { id: 'art' } as unknown as CanvasImageSource
const deputy = getStateDeputyCard('julio')!
const render = (chosen: StateDeputyCatalogEntry | null) => {
const fake = createFakeContext()
renderColinhaCard(fake.ctx, colinhaModel, {
group,
lockup,
band,
image: art,
deputy: chosen,
fontFamily: 'Brexter',
measure: createCardMeasure(fake.ctx, 'Brexter', 900),
@ -498,104 +499,89 @@ describe('renderColinhaCard (S31)', () => {
return fake
}
it('draws the white slip, the composed top and the legal line before the rows', () => {
const { ctx, drawCalls, rectCalls, textCalls, ops } = render(deputy)
// The fake face is conservative (0.84em per char): JULIO PINHEIRO shrinks
// from 36.72 to floor(36.72 × 195.83 / 431.83) = 16 to fit the mask band.
const nameFontSize = 16
const baseline = COLINHA_ROW_LAYOUT.copy.top + nameFontSize * 0.72
const nameWidth = 'JULIO PINHEIRO'.length * nameFontSize * 0.84
expect(rectCalls[0]).toEqual({ x: 0, y: 0, width: 1080, height: 1920 })
expect(drawCalls.map((call) => call.image)).toEqual([group, lockup, band])
expect(drawCalls[0]).toEqual({ image: group, dx: 0, dy: -335.62, dw: 1080, dh: 1440 })
expect(drawCalls[1]!.dx).toBeCloseTo(226.44, 1)
expect(drawCalls[1]!.dy).toBeCloseTo(48.29, 2)
expect(drawCalls[1]!.dw).toBeCloseTo(141.12, 1)
expect(drawCalls[1]!.dh).toBeCloseTo(80.97, 1)
expect(drawCalls[2]!.dy).toBeCloseTo(
COLINHA_LAYOUT.top.band.y - COLINHA_LAYOUT.top.band.sourceY,
it('draws the approved art whole at 1080×1920 with high smoothing', () => {
const { ctx, drawCalls, ops } = render(deputy)
expect(drawCalls).toEqual([{ image: art, dx: 0, dy: 0, dw: 1080, dh: 1920 }])
expect(ctx.imageSmoothingEnabled).toBe(true)
expect(ctx.imageSmoothingQuality).toBe('high')
// None of the S31 composition survives: no clip/save/gradient/roundRect.
expect(ops).toHaveLength(0)
})
it('leaves the art exactly as delivered before a pick (no text, no rect)', () => {
const { drawCalls, rectCalls, textCalls, ops } = render(null)
expect(drawCalls).toEqual([{ image: art, dx: 0, dy: 0, dw: 1080, dh: 1920 }])
expect(rectCalls).toHaveLength(0)
expect(textCalls).toHaveLength(0)
expect(ops).toHaveLength(0)
})
it('fills the estadual line: mask, label at the left, name at the right and five digits', () => {
const { ctx, rectCalls, textCalls, textStates } = render(deputy)
expect(rectCalls).toEqual([
{
x: COLINHA_ROW_LAYOUT.mask.x,
y: COLINHA_ROW_LAYOUT.mask.y,
width: COLINHA_ROW_LAYOUT.mask.width,
height: COLINHA_ROW_LAYOUT.mask.height,
},
])
expect(textCalls.map((call) => call.text)).toEqual([
COLINHA_ESTADUAL_LABEL,
'JULIO PINHEIRO',
'1',
'3',
'9',
'9',
'9',
])
const label = textCalls[0]!
expect(label.x).toBeCloseTo(
COLINHA_ROW_LAYOUT.copy.right - nameWidth - COLINHA_ROW_LAYOUT.gap,
5,
)
expect(label.y).toBeCloseTo(baseline, 5)
expect(textStates[0]).toMatchObject({
align: 'right',
fill: COLINHA_ROW_LAYOUT.office.color,
font: `400 ${COLINHA_ROW_LAYOUT.office.fontSize}px Brexter`,
letterSpacing: '0px',
})
// Top clip + band clip; one save/restore pair per clipped composition and the legal line.
expect(ops.filter((op) => op.op === 'clip')).toHaveLength(2)
expect(ops.filter((op) => op.op === 'save')).toHaveLength(3)
expect(ops.filter((op) => op.op === 'restore')).toHaveLength(3)
// CSS 125° gradient with the 52% hard stop: red 0→52, blue 52→100.
expect(ops.filter((op) => op.op === 'addColorStop').map((op) => op.args[0])).toEqual([
0, 0.52, 0.52, 1,
])
expect(textCalls[0]).toEqual({ text: COLINHA_LEGAL_TEXT, x: 0, y: 0 })
const name = textCalls[1]!
expect(name.x).toBeCloseTo(COLINHA_ROW_LAYOUT.copy.right, 5)
expect(name.y).toBeCloseTo(baseline, 5)
expect(textStates[1]).toMatchObject({
align: 'right',
fill: COLINHA_ROW_LAYOUT.name.color,
font: `900 ${nameFontSize}px Brexter`,
letterSpacing: `${COLINHA_ROW_LAYOUT.name.letterSpacingEm * nameFontSize}px`,
})
const { digit } = COLINHA_ROW_LAYOUT
const centerY = digit.top + digit.height / 2
textCalls.slice(2).forEach((call, index) => {
expect(call.x).toBeCloseTo(digit.left + digit.width / 2 + index * digit.step, 5)
expect(call.y).toBeCloseTo(centerY, 5)
expect(textStates[2 + index]).toMatchObject({
align: 'center',
fill: digit.color,
font: `900 ${digit.fontSize}px Arial Black, Arial, Helvetica, sans-serif`,
})
})
// The tracking set for the name never leaks to the next paint.
expect(ctx.letterSpacing).toBe('0px')
expect(ctx.globalAlpha).toBe(1)
})
it('draws the six rows with one glyph per box and the filled estadual from the catalog', () => {
const { ctx, textCalls, ops } = render(deputy)
const candidates = [
'Jorge Solla',
'JULIO PINHEIRO',
'Jaques Wagner',
'Rui Costa',
'Jerônimo',
'Lula',
]
for (const candidate of candidates) {
expect(textCalls.some((call) => call.text === candidate)).toBe(true)
}
// One glyph per box, in row order: 4 + 5 + 3 + 3 + 2 + 2.
expect(textCalls.filter((call) => call.text.length === 1).map((call) => call.text)).toEqual([
'1',
'3',
'1',
'3',
'1',
'3',
'9',
'9',
'9',
'1',
'3',
'0',
'1',
'3',
'3',
'1',
'3',
'1',
'3',
])
expect(textCalls.filter((call) => call.text === COLINHA_CONFIRM_LABEL)).toHaveLength(6)
// 19 digit boxes (border + fill pair) + 6 single-fill pills.
expect(ops.filter((op) => op.op === 'roundRect')).toHaveLength(19 * 2 + 6)
// First box: content x (51.83 + 35.64) + the 30% office column + the 21.6 gap.
const firstBox = ops.find((op) => op.op === 'roundRect')!
expect(firstBox.args[0]).toBeCloseTo(396.14, 1)
expect(firstBox.args[1]).toBeCloseTo(923.16, 1)
expect(ctx.globalAlpha).toBe(1)
})
it('draws the empty estadual row: placeholder, five empty boxes and no glyph there', () => {
const { textCalls, ops } = render(null)
expect(textCalls.some((call) => call.text === COLINHA_ESTADUAL_PLACEHOLDER)).toBe(true)
expect(textCalls.filter((call) => call.text.length === 1).map((call) => call.text)).toEqual([
'1',
'3',
'1',
'3',
'1',
'3',
'0',
'1',
'3',
'3',
'1',
'3',
'1',
'3',
])
expect(textCalls.filter((call) => call.text === COLINHA_CONFIRM_LABEL)).toHaveLength(6)
// The five empty boxes are still drawn (19 boxes total) + the 6 pills.
expect(ops.filter((op) => op.op === 'roundRect')).toHaveLength(19 * 2 + 6)
})
})