diff --git a/.agents/rules/codebase-map.mdc b/.agents/rules/codebase-map.mdc index 20e67454..cb4e82a3 100644 --- a/.agents/rules/codebase-map.mdc +++ b/.agents/rules/codebase-map.mdc @@ -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//` — 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//` — 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`). diff --git a/docs/changelog/2026-09-12-s13-cards-personalizados.md b/docs/changelog/2026-09-12-s13-cards-personalizados.md new file mode 100644 index 00000000..af0ad18e --- /dev/null +++ b/docs/changelog/2026-09-12-s13-cards-personalizados.md @@ -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`. diff --git a/docs/plans/cards-personalizados-campanha-impl.md b/docs/plans/cards-personalizados-campanha-impl.md new file mode 100644 index 00000000..7c487727 --- /dev/null +++ b/docs/plans/cards-personalizados-campanha-impl.md @@ -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 + `` | 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 ``. +- **`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=`). +- **`src/app/(frontend)/(home)/page.tsx`** (editar): `` logo após ``, dentro do `
`, antes de `
`/`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 → `` → revoke, precedente `SupporterImportWizard.tsx:50-58`); filename sem PII (`card-jorge-solla-.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` + `` | +| `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. diff --git a/public/cards/name-card-base.jpg b/public/cards/name-card-base.jpg new file mode 100644 index 00000000..dc2967d6 Binary files /dev/null and b/public/cards/name-card-base.jpg differ diff --git a/public/cards/photo-portrait-frame.png b/public/cards/photo-portrait-frame.png new file mode 100644 index 00000000..eaf97aec Binary files /dev/null and b/public/cards/photo-portrait-frame.png differ diff --git a/public/cards/photo-square-frame.png b/public/cards/photo-square-frame.png new file mode 100644 index 00000000..ad5ecc22 Binary files /dev/null and b/public/cards/photo-square-frame.png differ diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 2ee626b1..c1aa5330 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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'], diff --git a/src/app/(frontend)/(home)/CampaignCardsSection.tsx b/src/app/(frontend)/(home)/CampaignCardsSection.tsx new file mode 100644 index 00000000..5f69ac3f --- /dev/null +++ b/src/app/(frontend)/(home)/CampaignCardsSection.tsx @@ -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> +) diff --git a/src/app/(frontend)/(home)/cards/page.tsx b/src/app/(frontend)/(home)/cards/page.tsx new file mode 100644 index 00000000..8a83b6b6 --- /dev/null +++ b/src/app/(frontend)/(home)/cards/page.tsx @@ -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 /> + </> + ) +} diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 9527a720..c424c05e 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -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 /> diff --git a/src/app/(frontend)/fonts.ts b/src/app/(frontend)/fonts.ts new file mode 100644 index 00000000..776e5822 --- /dev/null +++ b/src/app/(frontend)/fonts.ts @@ -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, +}) diff --git a/src/app/(frontend)/fonts/Brexter-Bold.ttf b/src/app/(frontend)/fonts/Brexter-Bold.ttf new file mode 100644 index 00000000..9877a8d3 Binary files /dev/null and b/src/app/(frontend)/fonts/Brexter-Bold.ttf differ diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx new file mode 100644 index 00000000..0442cd8b --- /dev/null +++ b/src/components/cards/CardComposer.tsx @@ -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> + ) +} diff --git a/src/components/cards/CardModelGallery.tsx b/src/components/cards/CardModelGallery.tsx new file mode 100644 index 00000000..cd7c8da3 --- /dev/null +++ b/src/components/cards/CardModelGallery.tsx @@ -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> + ) +} diff --git a/src/components/cards/CardModelTile.tsx b/src/components/cards/CardModelTile.tsx new file mode 100644 index 00000000..d3577673 --- /dev/null +++ b/src/components/cards/CardModelTile.tsx @@ -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> +) diff --git a/src/components/cards/CardPreviewCanvas.tsx b/src/components/cards/CardPreviewCanvas.tsx new file mode 100644 index 00000000..ab411dc2 --- /dev/null +++ b/src/components/cards/CardPreviewCanvas.tsx @@ -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} + /> +) diff --git a/src/components/cards/CardsStudio.tsx b/src/components/cards/CardsStudio.tsx new file mode 100644 index 00000000..f1ffe50b --- /dev/null +++ b/src/components/cards/CardsStudio.tsx @@ -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> + ) +} diff --git a/src/components/cards/cardCanvas.ts b/src/components/cards/cardCanvas.ts new file mode 100644 index 00000000..66e4fdb5 --- /dev/null +++ b/src/components/cards/cardCanvas.ts @@ -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` diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts new file mode 100644 index 00000000..f12960b0 --- /dev/null +++ b/src/lib/cardModels.ts @@ -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}` diff --git a/src/lib/cardNameFit.ts b/src/lib/cardNameFit.ts new file mode 100644 index 00000000..f14d172b --- /dev/null +++ b/src/lib/cardNameFit.ts @@ -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' } +} diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts new file mode 100644 index 00000000..7bd03eae --- /dev/null +++ b/src/lib/cardPhotoTransform.ts @@ -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, + ) +} diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts new file mode 100644 index 00000000..b4aac640 --- /dev/null +++ b/src/lib/cardRender.ts @@ -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) +} diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 4dc3445c..30ad291f 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -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) + }) +}) diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index bb50980c..231e1549 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -61,6 +61,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => { '/campanha/sw.js', '/campanha/convite/e2e-prewarm', '/', + '/cards', ]) { await prewarmGet(path) } diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts new file mode 100644 index 00000000..784ba1a8 --- /dev/null +++ b/tests/unit/cardModels.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/cardNameFit.unit.spec.ts b/tests/unit/cardNameFit.unit.spec.ts new file mode 100644 index 00000000..05b192ee --- /dev/null +++ b/tests/unit/cardNameFit.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/cardPhotoTransform.unit.spec.ts b/tests/unit/cardPhotoTransform.unit.spec.ts new file mode 100644 index 00000000..3d864253 --- /dev/null +++ b/tests/unit/cardPhotoTransform.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts new file mode 100644 index 00000000..28d2b912 --- /dev/null +++ b/tests/unit/cardRender.unit.spec.ts @@ -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) + }) +})