feat(S31): colinha de voto — 6º modelo com o estadual escolhido (Closes #1272)

This commit is contained in:
Francisco Solla 2026-09-23 12:08:48 -03:00
parent e72bfe8f8f
commit d1886c9480
17 changed files with 1388 additions and 84 deletions

View file

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

View file

@ -0,0 +1 @@
**S31 — "Minha colinha": o estadual escolhido na colinha de voto, sexto modelo do estúdio (2026-09-23):** o funil de cards (`#cards` da home + `/cards` + `?model=minha-colinha`) ganha a colinha de voto no MESMO estúdio e sem segundo editor: o visitante escolhe um estadual na MESMA lista de S30 (`stateDeputyCatalog`, 53 entradas; `StateDeputySelect` reusado intocado) e baixa um PNG 1080×1920 (9:16) com a linha do escolhido preenchida — nome de urna em caixa alta e os cinco dígitos, um por caixa — em volta das cinco escolhas fixas (`DEPUTADO FEDERAL Jorge Solla 1313`, `SENADOR Jaques Wagner 130`, `SENADOR Rui Costa 133`, `GOVERNADOR Jerônimo 13`, `PRESIDENTE Lula 13`), cada linha com o selo verde `CONFIRMA`. O render novo é uma composição canvas pura portada classe-a-classe do gate em 1080×1920: `src/lib/cardColinha.ts` guarda a geometria `COLINHA_*` medida no browser (topo de 710,4 com a foto do grupo em cover, caixa da marca no gradiente 125° `#e4102f`→`#184e92` com a `marca-negativa-completa`, faixa vermelha recortada de `team-card-front`, linha legal vertical de 15,66 e o corpo `30% | 1fr | auto` com linhas de 108) + a tabela das cinco linhas fixas + `colinhaVoteRows(deputy)` (estadual preenchido ou vazio com cinco caixas e `CONFIRMA` esmaecido) + `fitColinhaCandidate` (encolhe em uma linha e, no piso de 18, quebra em duas por palavra — o ritmo fixo das seis linhas nunca reflui); `renderColinhaCard` mora no dono único (`src/lib/cardRender.ts`, cujo `CardDrawContext` ganhou membros reais `roundRect`/`clip`/`createLinearGradient`/`letterSpacing`/`globalAlpha` e um peso opcional no `createCardMeasure`, com S13–S30 intocados). O `CardComposer` ganhou o branch `kind:'colinha'` de passo único — CTA `Baixar minha colinha` baixa direto da prévia compartilhada, `Cancelar` + primário no dialog e só o primário full-width no drawer (cenas 2/5 do gate) — com os literais aprovados (`Escolha seu estadual` / `Pronta para levar com você` / `Pronta para baixar`, aviso de escolha, caixa `Linha conferida`, nota nova `A escolha é processada neste aparelho…`) e prévias compacta (145) e grande (405/300). A tipografia do slip é a do gate (`Arial, Helvetica, sans-serif` via `COLINHA_FONT_FAMILY`, no desenho e na medição — não a display da campanha usada no resto do estúdio) e a descrição da colinha vive só no dialog (o cabeçalho do drawer mobile segue a cena 5, com o seletor na primeira viewport); os dois ajustes saíram da crítica final do `designer` (1ª passada DEGRADED, 2ª certificada). O tile usa o arquivo exato entregue pelo humano copiado byte-a-byte (`public/cards/modelo-colinha.jpeg`, 900×1600, sha256 pinado); a galeria passa a seis tiles numa fileira (`lg:grid-cols-6`), seis dots no mobile e a dica "Deslize para ver os seis modelos"; o badge `NOVO` passa para o modelo novo e o `?model=`/`isCardModelId` cobrem o id de graça. Sem migration, collection, CMS, Consent ou upload: a escolha não sai do aparelho e nada do visitante é solicitado. Verificação: unit (`cardColinha` com pins de geometria/linhas/fit e sha do tile, `cardModels` com o 6º modelo e o badge movido, `renderColinhaCard` no recorder falso com `roundRect`/clip/gradiente), `pnpm gate:fast` e e2e do `frontend` (6 tiles + 6 dots, seleção por busca/teclado, prévia vazia com CTA desabilitado e probe de pixel, linha preenchida, download real `card-jorge-solla-minha-colinha.png` com assinatura PNG e troca do estadual).

View file

@ -0,0 +1,203 @@
# Impl: Minha colinha — o estadual escolhido na colinha de voto
Status: aprovado (modo --auto)
Atualizado em: 2026-09-23
Issue: #1272
Intenção: docs/plans/cards-colinha.md
Appetite restante: herdado — ~1–1,5 dia eng (3 fases somam ≤1,3 dia; sem migration; o único peso novo é 1 asset copiado byte-a-byte)
## Leitura da intenção
- **Outcome:** o estúdio de cards ganha o sexto modelo `Minha colinha`: o visitante escolhe um estadual na MESMA lista de S30 e recebe uma colinha 9:16 (PNG 1080×1920) com o topo aprovado (foto do grupo + lockup + faixa `JORGE SOLLA 1313` e as marcas menores), a linha legal vertical na borda esquerda e seis linhas de voto — cinco fixas (`DEPUTADO FEDERAL Jorge Solla 1313`, `SENADOR Jaques Wagner 130`, `SENADOR Rui Costa 133`, `GOVERNADOR Jerônimo 13`, `PRESIDENTE Lula 13`) e a do estadual preenchida com nome de urna + cinco dígitos, um por caixa — cada uma com o selo verde `CONFIRMA`.
- **O que NÃO negociar:**
- 100% no aparelho: sem nome do visitante, sem foto, sem upload, sem persistência/analytics; os cinco modelos existentes intocados além do badge.
- Um único editor/fluxo: sexto item do MESMO `CardsStudio`/`CardComposer`; `#cards`, `/cards` e `?model=minha-colinha` continuam.
- Catálogo de estaduais = o de S30 (`src/lib/stateDeputyCatalog.ts`, 53 entradas): um estadual, uma linha; sem escolha não há download; o seletor S30 é reusado sem tocar na superfície entregue.
- Fidelidade ao gate: topo, linha legal vertical na borda esquerda e corpo de linhas seguem `docs/plans/cards-colinha-ui-design.html` (cenas 1–5).
- Escolha por toque e teclado; sem página/rota por colinha; a nota de privacidade precisa permanecer verdadeira (a existente fala de nome/foto).
- **O que reavaliar (hipóteses da intenção):**
- **“O `CardComposer` ramifica por `kind`”**: confirmado (`CardComposer.tsx:206-208` e ternários ao longo do arquivo); a colinha é o primeiro `kind` novo desde S13 e o branch fica isolado — sem flag extra dentro do `kind:'team'`.
- **“Catálogo e assets são os de S30”**: confirmado — reuso puro; nenhuma entrada/asset de estadual nasce aqui.
- **“Pipeline `renderX → preview canvas → PNG download` reaproveitado”**: confirmado — o render novo (`renderColinhaCard`) mora no dono único (`cardRender.ts`) e usa `CardPreviewCanvas`/`cardCanvas`/`cardFileName`.
- **“NEEDS ASSET do topo”**: resolvido no gate — o topo é composição medida a partir do kit (`team-card-base.png` + `marca-negativa-completa.png` + `team-card-front.png`); não há asset-mestre novo nem script.
- **“`CardModelTile`/galeria”**: o tile já cai no `Image` com `previewSrc` para `kind` desconhecido (só o `sizes` acompanha as seis colunas); a galeria passa a seis colunas.
## Abordagem recomendada
```mermaid
flowchart LR
H["home #cards / rota /cards"] --> S["CardsStudio — ilha única"]
S --> G["CardModelGallery — 6 tiles + 6 dots"]
G -->|"tile Minha colinha"| C["CardComposer — branch kind:'colinha' (passo único)"]
C --> SEL["StateDeputySelect — S30, intocado"]
SEL --> CAT["stateDeputyCatalog — 53 entradas (S30)"]
C --> RC["renderColinhaCard — cardRender.ts (dono único)"]
MOD["cardColinha.ts — COLINHA_* + colinhaVoteRows"] --> RC
AST["team-card-base + team-card-front + marca-negativa (kit)"] --> RC
RC --> PV["prévia canvas compartilhada"]
PV --> DL["Baixar minha colinha — PNG 1080×1920"]
TILE["public/cards/modelo-colinha.jpeg — cópia byte-a-byte"] --> G
```
**Opções consideradas:** A) sexto modelo no estúdio existente com composição canvas portada do gate + seletor de S30 reusado | B) usar o `modelo-colinha.jpeg` como fundo e sobrepor só a linha do estadual | C) asset-mestre novo gerado por script sharp + render de camadas.
**Recomendação:** A — o outcome é um modelo no estúdio existente; portar o gate classe-a-classe mantém a fidelidade aprovada, usa só o kit oficial e reaproveita catálogo/seletor/preview/download; cabe no appetite (um render novo, nenhuma infra).
**Rejeitadas:** B porque o modelo aprovado não tem slot para o nome dinâmico (a linha do estadual está queimada e o rótulo não comporta nome variável); C porque cria mestre novo não aprovado e um script para um asset quando o gate já define a composição.
### Decisões de engenharia
1. **Sexto modelo — `kind:'colinha'` novo + flag `stateDeputyPicker: true` de S30.**
Opções: A) novo `kind:'colinha'` + `stateDeputyPicker:true` | B) reusar `kind:'team'` | C) uma entrada por estadual (53 modelos).
Recomendação: A — o fluxo não tem foto/recorte/nome (nada da esteira S15), mas reusa o eixo do catálogo de S30; o `kind` discrimina os branches de composer/render e `isCardModelId`/`?model=` cobrem o id de graça.
Rejeitadas: B porque arrastaria janela de foto/recorte/harmonia/nome para um fluxo que não tem nenhum (branch por flag extra dentro do `team`); C porque explode galeria/pins/URL.
2. **Render — composição canvas pura portada classe-a-classe do gate (não o `modelo-colinha.jpeg` como fundo).**
Opções: A) portar a composição do gate (medidas em px, canvas puro) | B) `modelo-colinha.jpeg` como fundo + linha do estadual desenhada por cima | C) mestre novo por script sharp + camadas.
Recomendação: A — a doutrina do estúdio porta o artefato aprovado e a intenção diz que o topo é “definida no design hi-fi e montada a partir do kit oficial”; o `modelo` não tem slot para o nome dinâmico (rótulo queimado).
Rejeitadas: B porque o nome/dígitos não têm onde entrar; C porque é mestre novo não aprovado + script, quando o gate já define a composição.
3. **Módulos — `src/lib/cardColinha.ts` puro + `renderColinhaCard` no dono do render.**
Opções: A) módulo puro novo (`COLINHA_*`, tabela fixa, `colinhaVoteRows`) + renderer em `src/lib/cardRender.ts` | B) tudo dentro do `cardRender.ts` | C) renderer standalone em módulo próprio.
Recomendação: A — o layout/tabela é conhecimento próprio, testável sem canvas; o renderer fica no dono único de prévia/export (um call site), como S13–S30.
Rejeitadas: B porque espalha a tabela de linhas no arquivo de desenho; C porque seria um segundo dono de render (twin).
4. **Geometria — px medidos do gate em 1080×1920, constantes `COLINHA_*` pinadas em unit; candidato shrink-to-fit em uma linha.**
Opções: A) portar os valores medidos do gate em px | B) recalcular percentuais/`cqw` em runtime | C) desenhar na escala 900×1600 do tile.
Recomendação: A — canvas não tem CSS; os valores do gate em 1080×1920 são a régua (re-medir no browser se algo divergir ao portar). Valores:
- **topo:** y `0 → 710,4` (37% de 1920), fundo `#148fc2`; foto do grupo `team-card-base.png` (1080×1440) em cover, desenhada em `(0, −335,62, 1080, 1440)` com clip no bloco.
- **caixa da marca:** `(32,4; 21,29; 529,19×134,97)`; fundo gradiente linear 125° `#e4102f` 0→52% e `#184e92` 52→100% (linha CSS: dir `(sin125°, −cos125°)`, comprimento `|w·sinθ| + |h·cosθ|`, stops via `createLinearGradient`); lockup `public/campaign-kit/marca-negativa-completa.png` (1037×595) contido → rect desenhado `(226,4; 48,29; 141,1×80,97)` (o padding percentual do gate resolve contra o bloco de 1080 = 27px, não contra a caixa; não recalcular o contain a partir de “13,23”).
- **faixa vermelha:** `(0; 504,39; 1080×206,0)`, fundo `#e4102f`; arte `public/cards/team-card-front.png` (1080×1440) recortada nas linhas de origem `1232,36 → 1438,36`, desenhada com clip em `dy = 504,39 − 1232,36 = −727,97`.
- **linha legal vertical:** box `(7,55; 748,79; 15,66×1132,81)`; texto `FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL (PT-PC DO B - PV) | CNPJ CANDIDATO: 68.430.467/0001-05`; `700 15,66px`, letter-spacing ~0,31, cor `#333`, rotacionada −90° (lê de baixo para cima).
- **corpo:** `(51,83; 710,39; 1028,17×1209,61)`; padding topo 34,56 / lados 35,64 / base 32,4; gap entre linhas 34,56; centralizado verticalmente; cada uma das 6 linhas min-height 108; grid da linha = `30% | 1fr | auto` com gap 21,6 (coluna do rótulo ≈287,07). Derivado: primeira linha em y ≈ 905,88 e passo 142,56 (6×108 + 5×34,56 = 820,8 no miolo de 1142,65).
- **rótulo do cargo:** `900 27px` (line-height 1,02), caixa alta, `#171717`, quebras `DEPUTADO\nFEDERAL` e `DEPUTADO\nESTADUAL`, uma linha nos outros quatro.
- **candidato:** `900 26,46px`, `#e4102f`, gap ~9,6 acima; shrink-to-fit em UMA linha dentro da coluna de 287px, piso 18px — rejeitadas a quebra em duas linhas com altura variável (o CSS do gate quebra; o ritmo fixo das 6 linhas é melhor no canvas) e candidatos de duas linhas fixas.
- **caixas de dígito:** `59,39×73,44`, borda `5,94` `#111`, raio `6,48`, fundo branco, dígito `900 46,44px` centrado; gap 8,1; vazia = borda `#9a9a9a`, fundo `#fafafa`, dígito invisível.
- **pill CONFIRMA:** raio cheio, fundo `#009647`, padding `17,28×21,6`, `900 21,6px` branco; vazia = 30% de opacidade (fill `#0096474d`, sem `globalAlpha` no subset).
- **posições:** CONFIRMA na borda direita da linha (coluna `auto`); dígitos alinhados à esquerda na coluna do meio.
Rejeitadas: B porque canvas não interpreta CSS e a conversão runtime reintroduz erro de medição; C porque a régua aprovada é 1080×1920 (o tile 900×1600 é só arte de galeria).
5. **`CardDrawContext` ganha `roundRect` (fill-only).**
Opções: A) adicionar `roundRect` real e compor bordas com dois preenchimentos (externo na cor da borda, interno no fill) | B) `stroke`/`lineWidth` no subset | C) cantos com arcos manuais.
Recomendação: A — método real, subset mínimo e fill-only; caixas (borda 5,94), pill e estados vazios saem de dois `roundRect` preenchidos; o recorder falso registra a op. Pela geometria medida o subset ganha também os membros reais `clip` (recortes do topo/faixa), `createLinearGradient` (caixa da marca) e `letterSpacing` (linha legal).
Rejeitadas: B porque stroke centrado exigiria meia-borda na geometria e polui o subset; C porque reinventa `roundRect`.
6. **Composer — passo único, CTA baixa direto da prévia compartilhada.**
Opções: A) sem `result`: CTA `Baixar minha colinha` baixa direto do canvas da prévia; desabilitado até estadual + imagens prontas; `Gerando…` durante | B) dois passos compose→result como os outros modelos | C) dois CTAs (criar + baixar).
Recomendação: A — não há nome/foto para confirmar; as cenas 2 e 5 do gate mostram um único CTA; evita estado novo. Rodapé fiel ao gate: dialog = `Cancelar` + primário (cena 2); drawer = só o primário full-width (cena 5). Prévias: compacta pré-escolha (cena 2, 145px) e grande pós-escolha (cena 3 desktop 405px / cena 5 mobile 300px) — no branch da colinha o `max-h-[38dvh]` do S15 não se aplica (o 9:16 precisa da largura da cena 3 e rola com o corpo); os demais modelos ficam intocados.
Rejeitadas: B porque adiciona tela de confirmação sem conteúdo novo; C porque não existe no design.
7. **Tile — cópia byte-a-byte do asset aprovado; badge `NOVO` muda de modelo.**
Opções: A) copiar `docs/plans/cards-colinha-ui-design-assets/modelo-colinha.jpeg` para `public/cards/modelo-colinha.jpeg` (900×1600, sha256 pinado) | B) derivativo via script sharp | C) referenciar o arquivo de `docs/plans/` no `src`.
Recomendação: A — precedente S30; `docs/` não é servido em produção; um asset não justifica script. O badge `NOVO` sai de `time-do-estadual` e entra em `minha-colinha` (literal assumido, gate cena 1).
Rejeitadas: B (script para 1 arquivo; mestre novo não aprovado); C (não é servido pelo Next).
8. **Galeria — seis tiles na mesma fileira.**
Opções: A) `lg:grid-cols-6` mantendo `lg:gap-4` + dica `Deslize para ver os seis modelos` (os dots já derivam de `CARD_MODELS.length`) | B) manter 5 colunas e esconder o 6º no scroll | C) extrair carrossel/dots genérico.
Recomendação: A — o gate cena 1 desenha seis colunas; dots e contagem já são derivados; nada de abstração nova. `/cards` passa a `Escolha um dos seis modelos, personalize e baixe para compartilhar.` e o doc-comment da home (`CampaignCardsSection.tsx:5`) “five”→“six”.
Rejeitadas: B viola o gate; C abstração sem segundo consumidor.
9. **Entrada do modelo.** `id:'minha-colinha'`, `kind:'colinha'`, `label:'Minha colinha'`, `badge:'NOVO'`, `stateDeputyPicker:true`, `assetSrc:'/cards/team-card-base.png'` (foto do grupo), `overlaySrc:'/cards/team-card-front.png'` (arte da faixa), `lockupSrc:'/campaign-kit/marca-negativa-completa.png'` (novo campo opcional de `CardModel`), `previewSrc:'/cards/modelo-colinha.jpeg'`, `width:1080`, `height:1920`, sem `photoWindow`.
10. **Copy do composer — literais do gate + nota nova; seletor S30 intocado.**
Opções: A) portar os literais das cenas 2/3/5 e criar constante nova em `cardCopy.ts` | B) reusar `CARD_PHOTO_PRIVACY_NOTE` | C) reescrever a copy.
Recomendação: A — a nota existente fala de “nome e foto” (falsa aqui: nada é digitado/enviado); a nova é o literal do gate. `StateDeputySelect` e sua dica seguem intocados (superfície S30 não é tocada).
Rejeitadas: B porque a frase não corresponde ao fluxo sem foto; C porque o gate é a fonte.
11. **Case dos textos.** Opções: A) rótulos UPPERCASE; fixos em Title Case como o gate/modelo; estadual UPPERCASE; vazio `Escolha abaixo` | B) tudo UPPERCASE | C) estadual em Title Case como no catálogo.
Recomendação: A — fidelidade literal (cena 3/4: `JULIO PINHEIRO`; fixos `Jorge Solla`, `Jaques Wagner`, `Rui Costa`, `Jerônimo`, `Lula`); o catálogo guarda o nome de exibição e a colinha aplica `toLocaleUpperCase('pt-BR')`.
Rejeitadas: B muda os fixos aprovados; C diverge do gate.
12. **Testes — estender os pins e o spec `frontend`; manifesto sem mudança.**
Opções: A) atualizar `cardModels`/`cardRender`, criar `cardColinha.unit.spec.ts` e estender `tests/e2e/frontend.e2e.spec.ts` | B) spec e2e novo separado | C) só unit.
Recomendação: A — o harness/probes do estúdio já vivem no spec `frontend`; `scripts/lib/e2e-affected-manifest.mjs` NÃO muda (o prefixo `src/lib/card` já casa `cardColinha.ts`, `src/components/cards` casa o composer e `public/` é ignorado); **int não se aplica** (sem Payload/DB/access/escrita multi-collection).
Rejeitadas: B fragmenta o harness e duplica setup; C não cobre o aceite visual (prévia/download/troca).
### Componentes / mudanças
- **`src/lib/cardColinha.ts`** (novo, puro, client-safe): geometria `COLINHA_*` (topo, marca, faixa, legal, corpo, linha, rótulo, candidato, dígito, confirmar; cores e pesos), `COLINHA_LEGAL_TEXT`, tabela fixa das 5 linhas e `colinhaVoteRows(deputy: StateDeputyCatalogEntry | null)` → 6 linhas na ordem do gate (federal, estadual, 2× senador, governador, presidente), com `officeLines`, `candidate`, `digits` (por caractere) e o marcador de estado vazio (5 slots + `Escolha abaixo` + CONFIRMA esmaecido).
- **`src/lib/cardRender.ts`** (editar): `CardDrawContext` ganha `roundRect` (+ `clip`, `createLinearGradient`, `letterSpacing` — membros reais exigidos pela geometria medida); `renderColinhaCard(ctx, model, args)` desenha na ordem medida — fundo branco → topo (fundo, foto recortada, caixa da marca com gradiente, faixa recortada) → linha legal rotacionada → 6 linhas (rótulo, candidato shrink-to-fit, caixas de dígito, pill) — reusando `CardMeasureText`; `createCardMeasure` ganha peso opcional com default 700 (S13–S30 intocados) para medir o candidato em 900.
- **`src/lib/cardModels.ts`** (editar): `CARD_MODEL_IDS` com 6 ids; `kind` ganha `'colinha'`; `CardModel` ganha `lockupSrc?: string`; entrada de S31 (Decisão 9) e remoção do `badge` de `time-do-estadual`.
- **`src/components/cards/CardComposer.tsx`** (editar): `isColinhaModel`; branch de carregamento (foto do grupo + faixa + lockup; `previewSrc` fica só no tile) e de render no canvas; sem `useCardCutout`/nome/harmonia no fluxo; título/eyebrow/descrição por estado; seletor S30 reusado; caixa `Linha conferida`; nota nova; CTA único (Decisão 6) chamando o `handleDownload` existente.
- **`src/components/cards/cardCopy.ts`** (editar): constante nova da nota de privacidade da colinha (`A escolha é processada neste aparelho. Nenhum nome, foto ou cadastro é solicitado.`).
- **`src/components/cards/CardModelGallery.tsx`** (editar): `lg:grid-cols-6` + `lg:gap-4`, dica `Deslize para ver os seis modelos`, doc-comment “five”→“six”.
- **`src/components/cards/CardModelTile.tsx`** (editar só o `sizes`): o `kind:'colinha'` cai no `Image` com `previewSrc` (fall-through já existente); o `sizes` de 5 para 6 colunas (`167px`).
- **`src/app/(frontend)/(home)/cards/page.tsx`** (editar): intro `Escolha um dos seis modelos, personalize e baixe para compartilhar.`.
- **`src/app/(frontend)/(home)/CampaignCardsSection.tsx`** (editar): doc-comment “five”→“six”.
- **`public/cards/modelo-colinha.jpeg`** (novo): cópia byte-a-byte do asset aprovado (900×1600, sha256 `76505492fb243c67313d237e58926afbd4d0847c00ab3bb43c090859cbdf18c8`).
- **Testes** (editar/novo): `tests/unit/cardModels.unit.spec.ts` (6 ids, pin do modelo novo + `lockupSrc`, badge movido), `tests/unit/cardRender.unit.spec.ts` (recorder falso com `roundRect`/`clip`/gradiente; ordem e medidas do `renderColinhaCard`; estado vazio), `tests/unit/cardColinha.unit.spec.ts` (novo, `// @vitest-environment node`: pins de `COLINHA_*`, `colinhaVoteRows` fixo/preenchido/vazio, sha256 + 900×1600 do tile via `sharp`), `tests/e2e/frontend.e2e.spec.ts` (loop de 6 tiles + badge em `minha-colinha`; 6 dots + `seis modelos`; describe novo `Cards personalizados (S31 — Minha colinha)`).
- **`scripts/lib/e2e-affected-manifest.mjs`**: **sem mudança** — `src/lib/card` já casa `cardColinha.ts`, `src/components/cards` cobre composer/galeria e assets em `public/` são ignorados pelo manifesto.
- **`docs/changelog/2026-09-23-s31-minha-colinha.md`** (novo).
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
- **Access / Consent:** não se aplica — nenhuma superfície Payload, nenhum opt-in; o fluxo é 100% client-side e a escolha não sai do aparelho.
- **UI:** Impeccable B — shape (gate cenas 1–5) → craft (port classe-a-classe das medidas) → critique (a11y toque/teclado, mobile 390, contraste das caixas, copy) → polish. Shells/tokens reusados: `CardsStudio`, `ui/dialog`, `ui/Drawer`, `CardPreviewCanvas`, `StateDeputySelect`, `--pt-red`/`--campaign-*`; alvos ≥44px; `role=status`/`role=alert` herdados. **Crítica final (c) do `designer`:** 1ª passada DEGRADED com 2 ajustes — tipografia do canvas devia ser o stack do gate, não a display da campanha, e a descrição não devia ocupar o cabeçalho do drawer mobile (cena 5) — ambos aplicados (`COLINHA_FONT_FAMILY` no desenho/medição; descrição só no dialog) e **certificados** na 2ª passada (`Design tier: openai/gpt-5.6-sol`).
### Dados → forma (se aplicável)
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “forma” é a geometria do card. A escolha do estadual é insumo local da imagem — nunca dado coletado, apresentado ou enviado (medição de uso é S32).
## Fases verificáveis
1. **Núcleo puro + modelo + tile** — quota ~0,5 dia. `cardColinha.ts` (geometria + tabela + builder); `renderColinhaCard` + membros novos do subset no `cardRender.ts`; `cardModels.ts` (6º modelo, `lockupSrc`, badge movido); cópia byte-a-byte do jpeg; unit pins (`cardModels`, `cardColinha`, `cardRender`).
Prova: `pnpm gate:fast` verde; `sha256sum public/cards/modelo-colinha.jpeg docs/plans/cards-colinha-ui-design-assets/modelo-colinha.jpeg` idênticos; unit da colinha verde (geometria, linhas, 900×1600).
2. **Composer + galeria + copy** — quota ~0,5 dia. Branch `kind:'colinha'` no `CardComposer` (passo único, CTA direto, prévias 145/300/405, `Linha conferida`, nota nova); galeria 6 colunas/dica/dots; intro `/cards` e doc-comment da home.
Prova: `pnpm gate:fast` verde; `?model=minha-colinha` no browser: estado vazio (`Escolha abaixo`, 5 caixas vazias, CONFIRMA esmaecido, CTA desabilitado), escolha por busca/↑↓/Enter, prévia com nome + 5 dígitos, CTA habilitado, `Baixar minha colinha`; `/cards` com 6 tiles e dica dos seis.
3. **Gates + e2e + changelog** — quota ~0,3 dia. e2e (loop 6 tiles + badge; 6 dots + `seis modelos`; describe S31); changelog; `pnpm gate:fast`; e2e da superfície; `pnpm push` (pre-push roda `gate:ci`).
Prova: `pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Minha colinha"` verde (prévia vazia + CTA off, seleção por teclado, prévia preenchida, download real `card-jorge-solla-minha-colinha.png` com assinatura PNG, troca mantendo a escolha); fixture de console-error intacta.
## Rabbit holes / Não escopo (engenharia)
- PDF/A4 ou impressão em casa (intenção, Questão 1) — só PNG 9:16; A4 vira item próprio se o produto pedir.
- Nome do visitante/“feito por você” (Questão 2) — a colinha é o produto.
- Segundo editor/estúdio, rota/página por colinha, editor de linhas (reordenar/remover cargos), `?estadual=`.
- Analytics de uso/abandono (S32); qualquer medição da escolha.
- CMS/collection/migration; ler `stateDeputy`/`Contact`; qualquer superfície Payload.
- Re-harmonização/segundo estilo de card; 53 modelos (a colinha é uma só); compartilhamento direto em redes.
- Redesenhar o topo/arte-mestre fora do gate; tipografia nova; otimização/prebuild de assets ou script de build (um asset = cópia byte-a-byte).
- Tocar nas artes-mestre (`team-card-base.png`, `team-card-front.png`, kit) e nos cinco modelos existentes além do badge; mexer no `StateDeputySelect`/dica de S30.
## Explicitamente fora (descartes e defers com gatilho do simplify)
- **Descartado — ternários por `kind` no composer** (revisores 1/2): a dívida é a F2 do #1277 (`escala-dry-pos-s30.md`), que já absorve `canAdvance`/`adjustable`/`eyebrow`/`title`/estágios de prévia; nada a duplicar aqui.
- **Descartado — identificadores PT (`colinha`/`isColinhaModel`/`kind:'colinha'`)**: literal do gate auto-aprovado no plano (palavra do domínio, como os enums de `post.type`).
- **Descartado — pins de geometria no unit/e2e e `sizes` 167px**: duplo pin intencional da composição (as constantes já são pinadas em `cardColinha.unit`); o `sizes` foi medido (166,7px para 6 colunas do contêiner de 1080).
- **Descartado — pureza ≤2** (`colinhaVoteRows` mutável/refs compartilhadas, `Record` inline, `drawColinhaTop` recebendo `CardModel`): sem bug e sem 2º consumidor.
- **Defer — `lockupSrc` opcional**: gatilho = 2º campo required por `kind` ou 2º consumidor de `lockupSrc` (entra junto com a união discriminada de `CardModel`).
- **Defer — dígitos/`ballotNumber` (5 fixo)**: gatilho = catálogo ganhar código ≠5 dígitos ou layout parametrizado pela contagem.
- **Defer — teste do caminho de falha do download**: gatilho = próxima mexida no caminho de download/erro do estúdio.
- **Re-defer — par JULIO default morto/duplicado (S30 P6+P10, #1277)**: o gatilho do S30 (“o S31 revisita o contrato”) disparou, mas a condição não se cumpriu — os defaults seguem mortos e sem 2º consumidor; o S31 só acrescentou `lockupSrc`. Novo gatilho: 2º consumidor do par default ou modelo que precise de default real (não ilustrativo).
- **Já resolvido no simplify (não reabrir):** `downloadError` renderizado no compose da colinha (alert novo), doc de `previewSrc`, plano/codebase-map, `Linha conferida` derivada de `colinhaVoteRows` (fim do rótulo/case duplicados), splice de classe, guard `width <= 0`, `drawColinhaDigit(empty)`, altura da pill calculada 1×, `capAt`, `pickJulioByKeyboard` içado, nota de privacidade asserida no e2e.
## Riscos e mitigação
- **Fidelidade do topo vs `modelo-colinha.jpeg`:** o port segue as medidas do gate + crítica do designer (c) + UAT; os números `COLINHA_*` ficam pinados em unit e a divergência gate × modelo é literal assumido (fonte canônica = gate). Gatilho: crítica do designer reprovar → ajustar só as constantes.
- **Nomes longos do catálogo (53):** shrink-to-fit em uma linha com piso 18px; unit pina o builder/geometria e o UAT cobre o pior caso (`Artur Barachisio Lisbôa`, `Coletivo de Enfermagem`); se um nome ainda estourar no piso, o candidato quebra em duas linhas apenas nessa linha (revisitar a Decisão 4).
- **Badge `NOVO` movido quebra pins:** unit + e2e atualizados no mesmo PR (literal assumido).
- **Medida do candidato em 900:** `createCardMeasure` mede em 700; o peso opcional (default 700) entra junto para o shrink medir o que desenha — sem tocar S13–S30.
- **Regressão nos modelos S13–S30:** subset só ganha membros; nenhum call site antigo muda; unit/e2e dos cinco modelos seguem verdes; o `max-h-[38dvh]` do S15 fica intocado (o branch da colinha tem o próprio enquadramento).
- **Prévia 9:16 no dialog:** sem o teto de altura do S15 a prévia usa a largura da cena 3 e rola com o corpo; se o UAT achar o dialog alto demais, reduzir a largura pós-escolha (constante da Decisão 6), nunca a fidelidade das linhas.
- **Peso do asset novo (~250KB):** aceito; cópia byte-a-byte sem script e sem tocar no bundle JS.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: 6º modelo `Minha colinha` na home e `/cards` (`#cards`, `/cards`, `?model=minha-colinha`); escolha do estadual na lista de S30 por toque/teclado; prévia com nome de urna + 5 dígitos (um por caixa) e as 5 linhas fixas intocadas; PNG 1080×1920 baixado do mesmo estúdio; 100% no aparelho (sem nome/foto/upload); fidelidade ao gate (topo, linha legal vertical, corpo); sem página/rota por colinha.
- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent; `src/lib` puro/client-safe (`cardColinha` sem `utilities/`); copy pt-BR/identificadores em inglês; sem `as never`; knip sem órfãos; artes-mestre intocadas; `pnpm gate:fast` + e2e da superfície + `pnpm push` verdes.
- [ ] Testes de domínio previstos: unit dos módulos puros (6 ids/badge/`lockupSrc`, pins de `COLINHA_*`, `colinhaVoteRows` preenchido/vazio, render no recorder falso com `roundRect`/`clip`, sha256 + 900×1600 do tile) obrigatório; e2e no spec `frontend` (6 tiles + badge, 6 dots + `seis modelos`, describe S31 com prévia vazia + CTA desabilitado, busca/teclado, prévia preenchida, download PNG, troca mantendo a escolha); **int não se aplica** — sem Payload/DB/access nem escrita multi-collection neste fluxo (client-side + assets estáticos).
## Literais assumidos (auto-aprovados)
- **Modelo:** id `minha-colinha`, label `Minha colinha`, `kind:'colinha'`, `stateDeputyPicker:true`, `badge:'NOVO'` (**movido** de `time-do-estadual`), `assetSrc:'/cards/team-card-base.png'`, `overlaySrc:'/cards/team-card-front.png'`, `lockupSrc:'/campaign-kit/marca-negativa-completa.png'`, `previewSrc:'/cards/modelo-colinha.jpeg'`, `1080×1920`, sem `photoWindow`.
- **Tile:** `public/cards/modelo-colinha.jpeg` = cópia byte-a-byte de `docs/plans/cards-colinha-ui-design-assets/modelo-colinha.jpeg` (900×1600; sha256 `76505492fb243c67313d237e58926afbd4d0847c00ab3bb43c090859cbdf18c8`).
- **Linhas fixas (ordem e literais):** `DEPUTADO FEDERAL · Jorge Solla · 1313` (4 caixas) · `DEPUTADO ESTADUAL · <nome> · <5 dígitos>` (5 caixas) · `SENADOR · Jaques Wagner · 130` · `SENADOR · Rui Costa · 133` · `GOVERNADOR · Jerônimo · 13` · `PRESIDENTE · Lula · 13`; todas com `CONFIRMA`.
- **Legal:** `FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL (PT-PC DO B - PV) | CNPJ CANDIDATO: 68.430.467/0001-05`.
- **Case:** rótulos UPPERCASE; fixos em Title Case (`Jorge Solla`, `Jaques Wagner`, `Rui Costa`, `Jerônimo`, `Lula`); estadual UPPERCASE (gate `JULIO PINHEIRO`); vazio `Escolha abaixo`.
- **Copy do composer:** eyebrow `Minha colinha`; pré-escolha título `Escolha seu estadual` + descrição `A linha de deputado estadual será preenchida na hora.`; pós-escolha título dialog `Pronta para levar com você` / drawer `Pronta para baixar` + descrição `Confira o estadual escolhido. As demais escolhas já vêm preenchidas no modelo oficial.`; aviso pré-escolha `Escolha um estadual para baixar.` / `A linha vazia será preenchida com o nome e os cinco dígitos.`; pós-escolha `Linha conferida` + `DEPUTADO ESTADUAL · <NOME MAIÚSCULO> · <5 dígitos>`; CTA `Baixar minha colinha` / `Gerando…`; nota nova `A escolha é processada neste aparelho. Nenhum nome, foto ou cadastro é solicitado.`
- **Galeria:** `lg:grid-cols-6` com `lg:gap-4`; dica `Deslize para ver os seis modelos`; intro `/cards` `Escolha um dos seis modelos, personalize e baixe para compartilhar.`; badge `NOVO` no modelo novo (assert e2e acompanha).
- **Geometria:** constantes `COLINHA_*` com os valores da Decisão 4 (fonte canônica = gate em 1080×1920; o gate × `modelo` divergente fica sinalizado para a crítica do designer).
- **Prévias:** 145px pré-escolha; 300px mobile / 405px desktop pós-escolha; rodapé dialog `Cancelar` + primário, drawer só primário full-width.
## Self-score decision-quality: 5/5
1. **Decisões caras com rejeitadas:** modelo/kind, estratégia de render, módulos, geometria (e o candidato em uma linha), subset do contexto, fluxo do composer, tile/badge, galeria, entrada, copy, case e harness de testes — todas com Opções/Recomendação/Rejeitadas.
2. **Cabe no appetite:** 3 fases ≤1,3 dia, sem migration/infra; o único peso novo é 1 asset copiado byte-a-byte (~250KB), já previsto na intenção.
3. **Rabbit holes nomeados:** PDF/A4, nome do visitante, segundo editor/rota, editor de linhas, analytics, CMS, 53 modelos, redesenho do topo, script de asset, tocar nas artes-mestre.
4. **Depth check:** reusa `CardsStudio`/shells, `CardComposer`, `StateDeputySelect`/`stateDeputyCatalog`, `CardPreviewCanvas`/`cardCanvas`/`cardFileName`, o dono `cardRender` e o padrão de cópia byte-a-byte + sha256 de S30; nada de abstração especulativa.
5. **Intenção preservada:** aceite de produto intacto (6º modelo, escolha S30, prévia preenchida, PNG 1080×1920, 100% local, 5 modelos intocados além do badge); a engenharia só resolveu forma (kind novo, render portado, módulo puro, branch single-step).

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View file

@ -2,8 +2,8 @@ import { brexterBold } from '@/app/(frontend)/fonts'
import { CardsStudio } from '@/components/cards/CardsStudio'
/**
* S14/S30 — the home invitation to the card funnel: short copy + the five model
* tiles, positioned right before the newsletter capture (which stays the last
* S14/S30/S31 — the home invitation to the card funnel: short copy + the six
* model tiles, positioned right before the newsletter capture (which stays the last
* conversion block). Tapping a tile opens the SAME composer island used by
* `/cards` right here (dialog on desktop, drawer on mobile) — no navigation and
* no separate CTA. `brexterBold.variable` includes the display face in the page

View file

@ -9,7 +9,7 @@ import { isCardModelId } from '@/lib/cardModels'
import { hasPublishedJingles } from '@/utilities/jingleReads'
const intro =
'Escolha um dos cinco modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
'Escolha um dos seis modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
export const metadata: Metadata = {
title: 'Crie seu card de apoio | Jorge Solla',

View file

@ -20,6 +20,7 @@ import {
loadCardPhoto,
} from '@/components/cards/cardCanvas'
import {
CARD_COLINHA_PRIVACY_NOTE,
CARD_HARMONY_HELP,
CARD_HARMONY_LABEL,
CARD_PHOTO_PRIVACY_NOTE,
@ -30,6 +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 { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels'
import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
import {
@ -44,6 +46,7 @@ import {
} from '@/lib/cardPhotoTransform'
import {
createCardMeasure,
renderColinhaCard,
renderNameCard,
renderPhotoCard,
renderTeamCard,
@ -85,6 +88,16 @@ const STATE_DEPUTY_PREVIEW_WIDTH: Record<StateDeputyPreviewStage, string> = {
selected: 'max-w-[12.8125rem] sm:max-w-[14.375rem]',
}
/**
* 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).
*/
const COLINHA_PREVIEW_WIDTH: Record<'idle' | 'selected', string> = {
idle: 'max-w-[9.0625rem]',
selected: 'max-w-[18.75rem] sm:max-w-[25.3125rem]',
}
const TeamNameField = ({
id,
value,
@ -180,6 +193,8 @@ 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)
@ -205,10 +220,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const photoWindow = model.photoWindow
const isNameModel = model.kind === 'name'
const isTeamModel = model.kind === 'team'
const isColinhaModel = model.kind === 'colinha'
const isStateDeputyModel = isTeamModel && model.stateDeputyPicker === true
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).
@ -247,6 +265,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
setLoadState('loading')
setBaseImage(null)
setOverlayImage(null)
setLockupImage(null)
setPreviewImage(null)
const load = async () => {
@ -255,17 +274,22 @@ 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.
const [image, overlay, preview] = await Promise.all([
// 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([
isStateDeputyModel ? Promise.resolve(null) : loadCardImage(model.assetSrc),
!isStateDeputyModel && model.overlaySrc
? loadCardImage(model.overlaySrc)
: Promise.resolve(null),
model.previewSrc ? loadCardImage(model.previewSrc) : Promise.resolve(null),
model.lockupSrc ? loadCardImage(model.lockupSrc) : Promise.resolve(null),
model.previewSrc && !isColinhaModel
? loadCardImage(model.previewSrc)
: Promise.resolve(null),
])
if (cancelled) return
setBaseImage(image)
setOverlayImage(overlay)
setLockupImage(lockup)
setPreviewImage(preview)
setLoadState('ready')
} catch {
@ -277,7 +301,15 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
return () => {
cancelled = true
}
}, [isStateDeputyModel, model.assetSrc, model.overlaySrc, model.previewSrc, fontFamily])
}, [
isStateDeputyModel,
isColinhaModel,
model.assetSrc,
model.overlaySrc,
model.lockupSrc,
model.previewSrc,
fontFamily,
])
/**
* S30 — the selected deputy's pair swaps in place: the previous art stays
@ -322,6 +354,23 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
return
}
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
ctx.clearRect(0, 0, model.width, model.height)
renderColinhaCard(ctx, model, {
group: baseImage,
lockup: lockupImage,
band: overlayImage,
deputy: selectedDeputy,
fontFamily: COLINHA_FONT_FAMILY,
measure: createCardMeasure(ctx, COLINHA_FONT_FAMILY, 900),
})
return
}
if (isTeamModel) {
// S30 — the dobradinha pair comes from the selected catalog entry: `photos`
// is the FOTOS background (drawn under the cutout) and `base` is the front
@ -415,10 +464,12 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}, [
baseImage,
overlayImage,
lockupImage,
previewImage,
loadState,
isNameModel,
isTeamModel,
isColinhaModel,
isStateDeputyModel,
selectedDeputy,
deputyImages,
@ -539,13 +590,15 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
const nameCanAdvance = nameFit?.ok === true
const canAdvance = isNameModel
? nameCanAdvance
: isStateDeputyModel
? selectedDeputy !== null && deputyPairReady && teamReady !== null && nameCanAdvance
: isTeamModel
? teamReady !== null && nameCanAdvance
: photo !== null
const canAdvance = isColinhaModel
? selectedDeputy !== null
: isNameModel
? nameCanAdvance
: isStateDeputyModel
? selectedDeputy !== null && deputyPairReady && teamReady !== null && nameCanAdvance
: isTeamModel
? teamReady !== null && nameCanAdvance
: photo !== null
const nameError =
(isNameModel || isTeamModel) && name.trim().length > 0 && nameFit !== null && !nameFit.ok
const adjustable = isStateDeputyModel
@ -556,6 +609,10 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const title = (() => {
if (step === 'result') return 'Seu card está pronto para compartilhar.'
if (isNameModel) return 'Personalize com seu nome'
if (isColinhaModel) {
if (!selectedDeputy) return 'Escolha seu estadual'
return shell === 'dialog' ? 'Pronta para levar com você' : 'Pronta para baixar'
}
if (isStateDeputyModel && !selectedDeputy) return 'Escolha sua dobradinha'
if (!isTeamModel) return 'Enquadre sua foto'
if (teamProcessing) return 'Preparando sua foto'
@ -566,32 +623,46 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
return 'Entre para o time'
})()
const photoHeaderDescription =
step === 'compose' && !isNameModel && !isTeamModel
step === 'compose' && !isNameModel && !isTeamModel && !isColinhaModel
? 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
: null
const stateDeputyHeaderDescription =
isStateDeputyModel && step === 'compose' && !selectedDeputy
? 'Escolha seu estadual da dobradinha.'
: null
const headerDescription = photoHeaderDescription ?? stateDeputyHeaderDescription
/**
* S31 — the colinha description lives only in the dialog: the gate's mobile
* drawer (scene 5) carries eyebrow + title only, so the compact preview keeps
* the selector in the first viewport.
*/
const colinhaHeaderDescription =
isColinhaModel && step === 'compose' && shell === 'dialog'
? selectedDeputy
? 'Confira o estadual escolhido. As demais escolhas já vêm preenchidas no modelo oficial.'
: 'A linha de deputado estadual será preenchida na hora.'
: null
const headerDescription =
photoHeaderDescription ?? stateDeputyHeaderDescription ?? colinhaHeaderDescription
const teamBodyNotice =
isTeamModel && step === 'compose' && teamReady && !nameError
? 'O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.'
: null
const ShellDescription = shell === 'dialog' ? DialogDescription : DrawerDescription
const eyebrow = isTeamModel
? step === 'result'
? 'Tudo certo'
: teamError
? 'Falha no recorte'
: nameError
? 'Nome muito longo'
: isStateDeputyModel
? selectedDeputy
? 'Estadual escolhido'
: 'Time do estadual'
: 'Time de você'
: null
const eyebrow = isColinhaModel
? 'Minha colinha'
: isTeamModel
? step === 'result'
? 'Tudo certo'
: teamError
? 'Falha no recorte'
: nameError
? 'Nome muito longo'
: isStateDeputyModel
? selectedDeputy
? 'Estadual escolhido'
: 'Time do estadual'
: 'Time de você'
: null
const eyebrowNode = eyebrow ? (
<p className="text-[10px] font-black tracking-[0.1em] text-(--pt-red) uppercase sm:text-xs">
{eyebrow}
@ -624,11 +695,15 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
step === 'result' ? 'result' : teamReady ? 'ready' : teamProcessing ? 'processing' : 'idle'
const stateDeputyPreviewStage: StateDeputyPreviewStage =
teamPreviewStage === 'idle' && selectedDeputy && !nameError ? 'selected' : teamPreviewStage
const previewWidthClassName = isStateDeputyModel
? STATE_DEPUTY_PREVIEW_WIDTH[stateDeputyPreviewStage]
: isTeamModel
? TEAM_PREVIEW_WIDTH[teamPreviewStage]
: 'max-w-[22rem]'
const previewWidthClassName = isColinhaModel
? selectedDeputy
? COLINHA_PREVIEW_WIDTH.selected
: COLINHA_PREVIEW_WIDTH.idle
: isStateDeputyModel
? STATE_DEPUTY_PREVIEW_WIDTH[stateDeputyPreviewStage]
: isTeamModel
? TEAM_PREVIEW_WIDTH[teamPreviewStage]
: 'max-w-[22rem]'
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
@ -771,9 +846,9 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
<CardPreviewCanvas
model={model}
canvasRef={canvasRef}
className={`mx-auto block h-auto max-h-[38dvh] w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${
loadState === 'loading' ? 'opacity-60' : 'opacity-100'
}`}
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] '
}${loadState === 'loading' ? 'opacity-60' : 'opacity-100'}`}
onPointerDown={adjustable ? handlePointerDown : undefined}
onPointerMove={adjustable ? handlePointerMove : undefined}
onPointerUp={adjustable ? handlePointerEnd : undefined}
@ -803,6 +878,36 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
</div>
) : null}
{step === 'compose' && isColinhaModel ? (
<div>
<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} ·{' '}
{selectedDeputy.ballotNumber}
</p>
</div>
) : (
<p className="mt-4 rounded-lg bg-(--campaign-cream) p-3 text-xs leading-5 text-(--campaign-muted)">
<span className="block font-bold text-(--campaign-ink)">
Escolha um estadual para baixar.
</span>
A linha vazia será preenchida com o nome e os cinco dígitos.
</p>
)}
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_COLINHA_PRIVACY_NOTE}
</p>
{downloadError ? (
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
{downloadError}
</p>
) : null}
</div>
) : null}
{step === 'compose' && isNameModel ? (
<div className="mt-5">
<label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)">
@ -1067,16 +1172,24 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
<div className="flex shrink-0 gap-3 border-t border-(--campaign-line) px-5 py-4">
{step === 'compose' ? (
<>
<button type="button" onClick={onClose} className={secondaryButtonClassName}>
Cancelar
</button>
{isColinhaModel && shell === 'drawer' ? null : (
<button type="button" onClick={onClose} className={secondaryButtonClassName}>
Cancelar
</button>
)}
<button
type="button"
disabled={!canAdvance || loadState !== 'ready'}
onClick={() => setStep('result')}
disabled={!canAdvance || loadState !== 'ready' || (isColinhaModel && isDownloading)}
onClick={isColinhaModel ? () => void handleDownload() : () => setStep('result')}
className={primaryButtonClassName}
>
{teamProcessing ? 'Processando…' : 'Criar meu card'}
{isColinhaModel
? isDownloading
? 'Gerando…'
: 'Baixar minha colinha'
: teamProcessing
? 'Processando…'
: 'Criar meu card'}
</button>
</>
) : (

View file

@ -19,10 +19,10 @@ const tileClassName =
'group flex h-full w-full flex-col rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:ring-offset-(--campaign-cream)'
/**
* S14/S15/S30 — the five models side by side on desktop (a single row) and a
* S14/S15/S30/S31 — the six models side by side on desktop (a single row) and a
* one-item-with-peek snap track on mobile, as buttons for the studio island:
* selecting a tile opens the single composer in place (home or `/cards`). Both
* tracks come from the same catalog (S13); the mobile track carries the five
* tracks come from the same catalog (S13); the mobile track carries the six
* page dots of the design gate (scene 2), following the scroll position.
*/
export const CardModelGallery = ({
@ -96,7 +96,7 @@ export const CardModelGallery = ({
<div className="mt-8">
<ul
aria-label={`${ariaLabel} (desktop)`}
className="m-0 hidden list-none grid-cols-5 gap-5 p-0 sm:gap-6 lg:grid lg:gap-4"
className="m-0 hidden list-none grid-cols-6 gap-5 p-0 sm:gap-6 lg:grid lg:gap-4"
>
{CARD_MODELS.map((model) => (
<li key={model.id} className="m-0">
@ -119,7 +119,7 @@ export const CardModelGallery = ({
</ul>
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
Deslize para ver os cinco modelos
Deslize para ver os seis modelos
</p>
{/* The visual dot of the gate sits inside a 24px target (WCAG 2.5.8 AA). */}

View file

@ -6,11 +6,12 @@ import type { CardModel } from '@/lib/cardModels'
import { cn } from '@/lib/utils'
/**
* S13/S15 — 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 tile shows
* the filled example (`previewSrc`) with its `NOVO` badge.
* 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
* `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.
*/
export const CardModelTile = ({
model,
@ -57,7 +58,7 @@ export const CardModelTile = ({
src={model.previewSrc ?? model.assetSrc}
alt=""
fill
sizes="(min-width: 1024px) 200px, 78vw"
sizes="(min-width: 1024px) 167px, 78vw"
className="object-cover"
/>

View file

@ -6,6 +6,13 @@
export const CARD_PRIVACY_NOTE =
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
export const CARD_PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
/**
* S31 — the colinha has no typed name and no photo: the slip is composed from
* the official art plus the deputy choice, all on the device (literal of the
* design gate, scene 3).
*/
export const CARD_COLINHA_PRIVACY_NOTE =
'A escolha é processada neste aparelho. Nenhum nome, foto ou cadastro é solicitado.'
/**
* S17 — the tone-harmony control of the team model. Literals of the intent and

204
src/lib/cardColinha.ts Normal file
View file

@ -0,0 +1,204 @@
/**
* 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).
*/
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
}
/** 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.
*/
export const COLINHA_FONT_FAMILY = '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 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.
*/
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,
},
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,
},
} 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.
*/
export const fitColinhaCandidate = (
text: string,
measure: (text: string, fontSize: number) => { width: number },
maxWidth: number,
): ColinhaCandidateFit => {
const { fontSize: ideal, minFontSize } = COLINHA_LAYOUT.candidate
const width = measure(text, ideal).width
if (width <= maxWidth || width <= 0) return { fontSize: ideal, lines: [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] }
}

View file

@ -17,10 +17,11 @@ const CARD_MODEL_IDS = [
'perfil-retangular',
'time-de-voce',
'time-do-estadual',
'minha-colinha',
] as const
export type CardModelId = (typeof CARD_MODEL_IDS)[number]
type CardModelKind = 'name' | 'photo' | 'team'
type CardModelKind = 'name' | 'photo' | 'team' | 'colinha'
export type CardRect = {
x: number
@ -40,12 +41,21 @@ export type CardModel = {
photoWindow?: CardRect
/** Team model: the master front overlay drawn above the cutout photo. */
overlaySrc?: string
/** Team model: the filled example shown before a cutout is ready. */
/**
* 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.
*/
previewSrc?: string
/**
* S30 — team model whose art is picked per state deputy: the composer swaps
* `assetSrc`/`overlaySrc` for the selected catalog entry (the defaults are the
* illustrative JULIO pair) and gates the flow on the selection.
* S30/S31 — model whose flow is picked per state deputy from the shared
* catalog: on the team model the composer swaps `assetSrc`/`overlaySrc` for
* the selected entry (the defaults are the illustrative JULIO pair); on the
* colinha it fills the estadual row. Both gate the flow on the selection.
*/
stateDeputyPicker?: boolean
/** Optional gallery badge (e.g. `NOVO`). */
@ -102,11 +112,26 @@ export const CARD_MODELS: readonly CardModel[] = [
overlaySrc: '/cards/estaduais/julio-base.webp',
previewSrc: '/cards/modelo-time-de-voce-com-estadual.jpeg',
stateDeputyPicker: true,
badge: 'NOVO',
width: 1080,
height: 1440,
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
},
{
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',
stateDeputyPicker: true,
badge: 'NOVO',
width: 1080,
height: 1920,
},
]
/** Fields every name slot shares, whatever the drawing alignment. */

View file

@ -7,6 +7,13 @@
* recorder instead of jsdom canvas.
*/
import {
COLINHA_CONFIRM_LABEL,
COLINHA_LAYOUT,
COLINHA_LEGAL_TEXT,
colinhaVoteRows,
fitColinhaCandidate,
} from './cardColinha'
import {
BRAND_FALLBACK_FONT,
NAME_CARD_SLOT,
@ -31,9 +38,12 @@ import {
type CardPhotoSize,
type CardPhotoTransform,
} from './cardPhotoTransform'
import type { StateDeputyCatalogEntry } from './stateDeputyCatalog'
export type CardDrawContext = {
font: string
letterSpacing: string
globalAlpha: number
fillStyle: string | CanvasGradient | CanvasPattern
textAlign: CanvasTextAlign
textBaseline: CanvasTextBaseline
@ -57,15 +67,19 @@ export type CardDrawContext = {
endAngle: number,
): void
rect(x: number, y: number, width: number, height: number): void
roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]): void
clip(): void
createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient
fill(): void
}
export const createCardMeasure = (
ctx: Pick<CardDrawContext, 'font' | 'measureText'>,
fontFamily: string,
weight = 700,
): CardMeasureText => {
return (text, fontSize) => {
ctx.font = `700 ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}`
ctx.font = `${weight} ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}`
const metrics = ctx.measureText(text)
return {
@ -320,3 +334,258 @@ export const renderTeamCard = (
return { fit, transform }
}
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. */
deputy: StateDeputyCatalogEntry | null
fontFamily: string
/** The 900-weight measure the colinha draws 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.
*/
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.fillStyle = COLINHA_LAYOUT.background
ctx.fillRect(0, 0, model.width, model.height)
drawColinhaTop(ctx, model, args)
drawColinhaLegal(ctx, args.fontFamily)
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
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 = 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 = 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,
)
})
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,
)
})
}

View file

@ -1783,7 +1783,7 @@ test.describe('Cards personalizados (S14)', () => {
await expect(
section.getByRole('heading', { name: 'Mostre que você está com Solla' }),
).toBeVisible()
// S14/S15/S30 — the CTA is gone: the five models are the only trigger.
// S14/S15/S30/S31 — the CTA is gone: the six models are the only trigger.
await expect(section.getByRole('link', { name: 'Criar meu card' })).toHaveCount(0)
for (const model of [
'eu-sou-solla',
@ -1791,13 +1791,14 @@ test.describe('Cards personalizados (S14)', () => {
'perfil-retangular',
'time-de-voce',
'time-do-estadual',
'minha-colinha',
]) {
await expect(section.locator(`[data-card-model-tile="${model}"]`).first()).toBeAttached()
}
// S30 — the `NOVO` badge marks the newest model (design gate scene 1).
await expect(
section.locator('[data-card-model-tile="time-do-estadual"]').first(),
).toContainText('NOVO')
// S31 — the `NOVO` badge marks the newest model (design gate scene 1).
await expect(section.locator('[data-card-model-tile="minha-colinha"]').first()).toContainText(
'NOVO',
)
// S14 — the name tile reproduces the real result: `SEU NOME` drawn by the
// composer pipeline, left-aligned with the `SOU` border (x≈213 at 1080).
@ -1930,6 +1931,14 @@ const uploadBustPhoto = (dialog: Locator) =>
buffer: TEST_PHOTO_PNG,
})
/** S30/S31 — `jul` matches Juliete and Julio; ↓ moves the active option to Julio. */
const pickJulioByKeyboard = async (dialog: Locator) => {
const search = dialog.getByRole('combobox', { name: 'Buscar estadual' })
await search.fill('jul')
await search.press('ArrowDown')
await search.press('Enter')
}
/**
* S16 — the blue name banner probe: scan only the rows where the rotated
* rectangle spans its full width (|v| ≤ h/2·cos − w/2·sin) and take the widest
@ -2206,14 +2215,6 @@ test.describe('Cards personalizados (S15 — enquadramento automático)', () =>
* slot and the art swap that must keep the typed name and the processed photo.
*/
test.describe('Cards personalizados (S30 — Time do estadual)', () => {
/** `jul` matches Juliete and Julio; ↓ moves the active option to Julio. */
const pickJulioByKeyboard = async (dialog: Locator) => {
const search = dialog.getByRole('combobox', { name: 'Buscar estadual' })
await search.fill('jul')
await search.press('ArrowDown')
await search.press('Enter')
}
/**
* Window probes at 1080×1440: x=582 is the photo window center (286 + 592/2),
* y=540 the silhouette head center (439 + 0.17·592) and y=727 the window
@ -2328,20 +2329,20 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => {
await expect(dialog.getByText('Nenhum estadual encontrado.')).toBeVisible()
})
test('mobile gallery exposes the five dots and the five-model hint', async ({ page }) => {
test('mobile gallery exposes the six dots and the six-model hint', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/cards')
const firstDot = page.getByRole('button', { name: 'Ir para o modelo 1 de 5' })
const fifthDot = page.getByRole('button', { name: 'Ir para o modelo 5 de 5' })
await expect(fifthDot).toBeVisible()
await expect(page.getByText('Deslize para ver os cinco modelos')).toBeVisible()
const firstDot = page.getByRole('button', { name: 'Ir para o modelo 1 de 6' })
const sixthDot = page.getByRole('button', { name: 'Ir para o modelo 6 de 6' })
await expect(sixthDot).toBeVisible()
await expect(page.getByText('Deslize para ver os seis modelos')).toBeVisible()
await expect(firstDot).toHaveAttribute('aria-current', 'true')
// Clicking the last dot scrolls the track and keeps the active dot on it
// (the programmatic scroll lock must not bounce the index back mid-animation).
await fifthDot.click()
await expect(fifthDot).toHaveAttribute('aria-current', 'true')
await sixthDot.click()
await expect(sixthDot).toHaveAttribute('aria-current', 'true')
await expect(firstDot).not.toHaveAttribute('aria-current', 'true')
await expect
.poll(() =>
@ -2352,3 +2353,84 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => {
.toBeGreaterThan(0)
})
})
test.describe('Cards personalizados (S31 — 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.
*/
const colinhaBoxPixel = (canvas: Locator) =>
canvas.evaluate((element) => {
const node = element as HTMLCanvasElement
return [...node.getContext('2d')!.getImageData(426, 1102, 1, 1).data]
})
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')
await expect(dialog.getByRole('heading', { name: 'Escolha seu estadual' })).toBeVisible()
await expect(
dialog.getByText('A linha de deputado estadual será preenchida na hora.'),
).toBeVisible()
await expect(dialog.getByText('Escolha um estadual para baixar.')).toBeVisible()
const canvas = dialog.locator('canvas')
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])
const primary = dialog.getByRole('button', { name: 'Baixar minha colinha' })
await expect(primary).toBeDisabled()
await pickJulioByKeyboard(dialog)
await expect(dialog.getByRole('heading', { name: 'Pronta para levar com você' })).toBeVisible()
const trigger = dialog.getByRole('button', { name: /Seu estadual/ })
await expect(trigger).toContainText('Julio Pinheiro')
await expect(trigger).toContainText('13999')
await expect(dialog.getByText('Linha conferida')).toBeVisible()
await expect(dialog.getByText('DEPUTADO ESTADUAL · JULIO PINHEIRO · 13999')).toBeVisible()
await expect(
dialog.getByText(
'A escolha é processada neste aparelho. Nenhum nome, foto ou cadastro é solicitado.',
),
).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])
const [download] = await Promise.all([page.waitForEvent('download'), primary.click()])
expect(download.suggestedFilename()).toBe('card-jorge-solla-minha-colinha.png')
expect(
readFileSync((await download.path())!)
.subarray(0, 8)
.equals(PNG_SIGNATURE),
).toBe(true)
})
test('switching the estadual keeps the slip ready for download', async ({ page }) => {
await page.goto('/cards?model=minha-colinha')
const dialog = page.getByRole('dialog')
await pickJulioByKeyboard(dialog)
// Swap through the trigger; the new one confirms on it.
await dialog.getByRole('button', { name: /Seu estadual/ }).click()
const search = dialog.getByRole('combobox', { name: 'Buscar estadual' })
await search.fill('adriana')
await search.press('Enter')
const trigger = dialog.getByRole('button', { name: /Seu estadual/ })
await expect(trigger).toContainText('Coletivo de Enfermagem')
await expect(trigger).toContainText('13763')
await expect(dialog.getByRole('button', { name: 'Baixar minha colinha' })).toBeEnabled()
})
})

View file

@ -0,0 +1,231 @@
// @vitest-environment node
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import { describe, expect, it } from 'vitest'
import {
COLINHA_ESTADUAL_PLACEHOLDER,
COLINHA_FIXED_ROWS,
COLINHA_LAYOUT,
COLINHA_LEGAL_TEXT,
colinhaVoteRows,
fitColinhaCandidate,
} from '@/lib/cardColinha'
import { getStateDeputyCard } from '@/lib/stateDeputyCatalog'
const publicFile = (src: string) => fileURLToPath(new URL(`../../public${src}`, import.meta.url))
const APPROVED_TILE_SRC = fileURLToPath(
new URL('../../docs/plans/cards-colinha-ui-design-assets/modelo-colinha.jpeg', import.meta.url),
)
/** 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'],
])
})
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'],
})
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(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_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_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,
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',
)
})
})
describe('colinha tile asset (S31)', () => {
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)
expect(createHash('sha256').update(shipped).digest('hex')).toBe(TILE_SHA256)
expect(createHash('sha256').update(shipped).digest('hex')).toBe(
createHash('sha256').update(approved).digest('hex'),
)
const metadata = await sharp(publicFile('/cards/modelo-colinha.jpeg')).metadata()
expect(`${metadata.width}x${metadata.height}`).toBe('900x1600')
})
})
describe('fitColinhaCandidate (S31)', () => {
const measure = (text: string, fontSize: number) => ({ width: text.length * fontSize * 0.84 })
const officeColumn = 287.07
it('keeps the ideal size while the name fits the office column', () => {
expect(fitColinhaCandidate('Lula', measure, officeColumn)).toEqual({
fontSize: COLINHA_LAYOUT.candidate.fontSize,
lines: ['Lula'],
})
})
it('shrinks a long name on one line instead of wrapping (fixed row rhythm)', () => {
const fit = fitColinhaCandidate('Julio Pinheiro', measure, officeColumn)
expect(fit.fontSize).toBeLessThan(COLINHA_LAYOUT.candidate.fontSize)
expect(fit.fontSize).toBe(24)
expect(fit.lines).toEqual(['Julio Pinheiro'])
})
it('wraps by word into two lines at the floor when shrinking is not enough', () => {
const fit = fitColinhaCandidate('Marlene do Sindicato', measure, officeColumn)
expect(fit.fontSize).toBe(COLINHA_LAYOUT.candidate.minFontSize)
expect(fit.lines).toEqual(['Marlene do', 'Sindicato'])
})
it('wraps the longest catalog names into two lines that fit the column', () => {
const fit = fitColinhaCandidate('COLETIVO DE ENFERMAGEM', measure, officeColumn)
expect(fit.lines).toEqual(['COLETIVO DE', 'ENFERMAGEM'])
for (const line of fit.lines) {
expect(measure(line, fit.fontSize).width).toBeLessThanOrEqual(officeColumn)
}
})
})

View file

@ -11,13 +11,14 @@ import {
} from '@/lib/cardModels'
describe('card model catalog (S13/S15)', () => {
it('exposes the five shipped models with unique ids', () => {
it('exposes the six shipped models with unique ids', () => {
expect(CARD_MODELS.map((model) => model.id)).toEqual([
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
'time-de-voce',
'time-do-estadual',
'minha-colinha',
])
expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length)
})
@ -90,17 +91,33 @@ describe('card model catalog (S13/S15)', () => {
overlaySrc: '/cards/estaduais/julio-base.webp',
previewSrc: '/cards/modelo-time-de-voce-com-estadual.jpeg',
stateDeputyPicker: true,
badge: 'NOVO',
width: 1080,
height: 1440,
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
})
// The `NOVO` badge marks the newest model only (design gate scene 1).
expect(getCardModel('time-de-voce')?.badge).toBeUndefined()
expect(getCardModel('time-do-estadual')?.stateDeputyPicker).toBe(true)
expect(getCardModel('time-de-voce')?.stateDeputyPicker).toBeUndefined()
})
it('pins the S31 colinha model: new kind, official top assets, picker flag and the approved tile', () => {
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',
stateDeputyPicker: true,
badge: 'NOVO',
width: 1080,
height: 1920,
})
expect(getCardModel('minha-colinha')?.photoWindow).toBeUndefined()
// The `NOVO` badge marks the newest model only (design gate scene 1).
expect(getCardModel('time-do-estadual')?.badge).toBeUndefined()
})
it('guards the team banners and name slot measured from the example card', () => {
expect(TEAM_CARD_LABEL).toMatchObject({
text: 'TIME DE',
@ -157,6 +174,7 @@ describe('card model catalog (S13/S15)', () => {
expect(isCardModelId('perfil-retangular')).toBe(true)
expect(isCardModelId('time-de-voce')).toBe(true)
expect(isCardModelId('time-do-estadual')).toBe(true)
expect(isCardModelId('minha-colinha')).toBe(true)
expect(isCardModelId('modelo-inventado')).toBe(false)
expect(isCardModelId(['perfil-quadrado'])).toBe(false)
expect(isCardModelId(undefined)).toBe(false)

View file

@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
COLINHA_CONFIRM_LABEL,
COLINHA_ESTADUAL_PLACEHOLDER,
COLINHA_LAYOUT,
COLINHA_LEGAL_TEXT,
} from '@/lib/cardColinha'
import {
NAME_CARD_SLOT,
TEAM_CARD_LABEL,
@ -19,12 +25,14 @@ import {
createCardMeasure,
drawCardName,
drawCardVisitorSilhouette,
renderColinhaCard,
renderNameCard,
renderPhotoCard,
renderTeamCard,
resolveCardBannerWidth,
type CardDrawContext,
} from '@/lib/cardRender'
import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDeputyCatalog'
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
type TextCall = { text: string; x: number; y: number }
@ -44,6 +52,8 @@ const createFakeContext = () => {
const ctx: CardDrawContext = {
font: '',
letterSpacing: '',
globalAlpha: 1,
fillStyle: '',
textAlign: 'start',
textBaseline: 'alphabetic',
@ -88,6 +98,23 @@ const createFakeContext = () => {
rect: (x, y, width, height) => {
ops.push({ op: 'rect', args: [x, y, width, height] })
},
roundRect: (x, y, width, height, radii) => {
ops.push({
op: 'roundRect',
args: [x, y, width, height, typeof radii === 'number' ? radii : -1],
})
},
clip: () => {
ops.push({ op: 'clip', args: [] })
},
createLinearGradient: (x0, y0, x1, y1) => {
ops.push({ op: 'createLinearGradient', args: [x0, y0, x1, y1] })
return {
addColorStop: (offset: number) => {
ops.push({ op: 'addColorStop', args: [offset] })
},
}
},
fill: () => {
ops.push({ op: 'fill', args: [] })
},
@ -426,3 +453,126 @@ describe('renderPhotoCard', () => {
expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height)
})
})
describe('renderColinhaCard (S31)', () => {
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 deputy = getStateDeputyCard('julio')!
const render = (chosen: StateDeputyCatalogEntry | null) => {
const fake = createFakeContext()
renderColinhaCard(fake.ctx, colinhaModel, {
group,
lockup,
band,
deputy: chosen,
fontFamily: 'Brexter',
measure: createCardMeasure(fake.ctx, 'Brexter', 900),
})
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)
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,
5,
)
// 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 })
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)
})
})