mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S34): colinha usa a arte exata com a linha do estadual preenchida (Closes #1298)
This commit is contained in:
parent
9069d23d88
commit
2c8b38f2be
14 changed files with 624 additions and 784 deletions
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-24-s34-colinha-arte-exata.md
Normal file
1
docs/changelog/2026-09-24-s34-colinha-arte-exata.md
Normal 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`.
|
||||
162
docs/plans/cards-colinha-arte-exata-impl.md
Normal file
162
docs/plans/cards-colinha-arte-exata-impl.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue