mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 02:05:16 +00:00
feat(S31): colinha de voto — 6º modelo com o estadual escolhido (Closes #1272)
This commit is contained in:
parent
b8051bb7e0
commit
b0bac78090
17 changed files with 1388 additions and 84 deletions
|
|
@ -22,7 +22,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
|
|||
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
|
||||
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`/`cardCutout.ts`/`cardPhotoHarmonyCanvas.ts` (S17). Geometria/catálogo/tom puros em `src/lib/card{Models,NameFit,PhotoTransform,Render,Harmony}.ts`.
|
||||
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
|
||||
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`) e do roster estático das 53 dobradinhas (`stateDeputyCatalog`, S30 — snapshot em `tests/fixtures/state-deputy-catalog.snapshot.json`; artes derivadas por `scripts/build-state-deputy-card-assets.mjs`).
|
||||
- `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`).
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-23-s31-minha-colinha.md
Normal file
1
docs/changelog/2026-09-23-s31-minha-colinha.md
Normal 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).
|
||||
203
docs/plans/cards-colinha-impl.md
Normal file
203
docs/plans/cards-colinha-impl.md
Normal 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).
|
||||
BIN
public/cards/modelo-colinha.jpeg
Normal file
BIN
public/cards/modelo-colinha.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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). */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
204
src/lib/cardColinha.ts
Normal 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] }
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
231
tests/unit/cardColinha.unit.spec.ts
Normal file
231
tests/unit/cardColinha.unit.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue