feat(S13): cards personalizados da campanha no site público

Home ganha a seção Faça parte com os três modelos e a rota /cards
concentra catálogo + compositor client-side (dialog desktop, bottom
drawer mobile): nome no slot medido do mestre com Brexter 700 e foto
full-bleed sob o overlay oficial (pan/zoom clampados), download PNG
sem conta e sem enviar nome/foto. Núcleo puro em src/lib/card* com
unit tests; e2e cobre home→deep-link, nome→PNG real, erro fail-closed,
foto e drawer mobile. Sem migration/PII.
This commit is contained in:
Francisco Solla 2026-09-12 02:16:04 -03:00
parent f3c1ad69ea
commit 681f3bb566
28 changed files with 2207 additions and 1 deletions

View file

@ -20,8 +20,9 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
- `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`.
- `src/utilities/<domínio>/` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`).
- `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`. Geometria/catálogo puros em `src/lib/card{Models,NameFit,PhotoTransform,Render}.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/`).
- `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`).
- `src/migrations/` — história congelada; nunca editar retroativamente.
- `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`).

View file

@ -0,0 +1 @@
**S13 — cards personalizados da campanha no site público (2026-09-12):** o visitante agora cria um card de apoio com a identidade oficial e baixa um PNG sem conta e sem enviar nome/foto — tudo processado no aparelho. A home ganhou a seção `Faça parte / Mostre que você está com Solla` (depois da captura de novidades, antes do rodapé) com os três modelos e o CTA `Criar meu card`; a rota nova `/cards` (grupo `(home)`, herda tema `campaign-site` + MetaPixel) concentra catálogo e edição, com deep-link `?model=` sanitizado server-side (`isCardModelId`; valor inválido cai no catálogo). O compositor é uma ilha única (`CardsStudio`) com um editor (`CardComposer`) em dialog central (desktop) ou bottom drawer (mobile, `useIsMobileMeasured`); o núcleo de composição é puro e unit-testado (`cardModels`/`cardNameFit`/`cardPhotoTransform`/`cardRender`, ctx estrutural injetado): o nome entra na área medida do mestre (centro x≈570, cap 106px, tinta `#ffec01`, shrink/wrap até o mínimo legível e erro acionável em vez de corte silencioso) usando a Brexter 700 (`next/font/local`, `document.fonts.load` antes de medir) e as fotos são desenhadas full-bleed sob o overlay-mestre (mesmos PNGs oficiais, saída 1000×1000 e 1000×1440), com cover na janela transparente e pan/zoom clampados (toque, setas, slider de zoom). Assets originais versionados em `public/cards/`; os três modelos saem do mesmo catálogo em `src/lib/cardModels.ts` (home, catálogo e render). Sem migration, sem collection, sem Consent novo, sem PII em rede/analytics; e2e novo no spec `frontend` cobre home→deep-link, nome→resultado→download PNG real, erro de nome longo fail-closed, foto (decode + erro recuperável) e drawer mobile. Manifest de e2e ganhou os prefixos `src/components/cards`/`src/lib/card` → `frontend` e o prewarm dev inclui `/cards`. Plano: `docs/plans/cards-personalizados-campanha-impl.md`.

View file

@ -0,0 +1,192 @@
# Impl: Cards personalizados da campanha no site público
Status: em execução
Atualizado em: 2026-09-12
Issue: #941
Intenção: docs/plans/cards-personalizados-campanha.md
Appetite restante: herdado — ~3–4 dias eng (fases 1–4 somam ≤4 dias; sem migration)
## Leitura da intenção
- **Outcome:** o visitante (majoritariamente no celular) encontra o convite na home com os três modelos, entra em `/cards`, personaliza com nome ou foto, confere a prévia e baixa um PNG com a identidade da campanha — sem conta e sem que nome/foto saiam do aparelho.
- **O que NÃO negociar:**
- Fluxo 100% client-side: sem upload, sem persistência, sem conta, sem analytics de PII; nome/foto nunca saem do aparelho.
- Nenhuma collection/global/migration/CMS novo; artes-mestre intocadas (compor por cima, nunca redesenhar).
- Dimensões reais de saída: nome `1080×1440`; foto quadrada `1000×1000`; foto vertical `1000×1440`.
- Nome nunca cortado silenciosamente: reduzir/quebrar até o mínimo legível; acima disso, erro acionável.
- `/cards` é a única superfície de edição; o download do PNG é o fim (sem API de rede social).
- Copy literais da intenção preservados.
- **O que reavaliar (hipóteses da intenção):**
- “Arquivos originais já estão acessíveis em `/home/fsolla/Documentos/cards/`” — verdade no workstation, **não** em produção: assets e fonte precisam ser provisionados no repo (o Dockerfile copia `src/` e `public/`).
- “overlays responsivos existentes apenas como referência” — o overlay oficial é o próprio PNG mestre; não recriar faixas/contra-formas em CSS/SVG.
- A base do modelo de nome já traz os textos fixos (`#SOU`, `TÔ COM SOLLA`); o compositor desenha **somente o nome** na banda medida — não re-renderizar a frase.
## Abordagem recomendada
```mermaid
flowchart LR
H["Home · CampaignCardsSection"] -->|"Criar meu card"| C["/cards"]
H -->|"/cards?model=id"| C
C -->|"await searchParams"| S["CardsStudio (client)"]
S --> G["CardModelGallery (grid / peek)"]
S --> O{"viewport abaixo de 768px?"}
O -->|"não"| M["Dialog (Radix)"]
O -->|"sim"| W["Drawer (base-ui, bottom)"]
M --> K["CardComposer (editor único)"]
W --> K
K -->|"nome"| NF["cardNameFit"]
K -->|"foto"| PF["cardPhotoTransform"]
NF --> R["cardRender"]
PF --> R
R --> P["prévia no canvas"]
K -->|"Criar meu card"| RES["Resultado"]
RES -->|"Baixar meu card"| DL["PNG no aparelho"]
CM["cardModels — catálogo único"] -.-> G
CM -.-> R
```
**Opções consideradas:** A (rota dentro de `(home)` herdando o layout da campanha + ilha única de edição + núcleo puro em `src/lib/card*`) | B (rota com layout próprio espelhando a home, estilo `artigos/`) | C (dois compositores independentes, um modal e um drawer)
**Recomendação:** A — a home e `/cards` são o mesmo funil e a mesma pele; o layout `(home)` já é o dono do `data-theme="campaign-site"`, do container de scroll e do MetaPixel, e o núcleo puro em `lib` mantém o editor testável sem DOM.
**Rejeitadas:** B porque duplica o dono do tema/scroll/pixel e cria divergência de pele no meio do funil (`artigos/`/`privacidade/` são outro domínio, o editorial); C porque dois editores divergem — é exatamente o rabbit hole “dois fluxos de criação” da intenção; o que muda entre desktop e mobile é a casca, não o editor.
### Decisões de engenharia
1. **Rota/tema — `/cards` dentro de `(home)`.**
Opções: A) `src/app/(frontend)/(home)/cards/page.tsx` herdando `(home)/layout.tsx` (campaign-site + MetaPixel + container `h-dvh overflow-y-auto`) | B) `src/app/(frontend)/cards/` com layout próprio no molde de `artigos/layout.tsx` | C) `(frontend)/cards/` sem layout novo.
Recomendação: A — zero duplicação do que o layout da home já garante; a URL é `/cards` nos três casos e o segmento estático vence o `[type]` dinâmico, sem conflito.
Rejeitadas: B porque reimplementa tema/scroll/pixel (twin do layout) para uma página do mesmo funil; C porque perde os tokens `--pt-*`/`--campaign-*` e o container de scroll interno.
Consequências aceitas: `/cards` dispara o PageView do pixel S10 (sem PII; é o mesmo funil). Portais de Dialog/Drawer montam no `body`, fora do subtree temado: `DialogContent`/`DrawerContent` re-aplicam `data-theme="campaign-site"` (precedente `PetitionSuccessDialog` com `data-theme="petition"`) e não dependem de `--popover` do tema.
2. **`?model=` — leitura server-side com guard.**
Opções: A) `await searchParams` na page e `initialModelId` para a ilha client | B) `useSearchParams` no client + `<Suspense>` | C) `window.location.search` em efeito.
Recomendação: A — o primeiro render já conhece o modelo e a casca abre direto (sem flash de catálogo-fechado); precedente no repo (`(campaign)/campanha/redefinir-senha/page.tsx:22-34`); a página vira dinâmica e isso é gratuito (não lê dados).
Rejeitadas: B porque exige Suspense e ainda pode pintar o catálogo antes de abrir; C porque é o flash pós-mount que a intenção quer evitar e não funciona no SSR.
Guard: `isCardModelId` (puro) sanitiza; `string[]` → primeiro item; id desconhecido → ignora e mostra só o catálogo (sem 404, sem redirect). A seleção feita em `/cards` não reescreve a URL (o param é entrada/deep-link; revisitar só se alguém pedir link do catálogo).
3. **Compositor — uma ilha, um editor, dois shells.**
Opções: A) `CardsStudio` (client) dono de `selectedId`/`open`; `CardComposer` único compartilhado entre `Dialog` (desktop) e `Drawer` (mobile), com o modo nome/foto ramificado pelo `kind` do modelo; lógica pura em `src/lib/card*` | B) compositor modal e compositor drawer independentes | C) um componente por modelo (nome/foto) com estado duplicado.
Recomendação: A — um só estado e um só editor; `useIsMobileMeasured` (`src/hooks/use-mobile.ts:31-34`) evita montar a casca errada no primeiro frame (até medir, renderiza só o catálogo); casca desktop = `ui/dialog` (Radix, precedente `PetitionSuccessDialog`), mobile = `ui/Drawer` (`modal`, `swipeDirection='down'`, `showSwipeHandle`).
Rejeitadas: B porque os editores divergem (o estado de nome/foto/transform vazaria para dois lugares); C porque a diferença nome↔foto é um campo de formulário, não dois fluxos — duplicaria fit/transform/render.
Nota de render: prévia desenha em canvas com tamanho limitado (capped, ex. maior aresta ~720px) e o export desenha na dimensão do modelo; ambos usam o mesmo `cardRender`, então não há divergência de spec.
4. **Fonte — `next/font/local` com o TTF no repo.**
Opções: A) `localFont` em `src/app/(frontend)/fonts.ts` com o arquivo em `src/app/(frontend)/fonts/Brexter-Bold.ttf` | B) `new FontFace(...)` client-side a partir de `public/cards/brexter-700.ttf` | C) fetch do TTF como ArrayBuffer + `FontFace` sem CSS.
Recomendação: A — convenção do repo (`next/font/google` em `(frontend)/layout.tsx:13-17`), self-host + hash + preload, e o arquivo viaja no build (Dockerfile copia `src/`); o client lê `brexterBold.style.fontFamily` e **espera** `document.fonts.load(...)` antes de medir/desenhar (canvas não espera o CSS).
Rejeitadas: B porque carrega manualmente, sem preload/pipeline e divergindo da convenção; C porque reinventa o pipeline do Next sem ganho.
Restrição estrutural: `src/lib` não pode importar `next` (ESLint `no-restricted-imports`), por isso o módulo da fonte vive em `app/` e só a família (string) chega ao núcleo puro.
Fallback documentado: se o build reclamar do formato TTF, converter para WOFF2 na mesma fase (mesma família/permissão), mantendo o path do módulo.
5. **Slot do nome — reproduzir o mestre, não centralizar.**
Opções: A) seguir a geometria medida do mestre: centro x≈570, cap height 106px, ink ≤714px, banda y[430..536], tinta `#ffec01`; reduzir/quebrar até `MIN_CAP_HEIGHT` e acima disso erro acionável | B) centralizar no canvas (x=540) e simplificar o fit.
Recomendação: A — o aceite exige “sem aproximação visual”; o centro do ink do mestre é ~570 (30px fora do centro do canvas) e centralizar desencaixa o nome dos textos fixos da base.
Rejeitadas: B porque viola o aceite de fidelidade ao mestre em um ponto mensurável.
Mecânica: `resolveFontSizeForCapHeight` calibra o corpo a partir de `actualBoundingBoxAscent` de um probe caixa-alta (não usar 106 como `font-size`); fit tenta 1 linha reduzindo, depois 2 linhas por quebra de espaço; nunca quebra no meio da palavra nem trunca; normalização `trim` + colapso de espaços + caixa alta `pt-BR` (o mestre é caixa alta; acentos preservados). Se o natural não reproduzir os gaps do mestre para `FULANO`, fallback é desenho por caractere com tracking explícito (puro, testável) — decidido na Fase 1 com a comparação com a referência preenchida.
6. **e2e — estender `tests/e2e/frontend.e2e.spec.ts`.**
Opções: A) novo `test.describe` no spec `frontend` já coletado + prewarm de `/cards` | B) criar `tests/e2e/cards.e2e.spec.ts` + `testMatch` no `playwright.config.ts` + manifest + pins.
Recomendação: A — menor blast radius: o projeto `frontend` já coleta esse arquivo (`playwright.config.ts:176-181`) e o manifest já mapeia `src/app/(frontend)` → `frontend` (`scripts/lib/e2e-affected-manifest.mjs:62-65`); cobre o que unit não cobre (canvas/`toBlob`/download e o fluxo real de arquivo). Adicionar `/cards` à lista de prewarm do `setup.e2e.spec.ts` (só dev; prod pula o setup).
Rejeitadas: B porque muda config + manifest + pins por nenhum ganho de cobertura, e um spec novo sem `testMatch` nem seria coletado.
Cobertura: home (seção/ordem/3 modelos/hrefs), `/cards` direto (catálogo, composer fechado), `?model=` (composer aberto sem clique), nome → resultado → download real (`waitForEvent('download')`, `.png`, bytes > 0), mobile drawer + foto via `setInputFiles` com buffer em memória, buffer inválido → erro recuperável com `Escolher outra foto`.
Manifest: mapear os prefixos novos `src/components/cards` e `src/lib/card` → `['frontend']`, para diffs futuros dessa superfície não caírem no fallback `campaignHomeActions` (que não cobre o site público).
7. **Assets — `public/cards/` com bytes originais e nomes ASCII.**
Opções: A) copiar os mestres sem re-encode para `public/cards/name-card-base.jpg`, `photo-square-frame.png`, `photo-portrait-frame.png`; catálogo reusa os mesmos arquivos via `next/image` | B) manter os nomes originais (`template-to-com-solla-site-sem-o-nome.jpg.jpeg`) | C) re-encodar/otimizar os mestres.
Recomendação: A — preserva o aceite “sem aproximação visual”, dá paths previsíveis e reusa o mesmo arquivo no catálogo (assets pequenos: ~430KB/142KB/169KB). A fonte vai separada em `src/app/(frontend)/fonts/` (não em `public`).
Rejeitadas: B porque path frágil (extensão dupla, não-ASCII); C porque re-encodar altera a arte-mestre (fora de escopo e risco de perda).
Tile do modelo de nome: `next/image` da base vazia com o placeholder `SEU NOME` sobreposto em HTML posicionado pelos percentuais do slot (centro 52,78%, banda 29,86%); o canvas desenha nome de verdade apenas no compositor.
### Componentes / mudanças
- **`src/app/(frontend)/(home)/cards/page.tsx`** (novo): RSC; `searchParams: Promise<{ model?: string | string[] }>`, primeiro item se array, `isCardModelId`; metadata `Crie seu card de apoio | Jorge Solla`; renderiza `<CardsStudio initialModelId={…} />`.
- **`src/app/(frontend)/(home)/CampaignCardsSection.tsx`** (novo): seção server da home no molde de `CampaignNewsletterSection.tsx` — `id="cards"`, `aria-labelledby`, `data-home-section="cards"`, `mx-auto max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16`, eyebrow/título/corpo com `.campaign-section-*`, CTA `--pt-red`; usa `CardModelGallery variant="link"` (tiles levam a `/cards?model=<id>`).
- **`src/app/(frontend)/(home)/page.tsx`** (editar): `<CampaignCardsSection />` logo após `<CampaignNewsletterSection … />`, dentro do `<main>`, antes de `</main>`/`CampaignFooter`.
- **`src/app/(frontend)/fonts.ts`** + **`src/app/(frontend)/fonts/Brexter-Bold.ttf`** (novos): `export const brexterBold = localFont({ src: './fonts/Brexter-Bold.ttf', weight: '700', style: 'normal', display: 'swap' })`; só a família (string) cruza para os componentes.
- **`src/lib/cardModels.ts`** (novo, puro): `CardModelId`, `CardModel`, `CARD_MODELS`, `isCardModelId`, `NAME_SLOT` (centerX 570 / capHeight 106 / maxInkWidth 714 / bandTop 430 / bandBottom 536 / ink `#ffec01`), `PHOTO_WINDOWS` (quadrada 1000×1000 janela y[0..740]; vertical 1000×1440 janela y[0..1044]), `OUTPUT` dims e paths ASCII. Fonte única do catálogo (home + `/cards` + render).
- **`src/lib/cardNameFit.ts`** (novo, puro): `fitCardName(name, measure, slot)` + `resolveFontSizeForCapHeight`; retorno `{ ok: true; lines; fontSize; lineHeight } | { ok: false; reason: 'too-long' }`; `MIN_CAP_HEIGHT` fill-in (~48px, validado no craft); nunca trunca.
- **`src/lib/cardPhotoTransform.ts`** (novo, puro): cover scale na janela, `clampPhotoTransform`, `photoDrawRect`; zoom 1–4; pan clampado para nunca abrir gap na janela (inclui a contra-forma transparente dentro dos limites da janela).
- **`src/lib/cardRender.ts`** (novo, puro): interfaces estruturais mínimas `CardRenderContext`/`CardTextMetrics` (o `CanvasRenderingContext2D` real as satisfaz); `renderNameCard` (base → nome) e `renderPhotoCard` (foto → máscara, nessa ordem); sem dependência de DOM/Next.
- **`src/components/cards/CardsStudio.tsx`** (novo, client): dono de `selectedId`/`open`; escolhe a casca com `useIsMobileMeasured`; fechar mantém a seleção no catálogo; `Criar outro card` volta ao catálogo.
- **`src/components/cards/CardModelGallery.tsx`** (novo, client): grid de 3 no desktop; scroll-snap com peek do próximo no mobile, sem auto-advance; `variant: 'link' | 'select'`; reusa os mesmos assets do catálogo via `next/image`.
- **`src/components/cards/CardComposer.tsx`** (novo, client): editor único (conteúdo compartilhado entre Dialog e Drawer); passos compose → result; modo pelo `kind` (nome: input + fit + erro inline; foto: file picker + pan/zoom + `Trocar foto`); ações `Cancelar`/`Criar meu card`; resultado `Baixar meu card`/`Criar outro card`; erro de foto com `Escolher outra foto`.
- **`src/components/cards/CardPreviewCanvas.tsx`** (novo, client): carrega assets e espera `document.fonts.load` (só desenha com a fonte real), desenha via `cardRender` em canvas de preview limitado.
- **`src/components/cards/cardCanvas.ts`** (novo, client-safe adapter): `loadCardImage` (`createImageBitmap` + fallback `Image.decode`), `toPngBlob`, `downloadBlob` (Blob → object URL → `<a download>` → revoke, precedente `SupporterImportWizard.tsx:50-58`); filename sem PII (`card-jorge-solla-<modelId>.png`).
- **`src/app/(frontend)/styles.css`** (editar só se o craft criar classe própria, `.campaign-cards-*` sob `[data-theme='campaign-site']`; preferir Tailwind + tokens existentes).
- **`scripts/lib/e2e-affected-manifest.mjs`** (editar): prefixos `src/components/cards` e `src/lib/card` → `['frontend']`.
- **`tests/e2e/setup.e2e.spec.ts`** (editar): `'/cards'` no prewarm.
- **`tests/e2e/frontend.e2e.spec.ts`** (editar): novo describe “Cards personalizados” com os cenários da decisão 6.
- **`tests/unit/cardModels.unit.spec.ts`**, **`tests/unit/cardNameFit.unit.spec.ts`**, **`tests/unit/cardPhotoTransform.unit.spec.ts`**, **`tests/unit/cardRender.unit.spec.ts`** (novos): fake ctx/measure (jsdom não tem canvas); catálogo íntegro, fit (shrink/quebra/mínimo/erro/acentos), clamp do pan/zoom e ordem de desenho.
- **`.agents/rules/codebase-map.mdc`** (editar): registrar `src/components/cards/` na seção “Onde vive o quê”.
- **`docs/changelog/2026-09-12-s13-cards-personalizados.md`** (novo): entrada da entrega.
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
- **Access / Consent:** não se aplica — nenhuma superfície Payload; nenhum dado pessoal trafega; sem chave nova de Consent.
- **UI:** Impeccable C — shape (cenas do rascunho) → craft (geometria do mestre, fonte, estados) → critique (a11y, mobile real, copy) → polish. Shells `ui/dialog` (Radix) e `ui/Drawer` (base-ui) com foco preso/ESC/`DialogTitle`+`DialogDescription`; alvos ≥44px; seleção com `aria-current`/`aria-pressed` + marcador (não só cor); zoom e setas cobrem “não depender de arrastar”; aviso de privacidade antes de escolher a foto.
### Copy (literais da intenção → superfície)
| Literal | Onde entra |
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `Faça parte` | eyebrow da seção da home e de `/cards` |
| `Mostre que você está com Solla` | h2 da seção da home |
| `Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua gente e fortaleça nossa caminhada.` | corpo da seção da home |
| `Criar meu card` | CTA da home e ação primária do composer (compose) |
| `Crie seu card de apoio` | h1 de `/cards` + `<title>` |
| `Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.` | intro de `/cards` |
| `Card com seu nome` / `Moldura quadrada` / `Moldura vertical` | labels dos modelos (catálogo e composer) |
| `Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.` | aviso de privacidade (página e composer, antes da foto) |
| `Trocar foto` | ação do passo de foto |
| `Cancelar` | ação secundária do composer |
| `Seu card está pronto para compartilhar.` | passo resultado |
| `Baixar meu card` / `Criar outro card` | ações do resultado |
Copy de apoio vinda do rascunho UI (não é literal da intenção; validar no craft/critique, não reescrever ao implementar): `Seu nome` (label), `Personalize com seu nome` / `Enquadre sua foto` (títulos do composer), `Zoom` / `Ajuste fino`, `Não foi possível usar esta foto` / `Escolha outra imagem e tente novamente.` / `Escolher outra foto` (erro de foto), `Voltar para a campanha` (voltar). Copy nova do plano, sujeita ao gate humano/critique: mensagem inline de nome longo (“Esse nome não cabe no card. Use um nome mais curto, como você é chamado.”) e estado de carregamento do preview.
### Dados → forma (se aplicável)
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “forma” é a geometria do próprio card, definida pelas artes-mestre. Nome/foto são insumos locais da imagem, nunca dados apresentados ou coletados.
## Fases verificáveis
1. **Tracer ponta a ponta do modelo de nome** — quota ~1,5 dia. Provisiona assets/fonte; `cardModels`/`cardNameFit`/`cardRender` + units; rota `(home)/cards` + `CardsStudio` mínimo + composer de nome + preview + resultado/download; calibra o slot contra a referência preenchida (`FULANO`) no navegador.
Prova: `pnpm gate:fast` verde; `/cards?model=eu-sou-solla` abre o composer direto; download de um PNG `1080×1440` com `João`/`Conceição` legível e encaixado como no mestre.
2. **Catálogo + seção na home** — quota ~1 dia. `CardModelGallery` (grid desktop / peek mobile) e `CampaignCardsSection` depois da newsletter; variantes link/select; a11y e sem overflow horizontal.
Prova: home na ordem certa, 3 modelos visíveis no desktop, 1 + peek no mobile, tiles com `href` `/cards?model=<id>`; e2e de home verde.
3. **Modelos de foto** — quota ~1 dia. `cardPhotoTransform` + unit; file picker, decode, pan/zoom clampados, overlay por cima, erro recuperável, `Trocar foto`; saídas `1000×1000` e `1000×1440`.
Prova: retrato e paisagem enquadram sem gap; arquivo inválido cai no erro sem travar; unit do clamp verde.
4. **Craft/Critique/Polish + e2e + gates** — quota ~0,5 dia. Impeccable C; e2e novo + prewarm + manifest; changelog; `pnpm gate:fast`; e2e local do describe (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Cards personalizados"`); `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não criar collection/global/migration/CMS para modelos (o catálogo é código).
- Não criar um segundo compositor na home: o tile da home é link e só `/cards` edita.
- Não recriar faixas/contra-formas das artes em CSS/SVG; overlay é o PNG mestre.
- Não implementar Web Share API, share de rede social, upload, conta, galeria ou histórico.
- Não extrair carrossel genérico para uma terceira variante agora (precedente documentado em `CampaignContentCarousel.tsx:13-18`); gatilho de revisitação: uma 4ª superfície de carrossel.
- Não adicionar detecção de rosto, crop automático, filtros, textos livres, múltiplos nomes ou tipografia configurável.
- Não testar pixels no jsdom nem snapshot visual no CI; fidelidade ao mestre é provada no craft + download real no e2e.
- Não usar OffscreenCanvas/worker sem evidência de jank (prévia limitada + export final resolvem).
## Riscos e mitigação
- **Fonte não pronta no draw** (medida com fallback desalinha o fit): só desenhar após `document.fonts.load` e `document.fonts.check` na família primária; estado de carregamento no preview.
- **Canvas tainted/cross-origin**: assets same-origin em `public/cards/`; nunca URL externa.
- **Foto HEIC/grande/corrompida**: decode em try/catch com erro recuperável (`Escolher outra foto`); formatos suportados pelo browser seguem funcionando; limite defensivo de tamanho sem bloquear caso comum.
- **Gap no pan/zoom**: clamp puro testado; a foto é desenhada antes do overlay, então nem uma borda vazaria.
- **iOS/Safari**: `toBlob` suportado; testar em iPhone físico no craft e registrar no PR; fallback `toDataURL` no catch.
- **Portais fora do tema**: `data-theme="campaign-site"` no conteúdo portaled e cores fixadas explicitamente (não depender de `--popover`).
- **Peso da home**: `next/image` com `sizes` para os tiles; os assets full só são carregados quando o composer abre (lazy).
- **Cold compile do `/cards` no e2e dev**: entrada no prewarm do `setup.e2e.spec.ts`.
- **Formato TTF no `next/font/local`**: se o build reclamar, converter para WOFF2 na mesma fase (família/permissão inalteradas).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: seção na home após a newsletter; 3 modelos (grid/carrossel com peek); CTA e tiles levam a `/cards`; composer modal desktop/bottom drawer mobile; nome só na banda do mestre, sem corte silencioso; foto com pan/zoom sobre a máscara; aviso de privacidade; resultado + download `PNG`; erro recuperável; toque e teclado.
- [ ] Invariantes AGENTS/engineering-standards: sem schema/migration; sem Consent novo; nenhum PII em rede/analytics; `src/lib` puro (sem Next/Payload); copy pt-BR / identificadores em inglês; sem `as never`; knip sem órfãos; gate `pnpm gate:fast` + `pnpm push` verdes.
- [ ] Testes de domínio previstos: unit dos módulos puros (fit, transform, render, catálogo) obrigatório; e2e no spec `frontend` com download real; **int não se aplica** — não há Payload/DB/access nem escrita multi-collection neste fluxo (100% client-side + assets estáticos).
## Self-score decision-quality: 5/5
1. **Decisões caras com rejeitadas**: rota/tema, `?model=`, estrutura do compositor, fonte, slot do nome, harness de e2e e assets — todas com Opções/Recomendação/Rejeitadas e consequências.
2. **Cabe no appetite**: 4 fases somam ≤4 dias, sem migration, sem infra nova; o tracer do nome vem primeiro.
3. **Rabbit holes nomeados**: coleção/CMS, segundo compositor, redesenho das artes, share social, carrossel genérico, testes de pixel/worker.
4. **Depth check**: reusa shells (`ui/dialog`, `ui/Drawer`, `useIsMobileMeasured`, `next/font`, padrão de seção da home, prewarm/manifest/specs existentes); não cria abstração especulativa.
5. **Intenção preservada**: aceite de produto intacto; engenharia não reescreveu outcome, copy nem geometria do mestre.

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View file

@ -63,6 +63,13 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: ['src/app/(frontend)'],
specs: ['frontend'],
},
{
// S13 — the personalized-cards studio lives in shared cards components and
// pure card* modules; a future diff there must wake the frontend spec (the
// home section is already covered by the src/app/(frontend) prefix).
prefixes: ['src/components/cards', 'src/lib/card'],
specs: ['frontend'],
},
{
prefixes: ['src/app/(campaign)/campanha/login', 'src/utilities/campaignAuth'],
specs: ['campaignAuth'],

View file

@ -0,0 +1,47 @@
import Link from 'next/link'
import { CardModelGallery } from '@/components/cards/CardModelGallery'
/**
* S13 — the home invitation to the card funnel: short copy + the three model
* tiles, positioned after the newsletter capture (which stays the first
* conversion of the page end) and before the footer. Each tile deep-links into
* `/cards` with its model selected; the general CTA opens the plain catalog.
*/
export const CampaignCardsSection = () => (
<section
id="cards"
aria-labelledby="cards-title"
data-home-section="cards"
className="border-b border-(--campaign-line) bg-(--campaign-cream)"
>
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
<div className="mx-auto max-w-2xl text-center">
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Faça parte
</p>
<h2
id="cards-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Mostre que você está com Solla
</h2>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">
Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua
gente e fortaleça nossa caminhada.
</p>
</div>
<CardModelGallery variant="link" ariaLabel="Modelos de card" />
<div className="mt-8 text-center">
<Link
href="/cards"
className="inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-(--pt-red) px-6 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none sm:w-auto"
>
Criar meu card
</Link>
</div>
</div>
</section>
)

View file

@ -0,0 +1,64 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { brexterBold } from '@/app/(frontend)/fonts'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CardsStudio } from '@/components/cards/CardsStudio'
import { isCardModelId } from '@/lib/cardModels'
const intro =
'Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
const privacyNote =
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
export const metadata: Metadata = {
title: 'Crie seu card de apoio | Jorge Solla',
description: intro,
}
type CardsPageProps = {
searchParams: Promise<{ model?: string | string[] }>
}
export default async function CardsPage({ searchParams }: CardsPageProps) {
const params = await searchParams
const rawModel = Array.isArray(params.model) ? params.model[0] : params.model
const initialModelId = isCardModelId(rawModel) ? rawModel : undefined
return (
<>
<main className={`w-full bg-white text-black ${brexterBold.variable}`}>
<div className="mx-auto w-full max-w-[1160px] px-5 pt-6 sm:px-8 lg:px-10">
<Link
href="/"
className="inline-flex min-h-11 items-center text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
← Voltar para a campanha
</Link>
</div>
<section
aria-labelledby="cards-page-title"
className="mx-auto w-full max-w-[1160px] px-5 pt-6 pb-12 sm:px-8 lg:px-10 lg:pt-10 lg:pb-16"
>
<div className="mx-auto max-w-2xl text-center">
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Faça parte
</p>
<h1
id="cards-page-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Crie seu card de apoio
</h1>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">{intro}</p>
<p className="mt-2 text-xs text-(--campaign-muted)">{privacyNote}</p>
</div>
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />
</section>
</main>
<CampaignFooter />
</>
)
}

View file

@ -7,6 +7,7 @@ import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import type { ReactNode } from 'react'
import { CampaignCardsSection } from './CampaignCardsSection'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
/**
@ -236,6 +237,7 @@ export default async function HomePage() {
<CampaignStorySection />
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
<CampaignCardsSection />
</main>
<CampaignFooter />

View file

@ -0,0 +1,18 @@
import localFont from 'next/font/local'
/**
* S13 — Brexter 700, the display face of the official card masters. Web use is
* licensed for the campaign (regular DEMO face is deliberately excluded). The
* font is only consumed by the card canvas, so the family string travels to the
* client and `ensureCardFont` awaits `document.fonts.load` before measuring.
*/
export const brexterBold = localFont({
src: './fonts/Brexter-Bold.ttf',
weight: '700',
style: 'normal',
display: 'swap',
variable: '--font-brexter',
// Canvas-only face: preloading it on every page would be waste (the studio
// loads it on demand via `document.fonts.load`).
preload: false,
})

Binary file not shown.

View file

@ -0,0 +1,486 @@
'use client'
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
MinusIcon,
PlusIcon,
} from 'lucide-react'
import { useEffect, useId, useRef, useState, type PointerEvent } from 'react'
import {
canvasToPngBlob,
cardFileName,
downloadBlob,
ensureCardFont,
loadCardImage,
loadCardPhoto,
} from '@/components/cards/cardCanvas'
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
import type { CardModel } from '@/lib/cardModels'
import type { CardNameFit } from '@/lib/cardNameFit'
import {
CARD_PHOTO_MAX_ZOOM,
CARD_PHOTO_MIN_ZOOM,
centerCardPhotoTransform,
panCardPhotoTransform,
zoomCardPhotoTransform,
type CardPhotoTransform,
} from '@/lib/cardPhotoTransform'
import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender'
const PRIVACY_NOTE =
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
const PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
const primaryButtonClassName =
'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-(--pt-red) px-5 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
const secondaryButtonClassName =
'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-5 text-sm font-extrabold text-(--pt-red) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none'
const controlButtonClassName =
'inline-flex size-11 items-center justify-center rounded-lg border border-(--campaign-line) bg-white text-(--campaign-ink) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none'
type CardComposerProps = {
model: CardModel
shell: 'dialog' | 'drawer'
fontFamily: string
onClose: () => void
}
export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposerProps) => {
const [step, setStep] = useState<'compose' | 'result'>('compose')
const [name, setName] = useState('')
const [nameFit, setNameFit] = useState<CardNameFit | null>(null)
const [photo, setPhoto] = useState<HTMLImageElement | null>(null)
const [photoTransform, setPhotoTransform] = useState<CardPhotoTransform | null>(null)
const [photoError, setPhotoError] = useState<string | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const canvasRef = useRef<HTMLCanvasElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null)
const nameInputId = useId()
const zoomInputId = useId()
const photoWindow = model.photoWindow
const photoSize = photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null
useEffect(() => {
let cancelled = false
const draw = async () => {
const ctx = canvasRef.current?.getContext('2d')
if (!ctx) return
setLoadState('loading')
try {
await ensureCardFont(fontFamily)
const base = await loadCardImage(model.assetSrc)
if (cancelled) return
ctx.clearRect(0, 0, model.width, model.height)
if (model.kind === 'name') {
const fit = renderNameCard(ctx, model, {
image: base,
name,
fontFamily,
measure: createCardMeasure(ctx, fontFamily),
})
if (!cancelled) setNameFit(fit)
} else if (photo && photoTransform && photoWindow) {
const clamped = renderPhotoCard(ctx, {
photo,
frame: base,
photoSize: { width: photo.naturalWidth, height: photo.naturalHeight },
frameSize: { width: model.width, height: model.height },
window: photoWindow,
transform: photoTransform,
})
if (
!cancelled &&
(clamped.zoom !== photoTransform.zoom ||
clamped.offsetX !== photoTransform.offsetX ||
clamped.offsetY !== photoTransform.offsetY)
) {
setPhotoTransform(clamped)
}
} else if (photoWindow) {
ctx.fillStyle = '#ded8d2'
ctx.fillRect(0, 0, model.width, model.height)
ctx.drawImage(base, 0, 0, model.width, model.height)
}
if (!cancelled) setLoadState('ready')
} catch {
if (!cancelled) setLoadState('error')
}
}
void draw()
return () => {
cancelled = true
}
}, [model, name, photo, photoTransform, photoWindow, fontFamily])
const handlePhotoFile = async (file: File | undefined) => {
if (!file || !photoWindow) return
setPhotoError(null)
try {
const image = await loadCardPhoto(file)
setPhoto(image)
setPhotoTransform(
centerCardPhotoTransform(
{ width: image.naturalWidth, height: image.naturalHeight },
photoWindow,
),
)
} catch {
setPhoto(null)
setPhotoTransform(null)
setPhotoError('Não foi possível usar esta foto. Escolha outra imagem e tente novamente.')
} finally {
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
if (!photoSize || !photoTransform || !photoWindow) return
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
event.currentTarget.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current
const canvas = canvasRef.current
if (
!drag ||
drag.pointerId !== event.pointerId ||
!photoSize ||
!photoTransform ||
!photoWindow
)
return
if (!canvas) return
const scale = model.width / Math.max(1, canvas.getBoundingClientRect().width)
const dx = (event.clientX - drag.x) * scale
const dy = (event.clientY - drag.y) * scale
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
setPhotoTransform(panCardPhotoTransform(photoTransform, photoSize, photoWindow, dx, dy))
}
const handlePointerEnd = () => {
dragRef.current = null
}
const panBy = (dx: number, dy: number) => {
if (!photoSize || !photoTransform || !photoWindow) return
setPhotoTransform(panCardPhotoTransform(photoTransform, photoSize, photoWindow, dx, dy))
}
const zoomTo = (zoom: number) => {
if (!photoSize || !photoTransform || !photoWindow) return
setPhotoTransform(zoomCardPhotoTransform(photoTransform, photoSize, photoWindow, zoom))
}
const handleDownload = async () => {
const canvas = canvasRef.current
if (!canvas) return
setIsDownloading(true)
setDownloadError(null)
try {
const blob = await canvasToPngBlob(canvas)
downloadBlob(blob, cardFileName(model.id))
} catch {
setDownloadError('Não foi possível gerar o arquivo agora. Tente de novo.')
} finally {
setIsDownloading(false)
}
}
const isNameModel = model.kind === 'name'
const nameCanAdvance = nameFit?.ok === true
const canAdvance = isNameModel ? nameCanAdvance : photo !== null
const nameError = isNameModel && name.trim().length > 0 && nameFit !== null && !nameFit.ok
const title =
step === 'result'
? 'Seu card está pronto para compartilhar.'
: isNameModel
? 'Personalize com seu nome'
: 'Enquadre sua foto'
const description =
step === 'result' || isNameModel
? null
: 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
const header =
shell === 'dialog' ? (
<DialogHeader className="min-w-0 text-left">
<DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance">
{title}
</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
) : (
<DrawerHeader className="min-w-0 text-left">
<DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance">
{title}
</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
)
return (
<div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)">
<div className="flex items-start gap-3 px-5 pt-5">
{header}
<button
type="button"
onClick={onClose}
aria-label="Fechar"
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-xl leading-none text-(--campaign-muted) transition-colors hover:bg-(--campaign-band) hover:text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
×
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 pb-5">
<p className="sr-only" role="status" aria-live="polite">
{loadState === 'loading' ? 'Carregando prévia…' : ''}
</p>
<div
className={`relative mx-auto w-full max-w-[22rem] ${photo ? 'touch-none' : ''}`}
tabIndex={photo ? 0 : undefined}
role={photo ? 'group' : undefined}
aria-label={photo ? 'Ajuste da foto' : undefined}
onKeyDown={
photo
? (event) => {
const step = event.shiftKey ? 48 : 12
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
}
const delta = deltas[event.key]
if (!delta) return
event.preventDefault()
panBy(delta[0], delta[1])
}
: undefined
}
>
<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'
}`}
onPointerDown={photo ? handlePointerDown : undefined}
onPointerMove={photo ? handlePointerMove : undefined}
onPointerUp={photo ? handlePointerEnd : undefined}
onPointerCancel={photo ? handlePointerEnd : undefined}
/>
</div>
{loadState === 'error' ? (
<p role="alert" className="mt-3 text-sm font-semibold text-(--pt-red)">
Não foi possível carregar o modelo. Recarregue a página e tente de novo.
</p>
) : null}
{step === 'compose' && isNameModel ? (
<div className="mt-5">
<label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)">
Seu nome
</label>
<input
id={nameInputId}
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
maxLength={60}
className="mt-2 h-11 w-full rounded-lg border border-(--field-border) bg-white px-3 text-base text-(--campaign-ink) focus-visible:border-(--pt-red) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
/>
<p className="mt-2 text-xs text-(--campaign-muted)">
Use seu primeiro nome ou o nome pelo qual as pessoas conhecem você.
</p>
{nameError ? (
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é
chamado.
</p>
) : null}
<p className="mt-2 text-xs text-(--campaign-muted)">{PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'compose' && !isNameModel && photoWindow ? (
<div className="mt-5">
{photo && photoTransform ? (
<>
<div className="flex items-center gap-3">
<label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)">
Zoom
</label>
<button
type="button"
aria-label="Diminuir zoom"
onClick={() => zoomTo(photoTransform.zoom - 0.1)}
className={controlButtonClassName}
>
<MinusIcon className="size-4" aria-hidden="true" />
</button>
<input
id={zoomInputId}
type="range"
min={CARD_PHOTO_MIN_ZOOM}
max={CARD_PHOTO_MAX_ZOOM}
step={0.05}
value={photoTransform.zoom}
onChange={(event) => zoomTo(Number(event.target.value))}
className="h-11 w-full flex-1 accent-(--pt-red)"
/>
<button
type="button"
aria-label="Aumentar zoom"
onClick={() => zoomTo(photoTransform.zoom + 0.1)}
className={controlButtonClassName}
>
<PlusIcon className="size-4" aria-hidden="true" />
</button>
</div>
<div className="mt-4">
<p className="text-sm font-bold text-(--campaign-ink)">Ajuste fino</p>
<div className="mt-2 grid grid-cols-4 gap-2">
<button
type="button"
aria-label="Mover foto para a esquerda"
onClick={() => panBy(-12, 0)}
className={controlButtonClassName}
>
<ArrowLeftIcon className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Mover foto para cima"
onClick={() => panBy(0, -12)}
className={controlButtonClassName}
>
<ArrowUpIcon className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Mover foto para baixo"
onClick={() => panBy(0, 12)}
className={controlButtonClassName}
>
<ArrowDownIcon className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Mover foto para a direita"
onClick={() => panBy(12, 0)}
className={controlButtonClassName}
>
<ArrowRightIcon className="size-4" aria-hidden="true" />
</button>
</div>
</div>
</>
) : (
<p className="text-sm text-(--campaign-muted)">
Escolha uma foto vertical ou horizontal para começar.
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(event) => void handlePhotoFile(event.target.files?.[0])}
/>
{photoError ? (
<div role="alert" className="mt-4 rounded-lg border border-(--pt-red) bg-white p-3">
<p className="text-sm font-bold text-(--pt-red)">Não foi possível usar esta foto</p>
<p className="mt-1 text-sm text-(--campaign-muted)">
Escolha outra imagem e tente novamente.
</p>
</div>
) : null}
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={secondaryButtonClassName}
>
{photoError ? 'Escolher outra foto' : photo ? 'Trocar foto' : 'Escolher foto'}
</button>
</div>
<p className="mt-3 text-xs text-(--campaign-muted)">{PHOTO_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'result' ? (
<div className="mt-5">
<p className="text-sm text-(--campaign-muted)">{PRIVACY_NOTE}</p>
{downloadError ? (
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
{downloadError}
</p>
) : null}
</div>
) : null}
</div>
<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>
<button
type="button"
disabled={!canAdvance || loadState !== 'ready'}
onClick={() => setStep('result')}
className={primaryButtonClassName}
>
Criar meu card
</button>
</>
) : (
<>
<button type="button" onClick={onClose} className={secondaryButtonClassName}>
Criar outro card
</button>
<button
type="button"
disabled={isDownloading || loadState !== 'ready'}
onClick={() => void handleDownload()}
className={primaryButtonClassName}
>
{isDownloading ? 'Gerando…' : 'Baixar meu card'}
</button>
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,92 @@
import Link from 'next/link'
import { CardModelTile } from '@/components/cards/CardModelTile'
import { CARD_MODELS, cardModelHref, type CardModel, type CardModelId } from '@/lib/cardModels'
type CardModelGalleryProps = {
variant: 'link' | 'select'
ariaLabel: string
selectedId?: CardModelId | null
onSelect?: (id: CardModelId) => void
}
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)'
/**
* S13 — the three models side by side on desktop and a one-item-with-peek
* snap track on mobile. `link` renders deep links (home funnel), `select`
* renders buttons for the `/cards` studio; both come from the same catalog.
*/
export const CardModelGallery = ({
variant,
ariaLabel,
selectedId = null,
onSelect,
}: CardModelGalleryProps) => {
const renderItem = (model: CardModel) => {
const selected = variant === 'select' && selectedId === model.id
const content = (
<>
<CardModelTile model={model} selected={selected} />
<span className="mt-auto block pt-3 text-center text-sm font-bold text-(--campaign-ink) sm:text-base">
{model.label}
</span>
</>
)
if (variant === 'select') {
return (
<button
type="button"
aria-pressed={selected}
aria-label={`${model.label}${selected ? ' (selecionado)' : ''}`}
onClick={() => onSelect?.(model.id)}
className={tileClassName}
>
{content}
</button>
)
}
return (
<Link
href={cardModelHref(model.id)}
aria-label={`${model.label} — criar meu card`}
className={tileClassName}
>
{content}
</Link>
)
}
return (
<div className="mt-8">
<ul
aria-label={`${ariaLabel} (desktop)`}
className="m-0 hidden list-none grid-cols-3 gap-5 p-0 sm:gap-6 lg:grid"
>
{CARD_MODELS.map((model) => (
<li key={model.id} className="m-0">
{renderItem(model)}
</li>
))}
</ul>
<ul
aria-label={`${ariaLabel} (mobile)`}
className="scrollbar-hide m-0 flex list-none snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-5 p-0 pb-2 lg:hidden"
>
{CARD_MODELS.map((model) => (
<li key={model.id} className="m-0 flex w-[78%] shrink-0 snap-start sm:w-[58%]">
{renderItem(model)}
</li>
))}
</ul>
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
Deslize para ver os três modelos
</p>
</div>
)
}

View file

@ -0,0 +1,79 @@
import { CheckIcon, UserRoundIcon } from 'lucide-react'
import Image from 'next/image'
import { NAME_CARD_SLOT, type CardModel } from '@/lib/cardModels'
import { cn } from '@/lib/utils'
const NAME_OVERLAY_LEFT = `${(NAME_CARD_SLOT.centerX / 1080) * 100}%`
const NAME_OVERLAY_TOP = `${(NAME_CARD_SLOT.capTop / 1440) * 100}%`
/**
* S13 — artwork preview of one card model, shared by the home section and the
* `/cards` gallery. The name tile reuses the empty master with an HTML
* `SEU NOME` placeholder (the real name is only drawn by the canvas); the photo
* tiles show the official transparent overlay above a neutral photo slot.
*/
export const CardModelTile = ({
model,
selected = false,
priority = false,
}: {
model: CardModel
selected?: boolean
priority?: boolean
}) => (
<span
className={cn(
'relative block overflow-hidden rounded-xl border bg-(--campaign-band) transition duration-200',
selected
? 'border-(--pt-red) ring-2 ring-(--pt-red)'
: 'border-(--campaign-line) group-hover:-translate-y-0.5 group-hover:shadow-lg',
)}
style={{ aspectRatio: `${model.width} / ${model.height}`, containerType: 'inline-size' }}
>
{model.kind === 'photo' ? (
<>
<span
aria-hidden="true"
className="absolute inset-0 bg-[linear-gradient(160deg,#ded8d2,#f7f3ef)]"
/>
{model.photoWindow ? (
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 flex items-center justify-center"
style={{
height: `${((model.photoWindow.y + model.photoWindow.height) / model.height) * 100}%`,
}}
>
<UserRoundIcon className="h-[30%] w-auto text-(--campaign-muted) opacity-45" />
</span>
) : null}
</>
) : null}
<Image
src={model.assetSrc}
alt=""
fill
priority={priority}
sizes="(min-width: 1024px) 340px, 78vw"
className="object-cover"
/>
{model.kind === 'name' ? (
<span
aria-hidden="true"
className="absolute -translate-x-1/2 font-[family-name:var(--font-exo2)] leading-none font-black tracking-[-0.02em] text-[#ffec01]"
style={{ left: NAME_OVERLAY_LEFT, top: NAME_OVERLAY_TOP, fontSize: '11.5cqw' }}
>
SEU NOME
</span>
) : null}
{selected ? (
<span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md">
<CheckIcon className="size-4" aria-hidden="true" />
</span>
) : null}
</span>
)

View file

@ -0,0 +1,26 @@
import type { HTMLAttributes, RefObject } from 'react'
/**
* S13 — the canvas element of the card studio. Presentational only: the
* composer owns the ref, the draw effect and the pointer/keyboard controls.
*/
export const CardPreviewCanvas = ({
model,
canvasRef,
className,
...props
}: {
model: { width: number; height: number; label: string }
canvasRef: RefObject<HTMLCanvasElement | null>
className?: string
} & HTMLAttributes<HTMLCanvasElement>) => (
<canvas
ref={canvasRef}
width={model.width}
height={model.height}
role="img"
aria-label={`Prévia do card: ${model.label}`}
className={className}
{...props}
/>
)

View file

@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
import { CardComposer } from '@/components/cards/CardComposer'
import { CardModelGallery } from '@/components/cards/CardModelGallery'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Drawer, DrawerContent } from '@/components/ui/Drawer'
import { useIsMobileMeasured } from '@/hooks/use-mobile'
import { getCardModel, type CardModelId } from '@/lib/cardModels'
/**
* S13 — the `/cards` studio island: owns which model is selected and whether
* the composer is open, renders the catalog and mounts the single editor in a
* centered dialog (desktop) or a bottom drawer (mobile). Closing returns to the
* catalog with the selection kept; the URL is only read, never rewritten.
*/
export const CardsStudio = ({
initialModelId,
fontFamily,
}: {
initialModelId?: CardModelId
fontFamily: string
}) => {
const initialModel = initialModelId ? getCardModel(initialModelId) : undefined
const [selectedId, setSelectedId] = useState<CardModelId | null>(initialModel?.id ?? null)
const [open, setOpen] = useState(Boolean(initialModel))
const { isMobile, measured } = useIsMobileMeasured()
const selectedModel = selectedId ? getCardModel(selectedId) : undefined
const handleSelect = (id: CardModelId) => {
setSelectedId(id)
setOpen(true)
}
return (
<div>
<CardModelGallery
variant="select"
ariaLabel="Modelos de card"
selectedId={selectedId}
onSelect={handleSelect}
/>
{selectedModel && measured ? (
isMobile ? (
<Drawer open={open} onOpenChange={setOpen} swipeDirection="down" showSwipeHandle>
<DrawerContent
data-theme="campaign-site"
className="bg-background text-foreground [--drawer-content-max-height:92dvh]"
>
<CardComposer
model={selectedModel}
shell="drawer"
fontFamily={fontFamily}
onClose={() => setOpen(false)}
/>
</DrawerContent>
</Drawer>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
data-theme="campaign-site"
showCloseButton={false}
className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:max-w-lg"
>
<CardComposer
model={selectedModel}
shell="dialog"
fontFamily={fontFamily}
onClose={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
) : null}
</div>
)
}

View file

@ -0,0 +1,74 @@
/**
* S13 — browser adapters for the card studio. Kept out of `src/lib` because
* they touch DOM APIs; the pure geometry stays in `cardRender`/`cardNameFit`/
* `cardPhotoTransform`. Imports of the model assets are cached so reopening a
* composer never re-downloads the master.
*/
const CARD_IMAGE_CACHE = new Map<string, Promise<HTMLImageElement>>()
export const loadCardImage = (src: string): Promise<HTMLImageElement> => {
const cached = CARD_IMAGE_CACHE.get(src)
if (cached) return cached
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.decoding = 'async'
image.onload = () => resolve(image)
image.onerror = () => {
CARD_IMAGE_CACHE.delete(src)
reject(new Error(`card-image-load-failed:${src}`))
}
image.src = src
})
CARD_IMAGE_CACHE.set(src, promise)
return promise
}
export const loadCardPhoto = (file: File): Promise<HTMLImageElement> =>
new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(file)
const image = new Image()
image.decoding = 'async'
image.onload = () => {
URL.revokeObjectURL(url)
resolve(image)
}
image.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('card-photo-load-failed'))
}
image.src = url
})
/** Canvas does not wait for CSS fonts: the family must be loaded before measuring. */
export const ensureCardFont = async (fontFamily: string): Promise<boolean> => {
if (typeof document === 'undefined' || !document.fonts) return false
try {
await document.fonts.load(`700 100px ${fontFamily}`)
return document.fonts.check(`700 100px ${fontFamily}`)
} catch {
return false
}
}
export const canvasToPngBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error('card-png-unavailable'))
}, 'image/png')
})
export const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
export const cardFileName = (modelId: string): string => `card-jorge-solla-${modelId}.png`

90
src/lib/cardModels.ts Normal file
View file

@ -0,0 +1,90 @@
/**
* S13 — the three personalized-card models of the campaign site. Pure data so
* the home section, the `/cards` gallery and the canvas renderer share one
* catalog (no second editor, no CMS): each entry carries the master asset, the
* real output size in pixels and, for photo models, the transparent window the
* visitor's photo must cover.
*
* Geometry comes from the master files (pixel-measured, keep in sync with
* `docs/plans/cards-personalizados-campanha-impl.md`): the photo frames are
* 1000×1000 and 1000×1440 despite the `1080x` in the original file names.
*/
const CARD_MODEL_IDS = ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular'] as const
export type CardModelId = (typeof CARD_MODEL_IDS)[number]
type CardModelKind = 'name' | 'photo'
export type CardRect = {
x: number
y: number
width: number
height: number
}
export type CardModel = {
id: CardModelId
kind: CardModelKind
label: string
assetSrc: string
width: number
height: number
/** Photo models only: the transparent window the photo must cover. */
photoWindow?: CardRect
}
export const CARD_MODELS: readonly CardModel[] = [
{
id: 'eu-sou-solla',
kind: 'name',
label: 'Card com seu nome',
assetSrc: '/cards/name-card-base.jpg',
width: 1080,
height: 1440,
},
{
id: 'perfil-quadrado',
kind: 'photo',
label: 'Moldura quadrada',
assetSrc: '/cards/photo-square-frame.png',
width: 1000,
height: 1000,
photoWindow: { x: 0, y: 0, width: 1000, height: 740 },
},
{
id: 'perfil-retangular',
kind: 'photo',
label: 'Moldura vertical',
assetSrc: '/cards/photo-portrait-frame.png',
width: 1000,
height: 1440,
photoWindow: { x: 0, y: 0, width: 1000, height: 1044 },
},
]
/**
* Name model slot, pixel-measured from the filled master (`FULANO` example):
* glyph cap band tops at y=430 with a 106px cap height, ink spans x[213..926]
* (714px) centered at x≈570 — not at the canvas center. The visitor's name is
* the only thing drawn over the base; the fixed lines already live in the art.
*/
export const NAME_CARD_SLOT = {
centerX: 570,
capTop: 430,
capHeight: 106,
maxInkWidth: 714,
maxBlockBottom: 630,
minCapHeight: 48,
fill: '#ffec01',
maxLines: 2,
} as const
export const BRAND_FALLBACK_FONT = 'sans-serif'
export const isCardModelId = (value: unknown): value is CardModelId =>
typeof value === 'string' && (CARD_MODEL_IDS as readonly string[]).includes(value)
export const getCardModel = (id: CardModelId): CardModel | undefined =>
CARD_MODELS.find((model) => model.id === id)
export const cardModelHref = (id: CardModelId): string => `/cards?model=${id}`

151
src/lib/cardNameFit.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* S13 — pure fit math for the name card. The visitor's name must never be cut
* silently: it starts at the master's cap height (106px), shrinks and — when a
* single line would drop below the readable minimum — wraps into two lines
* before shrinking further. Past `minCapHeight` the fit fails and the composer
* asks for a shorter name.
*
* Text measurement is injected (`CardMeasureText`) so the module stays free of
* DOM and unit-testable with a deterministic fake.
*/
import { NAME_CARD_SLOT } from './cardModels'
type CardTextMetrics = {
width: number
actualBoundingBoxAscent: number
actualBoundingBoxDescent: number
}
export type CardMeasureText = (text: string, fontSize: number) => CardTextMetrics
export type CardNameFit =
| {
ok: true
lines: string[]
fontSize: number
lineHeight: number
capHeight: number
}
| { ok: false; reason: 'empty' | 'too-long' }
export type CardNameSlot = typeof NAME_CARD_SLOT
const CAP_PROBE = 'X'
const LINE_HEIGHT_RATIO = 1.25
const MEASURE_PROBE_SIZE = 100
export const normalizeCardName = (name: string): string =>
name.trim().replace(/\s+/g, ' ').toLocaleUpperCase('pt-BR')
export const resolveFontSizeForCapHeight = (
measure: CardMeasureText,
capHeight: number,
): number => {
const probe = measure(CAP_PROBE, MEASURE_PROBE_SIZE)
if (probe.actualBoundingBoxAscent <= 0) return capHeight
return (capHeight / probe.actualBoundingBoxAscent) * MEASURE_PROBE_SIZE
}
const widthAt = (measure: CardMeasureText, text: string, fontSize: number): number =>
Math.max(0, measure(text, fontSize).width)
const capAt = (measure: CardMeasureText, fontSize: number): number =>
Math.max(0, measure(CAP_PROBE, fontSize).actualBoundingBoxAscent)
const fitSingleLine = (
text: string,
measure: CardMeasureText,
slot: CardNameSlot,
): number | null => {
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
const idealWidth = widthAt(measure, text, idealSize)
if (idealWidth <= slot.maxInkWidth) return idealSize
const shrunk = Math.floor((idealSize * slot.maxInkWidth) / idealWidth)
if (shrunk <= 0) return null
if (widthAt(measure, text, shrunk) > slot.maxInkWidth) return null
return shrunk
}
const fitTwoLines = (
words: string[],
measure: CardMeasureText,
slot: CardNameSlot,
): { fontSize: number; lines: [string, string] } | null => {
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
const maxBlockHeight = slot.maxBlockBottom - slot.capTop
let best: { fontSize: number; lines: [string, string] } | null = null
for (let split = 1; split < words.length; split++) {
const lines: [string, string] = [words.slice(0, split).join(' '), words.slice(split).join(' ')]
const widest = Math.max(
widthAt(measure, lines[0], idealSize),
widthAt(measure, lines[1], idealSize),
)
if (widest <= 0) continue
let size = Math.min(idealSize, (idealSize * slot.maxInkWidth) / widest)
const capHeight = capAt(measure, size)
const blockHeight = size * LINE_HEIGHT_RATIO + capHeight
if (blockHeight > maxBlockHeight) {
size *= maxBlockHeight / blockHeight
}
size = Math.floor(size)
if (size <= 0) continue
const lineWidths = [widthAt(measure, lines[0], size), widthAt(measure, lines[1], size)]
const sizeCap = capAt(measure, size)
const sizeBlock = size * LINE_HEIGHT_RATIO + sizeCap
if (lineWidths.some((width) => width > slot.maxInkWidth)) continue
if (sizeBlock > maxBlockHeight) continue
if (best === null || size > best.fontSize) best = { fontSize: size, lines }
}
return best
}
/**
* Fits `name` to the master slot honoring `maxLines`. The returned geometry is
* what `renderNameCard` draws: `capHeight` is the measured cap at the chosen
* size and `lineHeight` is the baseline-to-baseline distance.
*/
export const fitCardName = (
name: string,
measure: CardMeasureText,
slot: CardNameSlot = NAME_CARD_SLOT,
): CardNameFit => {
const normalized = normalizeCardName(name)
if (!normalized) return { ok: false, reason: 'empty' }
const minFontSize = resolveFontSizeForCapHeight(measure, slot.minCapHeight)
const singleSize = fitSingleLine(normalized, measure, slot)
if (singleSize !== null && singleSize >= minFontSize) {
return {
ok: true,
lines: [normalized],
fontSize: singleSize,
lineHeight: singleSize * LINE_HEIGHT_RATIO,
capHeight: capAt(measure, singleSize),
}
}
const words = normalized.split(' ')
if (slot.maxLines >= 2 && words.length > 1) {
const twoLines = fitTwoLines(words, measure, slot)
if (twoLines !== null && twoLines.fontSize >= minFontSize) {
return {
ok: true,
lines: twoLines.lines,
fontSize: twoLines.fontSize,
lineHeight: twoLines.fontSize * LINE_HEIGHT_RATIO,
capHeight: capAt(measure, twoLines.fontSize),
}
}
}
return { ok: false, reason: 'too-long' }
}

View file

@ -0,0 +1,126 @@
/**
* S13 — pure cover/pan/zoom math for the photo card models. The visitor photo
* is drawn full-bleed under the master overlay and must always cover the
* transparent window: `clampCardPhotoTransform` keeps the zoom inside
* [1, 4] and pins the offsets so no gap can open at the window edges.
*
* `centerCardPhotoTransform` is the initial framing (cover on the window, not
* on the whole card) so the face lands on the visible area of the frame.
*/
import type { CardRect } from './cardModels'
export const CARD_PHOTO_MIN_ZOOM = 1
export const CARD_PHOTO_MAX_ZOOM = 4
export type CardPhotoSize = {
width: number
height: number
}
export type CardPhotoTransform = {
zoom: number
offsetX: number
offsetY: number
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max)
export const coverScale = (source: CardPhotoSize, window: CardRect): number => {
if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1
return Math.max(window.width / source.width, window.height / source.height)
}
export const cardPhotoDrawRect = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
): CardRect => {
const clamped = clampCardPhotoTransform(transform, source, window)
const scale = coverScale(source, window) * clamped.zoom
return {
x: clamped.offsetX,
y: clamped.offsetY,
width: source.width * scale,
height: source.height * scale,
}
}
export const clampCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
): CardPhotoTransform => {
const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
const scale = coverScale(source, window) * zoom
const width = source.width * scale
const height = source.height * scale
return {
zoom,
offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x),
offsetY: clamp(transform.offsetY, window.y + window.height - height, window.y),
}
}
export const centerCardPhotoTransform = (
source: CardPhotoSize,
window: CardRect,
): CardPhotoTransform => {
const scale = coverScale(source, window)
const width = source.width * scale
const height = source.height * scale
return {
zoom: CARD_PHOTO_MIN_ZOOM,
offsetX: window.x + (window.width - width) / 2,
offsetY: window.y + (window.height - height) / 2,
}
}
/** Nudges the offset by a step in card pixels, clamped to the window. */
export const panCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
dx: number,
dy: number,
): CardPhotoTransform =>
clampCardPhotoTransform(
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
source,
window,
)
/**
* Rescales around a window anchor (used by the zoom control): keeps the point
* under `anchor` stable from old to new zoom, then clamps.
*/
export const zoomCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
nextZoom: number,
anchor: { x: number; y: number } = {
x: window.x + window.width / 2,
y: window.y + window.height / 2,
},
): CardPhotoTransform => {
const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window)
const ratio = zoom / transform.zoom
return clampCardPhotoTransform(
{
zoom,
offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio,
offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio,
},
source,
window,
)
}

114
src/lib/cardRender.ts Normal file
View file

@ -0,0 +1,114 @@
/**
* S13 — the single canvas renderer shared by the preview and the PNG export:
* `renderNameCard` draws the master base then the fitted name; `renderPhotoCard`
* draws the visitor photo full-bleed then the master overlay (the overlay is
* authoritative — never redraw bands or contra-forms). The context is a
* structural subset of `CanvasRenderingContext2D` so unit tests drive a fake
* recorder instead of jsdom canvas.
*/
import { BRAND_FALLBACK_FONT, NAME_CARD_SLOT, type CardModel, type CardRect } from './cardModels'
import {
fitCardName,
type CardMeasureText,
type CardNameFit,
type CardNameSlot,
} from './cardNameFit'
import {
cardPhotoDrawRect,
clampCardPhotoTransform,
type CardPhotoSize,
type CardPhotoTransform,
} from './cardPhotoTransform'
export type CardDrawContext = {
font: string
fillStyle: string | CanvasGradient | CanvasPattern
textAlign: CanvasTextAlign
textBaseline: CanvasTextBaseline
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void
fillText(text: string, x: number, y: number): void
measureText(text: string): CardTextMetricsLike
}
type CardTextMetricsLike = {
width: number
actualBoundingBoxAscent: number
actualBoundingBoxDescent: number
}
export const createCardMeasure = (
ctx: Pick<CardDrawContext, 'font' | 'measureText'>,
fontFamily: string,
): CardMeasureText => {
return (text, fontSize) => {
ctx.font = `700 ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}`
const metrics = ctx.measureText(text)
return {
width: metrics.width,
actualBoundingBoxAscent: metrics.actualBoundingBoxAscent ?? 0,
actualBoundingBoxDescent: metrics.actualBoundingBoxDescent ?? 0,
}
}
}
export type NameCardRenderArgs = {
image: CanvasImageSource
name: string
fontFamily: string
measure: CardMeasureText
slot?: CardNameSlot
}
export const renderNameCard = (
ctx: CardDrawContext,
model: CardModel,
args: NameCardRenderArgs,
): CardNameFit => {
const slot = args.slot ?? NAME_CARD_SLOT
ctx.drawImage(args.image, 0, 0, model.width, model.height)
const fit = fitCardName(args.name, args.measure, slot)
if (!fit.ok) return fit
ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}`
ctx.fillStyle = slot.fill
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
if (fit.lines.length === 1) {
ctx.fillText(fit.lines[0], slot.centerX, slot.capTop + fit.capHeight)
return fit
}
const availableHeight = slot.maxBlockBottom - slot.capTop
const blockHeight = fit.lineHeight + fit.capHeight
const blockTop = slot.capTop + Math.max(0, (availableHeight - blockHeight) / 2)
fit.lines.forEach((line, index) => {
ctx.fillText(line, slot.centerX, blockTop + fit.capHeight + index * fit.lineHeight)
})
return fit
}
export type PhotoCardRenderArgs = {
photo: CanvasImageSource
frame: CanvasImageSource
photoSize: CardPhotoSize
frameSize: CardPhotoSize
window: CardRect
transform: CardPhotoTransform
}
export const renderPhotoCard = (
ctx: CardDrawContext,
args: PhotoCardRenderArgs,
): CardPhotoTransform => {
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window)
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
ctx.drawImage(args.frame, 0, 0, args.frameSize.width, args.frameSize.height)
return clampCardPhotoTransform(args.transform, args.photoSize, args.window)
}

View file

@ -1,10 +1,19 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub'
import { expect, test } from './fixtures/e2eTest'
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
// 4×3 opaque PNG — enough to exercise decode/cover/clamp without a binary fixture.
const TEST_PHOTO_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVR4nGOQqzjxHxkzEBQAAMHJHF0PRQWkAAAAAElFTkSuQmCC',
'base64',
)
const swipeLeft = async (
page: Page,
box: { x: number; y: number; width: number; height: number },
@ -1646,3 +1655,130 @@ test.describe('Campaign home content section', () => {
}
})
})
test.describe('Cards personalizados (S13)', () => {
test('home invites to the card funnel right after the newsletter capture', async ({ page }) => {
await page.goto('/')
const order = await page
.locator('[data-home-section]')
.evaluateAll((sections) =>
sections.map((section) => section.getAttribute('data-home-section')),
)
expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') + 1)
const section = page.locator('section#cards')
await expect(
section.getByRole('heading', { name: 'Mostre que você está com Solla' }),
).toBeVisible()
for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular']) {
await expect(section.locator(`a[href="/cards?model=${model}"]`).first()).toBeAttached()
}
await section.getByRole('link', { name: /Moldura quadrada/ }).click()
await expect(page).toHaveURL(/\/cards\?model=perfil-quadrado$/)
const dialog = page.getByRole('dialog')
await expect(dialog.getByRole('heading', { name: 'Enquadre sua foto' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('name card fits the master, reaches the result and downloads a real PNG', async ({
page,
}) => {
await page.goto('/cards')
await expect(page.getByRole('dialog')).toHaveCount(0)
await page.getByRole('button', { name: 'Card com seu nome' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible()
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
await expect(primary).toBeDisabled()
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('João')
await expect(primary).toBeEnabled()
const canvas = dialog.locator('canvas')
await expect(canvas).toHaveAttribute('width', '1080')
await expect(canvas).toHaveAttribute('height', '1440')
await expect
.poll(() =>
canvas.evaluate((element) => {
const node = element as HTMLCanvasElement
const band = node.getContext('2d')!.getImageData(0, 420, node.width, 120).data
let yellow = 0
for (let index = 0; index < band.length; index += 4) {
if (band[index] > 230 && band[index + 1] > 200 && band[index + 2] < 80) yellow += 1
}
return yellow
}),
)
.toBeGreaterThan(1000)
await primary.click()
await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible()
const [download] = await Promise.all([
page.waitForEvent('download'),
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
])
expect(download.suggestedFilename()).toBe('card-jorge-solla-eu-sou-solla.png')
const downloadPath = await download.path()
expect(downloadPath).toBeTruthy()
expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true)
})
test('a name that cannot fit fails closed with an actionable message', async ({ page }) => {
await page.goto('/cards?model=eu-sou-solla')
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente')
await expect(dialog.getByRole('alert')).toContainText('nome mais curto')
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled()
})
test('photo card decodes the file, keeps the overlay and recovers from a bad image', async ({
page,
}) => {
await page.goto('/cards?model=perfil-quadrado')
const dialog = page.getByRole('dialog')
const canvas = dialog.locator('canvas')
await expect(canvas).toHaveAttribute('width', '1000')
await expect(canvas).toHaveAttribute('height', '1000')
await dialog.locator('input[type="file"]').setInputFiles({
name: 'foto-e2e.png',
mimeType: 'image/png',
buffer: TEST_PHOTO_PNG,
})
await expect(dialog.getByRole('button', { name: 'Trocar foto' })).toBeVisible()
await expect
.poll(() =>
canvas.evaluate((element) => {
const node = element as HTMLCanvasElement
return node.getContext('2d')!.getImageData(5, 5, 1, 1).data[3]
}),
)
.toBe(255)
await dialog.locator('input[type="file"]').setInputFiles({
name: 'nao-e-foto.txt',
mimeType: 'text/plain',
buffer: Buffer.from('not an image'),
})
await expect(dialog.getByRole('alert')).toContainText('Não foi possível usar esta foto')
await expect(dialog.getByRole('button', { name: 'Escolher outra foto' })).toBeVisible()
})
test('mobile opens the composer in the bottom drawer', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/cards?model=eu-sou-solla')
const drawer = page.locator('[data-slot="drawer-popup"]')
await expect(drawer).toBeVisible()
await expect(drawer.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(drawer).toHaveCount(0)
})
})

View file

@ -61,6 +61,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/sw.js',
'/campanha/convite/e2e-prewarm',
'/',
'/cards',
]) {
await prewarmGet(path)
}

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
CARD_MODELS,
NAME_CARD_SLOT,
cardModelHref,
getCardModel,
isCardModelId,
} from '@/lib/cardModels'
describe('card model catalog (S13)', () => {
it('exposes the three shipped models with unique ids', () => {
expect(CARD_MODELS.map((model) => model.id)).toEqual([
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
])
expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length)
})
it('serves every master from the local /cards asset folder', () => {
for (const model of CARD_MODELS) {
expect(model.assetSrc.startsWith('/cards/')).toBe(true)
}
})
it('pins the real output dimensions of the masters (not the 1080 in the file names)', () => {
expect(getCardModel('eu-sou-solla')).toMatchObject({ width: 1080, height: 1440, kind: 'name' })
expect(getCardModel('perfil-quadrado')).toMatchObject({
width: 1000,
height: 1000,
kind: 'photo',
})
expect(getCardModel('perfil-retangular')).toMatchObject({
width: 1000,
height: 1440,
kind: 'photo',
})
})
it('keeps the photo window measured from the transparent masters', () => {
expect(getCardModel('perfil-quadrado')?.photoWindow).toEqual({
x: 0,
y: 0,
width: 1000,
height: 740,
})
expect(getCardModel('perfil-retangular')?.photoWindow).toEqual({
x: 0,
y: 0,
width: 1000,
height: 1044,
})
expect(getCardModel('eu-sou-solla')?.photoWindow).toBeUndefined()
})
it('guards the name slot geometry measured from the filled master', () => {
expect(NAME_CARD_SLOT).toMatchObject({
centerX: 570,
capTop: 430,
capHeight: 106,
maxInkWidth: 714,
})
})
it('sanitizes query-param values through isCardModelId', () => {
expect(isCardModelId('eu-sou-solla')).toBe(true)
expect(isCardModelId('perfil-quadrado')).toBe(true)
expect(isCardModelId('perfil-retangular')).toBe(true)
expect(isCardModelId('modelo-inventado')).toBe(false)
expect(isCardModelId(['perfil-quadrado'])).toBe(false)
expect(isCardModelId(undefined)).toBe(false)
})
it('builds the deep-link used by the home tiles', () => {
expect(cardModelHref('perfil-quadrado')).toBe('/cards?model=perfil-quadrado')
})
})

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { NAME_CARD_SLOT } from '@/lib/cardModels'
import {
fitCardName,
normalizeCardName,
resolveFontSizeForCapHeight,
type CardMeasureText,
} from '@/lib/cardNameFit'
/** Deterministic fake: 0.6em per char, 0.72em cap, 0.2em descent. */
const measure: CardMeasureText = (text, fontSize) => ({
width: text.length * fontSize * 0.6,
actualBoundingBoxAscent: fontSize * 0.72,
actualBoundingBoxDescent: fontSize * 0.2,
})
const inkWidth = (text: string, fontSize: number) => measure(text, fontSize).width
const minFontSize = resolveFontSizeForCapHeight(measure, NAME_CARD_SLOT.minCapHeight)
describe('normalizeCardName', () => {
it('trims, collapses spaces and uppercases with the pt-BR locale', () => {
expect(normalizeCardName(' joão conceição ')).toBe('JOÃO CONCEIÇÃO')
})
})
describe('resolveFontSizeForCapHeight', () => {
it('calibrates the font size from the measured cap, not the raw cap value', () => {
expect(resolveFontSizeForCapHeight(measure, 106)).toBeCloseTo(106 / 0.72, 5)
})
it('falls back to the cap value when the probe has no ascent', () => {
const broken: CardMeasureText = () => ({
width: 0,
actualBoundingBoxAscent: 0,
actualBoundingBoxDescent: 0,
})
expect(resolveFontSizeForCapHeight(broken, 106)).toBe(106)
})
})
describe('fitCardName', () => {
it('keeps the master cap height for a short name', () => {
const fit = fitCardName('João', measure)
expect(fit).toMatchObject({ ok: true, lines: ['JOÃO'] })
if (fit.ok) {
expect(fit.capHeight).toBeCloseTo(NAME_CARD_SLOT.capHeight, 5)
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
}
})
it('shrinks a long single line to the ink width instead of cutting it', () => {
const fit = fitCardName('Conceição', measure)
expect(fit).toMatchObject({ ok: true, lines: ['CONCEIÇÃO'] })
if (fit.ok) {
expect(fit.fontSize).toBeLessThan(resolveFontSizeForCapHeight(measure, 106))
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize)
}
})
it('wraps into two balanced lines when single-line would fall under the minimum', () => {
const fit = fitCardName('Jorge Solla da Bahia', measure)
expect(fit.ok).toBe(true)
if (fit.ok) {
expect(fit.lines).toEqual(['JORGE SOLLA', 'DA BAHIA'])
expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize)
for (const line of fit.lines) {
expect(inkWidth(line, fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
}
const blockHeight = fit.lineHeight + fit.capHeight
expect(blockHeight).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom - NAME_CARD_SLOT.capTop)
}
})
it('does not split a single word in the middle', () => {
const fit = fitCardName('Bartolomeucostajunior', measure)
expect(fit).toEqual({ ok: false, reason: 'too-long' })
})
it('fails closed for an empty name', () => {
expect(fitCardName(' ', measure)).toEqual({ ok: false, reason: 'empty' })
})
it('preserves Portuguese accents in the fitted lines', () => {
const fit = fitCardName('Coração', measure)
expect(fit.ok).toBe(true)
if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO')
})
})

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import type { CardRect } from '@/lib/cardModels'
import {
CARD_PHOTO_MAX_ZOOM,
CARD_PHOTO_MIN_ZOOM,
cardPhotoDrawRect,
centerCardPhotoTransform,
clampCardPhotoTransform,
coverScale,
panCardPhotoTransform,
zoomCardPhotoTransform,
type CardPhotoSize,
} from '@/lib/cardPhotoTransform'
const WINDOW: CardRect = { x: 0, y: 0, width: 1000, height: 740 }
const LANDSCAPE: CardPhotoSize = { width: 2000, height: 1000 }
const PORTRAIT: CardPhotoSize = { width: 900, height: 1600 }
const coversWindow = (rect: CardRect, window: CardRect) =>
rect.x <= window.x &&
rect.y <= window.y &&
rect.x + rect.width >= window.x + window.width &&
rect.y + rect.height >= window.y + window.height
describe('coverScale', () => {
it('covers the window on the tighter axis', () => {
expect(coverScale(LANDSCAPE, WINDOW)).toBeCloseTo(0.74, 5)
expect(coverScale(PORTRAIT, WINDOW)).toBeCloseTo(1000 / 900, 5)
})
it('degrades to 1 for empty sizes instead of dividing by zero', () => {
expect(coverScale({ width: 0, height: 0 }, WINDOW)).toBe(1)
})
})
describe('centerCardPhotoTransform', () => {
it('covers the window and centers the photo on it', () => {
const transform = centerCardPhotoTransform(LANDSCAPE, WINDOW)
const rect = cardPhotoDrawRect(transform, LANDSCAPE, WINDOW)
expect(transform).toEqual({ zoom: 1, offsetX: -240, offsetY: 0 })
expect(coversWindow(rect, WINDOW)).toBe(true)
})
it('centers a portrait photo with vertical slack', () => {
const transform = centerCardPhotoTransform(PORTRAIT, WINDOW)
const rect = cardPhotoDrawRect(transform, PORTRAIT, WINDOW)
expect(rect.height).toBeGreaterThan(WINDOW.height)
expect(rect.y).toBeLessThan(0)
expect(coversWindow(rect, WINDOW)).toBe(true)
})
})
describe('clampCardPhotoTransform', () => {
it('clamps the zoom to [1, 4]', () => {
expect(
clampCardPhotoTransform({ zoom: 0.2, offsetX: 0, offsetY: 0 }, LANDSCAPE, WINDOW).zoom,
).toBe(CARD_PHOTO_MIN_ZOOM)
expect(
clampCardPhotoTransform({ zoom: 9, offsetX: 0, offsetY: 0 }, LANDSCAPE, WINDOW).zoom,
).toBe(CARD_PHOTO_MAX_ZOOM)
})
it('pins the offsets so no gap can open at the window edges', () => {
const tooFarRight = clampCardPhotoTransform(
{ zoom: 1, offsetX: 9999, offsetY: 9999 },
LANDSCAPE,
WINDOW,
)
const tooFarLeft = clampCardPhotoTransform(
{ zoom: 1, offsetX: -9999, offsetY: -9999 },
LANDSCAPE,
WINDOW,
)
expect(tooFarRight).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
expect(tooFarLeft).toEqual({ zoom: 1, offsetX: -480, offsetY: 0 })
expect(coversWindow(cardPhotoDrawRect(tooFarLeft, LANDSCAPE, WINDOW), WINDOW)).toBe(true)
})
})
describe('panCardPhotoTransform', () => {
it('nudges the offset and stays clamped', () => {
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
const panned = panCardPhotoTransform(start, LANDSCAPE, WINDOW, 100, -40)
expect(panned.offsetX).toBe(-140)
expect(panned.offsetY).toBe(0)
const overPanned = panCardPhotoTransform(panned, LANDSCAPE, WINDOW, 9999, 9999)
expect(overPanned).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
})
})
describe('zoomCardPhotoTransform', () => {
it('keeps the anchor point stable while zooming', () => {
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { x: 500, y: 370 })
const scale = coverScale(LANDSCAPE, WINDOW) * 2
expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5)
expect(coversWindow(cardPhotoDrawRect(zoomed, LANDSCAPE, WINDOW), WINDOW)).toBe(true)
})
it('clamps above the max zoom and never opens a gap', () => {
const zoomed = zoomCardPhotoTransform(
centerCardPhotoTransform(PORTRAIT, WINDOW),
PORTRAIT,
WINDOW,
99,
)
expect(zoomed.zoom).toBe(CARD_PHOTO_MAX_ZOOM)
expect(coversWindow(cardPhotoDrawRect(zoomed, PORTRAIT, WINDOW), WINDOW)).toBe(true)
})
})

View file

@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import { NAME_CARD_SLOT, getCardModel, type CardRect } from '@/lib/cardModels'
import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform'
import {
createCardMeasure,
renderNameCard,
renderPhotoCard,
type CardDrawContext,
} from '@/lib/cardRender'
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
type TextCall = { text: string; x: number; y: number }
const fontSizeFrom = (font: string): number => {
const match = /(\d+(?:\.\d+)?)px/.exec(font)
return match ? Number(match[1]) : 0
}
const createFakeContext = () => {
const drawCalls: DrawCall[] = []
const textCalls: TextCall[] = []
const ctx: CardDrawContext = {
font: '',
fillStyle: '',
textAlign: 'start',
textBaseline: 'alphabetic',
drawImage: (image, dx, dy, dw, dh) => {
drawCalls.push({ image, dx, dy, dw, dh })
},
fillText: (text, x, y) => {
textCalls.push({ text, x, y })
},
measureText: (text) => {
const size = fontSizeFrom(ctx.font)
return {
width: text.length * size * 0.6,
actualBoundingBoxAscent: size * 0.72,
actualBoundingBoxDescent: size * 0.2,
}
},
}
return { ctx, drawCalls, textCalls }
}
const nameModel = getCardModel('eu-sou-solla')!
const squareModel = getCardModel('perfil-quadrado')!
describe('renderNameCard', () => {
it('draws the master base first and then the centered name', () => {
const { ctx, drawCalls, textCalls } = createFakeContext()
const image = { id: 'base' } as unknown as CanvasImageSource
const fit = renderNameCard(ctx, nameModel, {
image,
name: 'João',
fontFamily: 'Brexter',
measure: createCardMeasure(ctx, 'Brexter'),
})
expect(fit.ok).toBe(true)
expect(drawCalls).toEqual([{ image, dx: 0, dy: 0, dw: 1080, dh: 1440 }])
expect(textCalls).toHaveLength(1)
expect(textCalls[0]!.text).toBe('JOÃO')
expect(textCalls[0]!.x).toBe(NAME_CARD_SLOT.centerX)
expect(textCalls[0]!.y).toBeCloseTo(NAME_CARD_SLOT.capTop + NAME_CARD_SLOT.capHeight, 5)
expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill)
expect(ctx.textAlign).toBe('center')
})
it('wraps long names into two lines with baselines inside the free band', () => {
const { ctx, textCalls } = createFakeContext()
const fit = renderNameCard(ctx, nameModel, {
image: { id: 'base' } as unknown as CanvasImageSource,
name: 'Jorge Solla da Bahia',
fontFamily: 'Brexter',
measure: createCardMeasure(ctx, 'Brexter'),
})
expect(fit.ok).toBe(true)
expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA'])
expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y)
expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom)
})
it('draws the base but no text when the name cannot fit (never cuts silently)', () => {
const { ctx, drawCalls, textCalls } = createFakeContext()
const fit = renderNameCard(ctx, nameModel, {
image: { id: 'base' } as unknown as CanvasImageSource,
name: 'Bartolomeucostajunior',
fontFamily: 'Brexter',
measure: createCardMeasure(ctx, 'Brexter'),
})
expect(fit).toEqual({ ok: false, reason: 'too-long' })
expect(drawCalls).toHaveLength(1)
expect(textCalls).toHaveLength(0)
})
})
describe('renderPhotoCard', () => {
it('draws the photo full-bleed under the official overlay', () => {
const { ctx, drawCalls } = createFakeContext()
const photo = { id: 'photo' } as unknown as CanvasImageSource
const frame = { id: 'frame' } as unknown as CanvasImageSource
const size: CardPhotoSize = { width: 2000, height: 1000 }
const window: CardRect = squareModel.photoWindow!
const transform = centerCardPhotoTransform(size, window)
const clamped = renderPhotoCard(ctx, {
photo,
frame,
photoSize: size,
frameSize: { width: squareModel.width, height: squareModel.height },
window,
transform,
})
expect(clamped).toEqual(transform)
expect(drawCalls).toHaveLength(2)
expect(drawCalls[0]!.image).toBe(photo)
expect(drawCalls[1]).toEqual({ image: frame, dx: 0, dy: 0, dw: 1000, dh: 1000 })
expect(drawCalls[0]!.dx).toBeLessThanOrEqual(window.x)
expect(drawCalls[0]!.dy).toBeLessThanOrEqual(window.y)
expect(drawCalls[0]!.dx + drawCalls[0]!.dw).toBeGreaterThanOrEqual(window.x + window.width)
expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height)
})
})