feat(S15): card "Time de você" — quarto modelo auto-serviço no funil público (Closes #1161)

This commit is contained in:
Francisco Solla 2026-09-18 21:29:35 -03:00
parent 7e164b637d
commit 560f2e494a
31 changed files with 1963 additions and 170 deletions

View file

@ -30,6 +30,12 @@ S3_SECRET_ACCESS_KEY=
# normalizes them to HTTP; otherwise this configured origin is the only fallback.
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# S15 — test seam for the on-device card cutout. `1` swaps the MediaPipe engine
# for a deterministic stub (e2e builds and the Playwright webServer only; the
# real engine must run in dev/production). Local E2E_PROD=1 builds set it too:
# NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm build
NEXT_PUBLIC_CARDS_CUTOUT_STUB=
# Shared secret guarding POST /api/revalidate, which busts ISR cache tags on demand.
# Needed after direct-DB writes (e.g. `pnpm db:seed:posts`, Onda 0 migrations) since those
# bypass the deployed Payload hooks. Default tag is `posts`; allowlisted tags also include

View file

@ -221,6 +221,9 @@ jobs:
env:
NEXT_DIST_DIR: .next-e2e
NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test
# S15 — deterministic cutout stub: the e2e build never downloads the
# MediaPipe wasm nor runs inference.
NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1'
- name: E2E tests (blast radius — manifest-selected or curated specs)
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
# OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs

View file

@ -195,6 +195,9 @@ jobs:
env:
NEXT_DIST_DIR: .next-e2e
NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test
# S15 — deterministic cutout stub: the e2e build never downloads the
# MediaPipe wasm nor runs inference.
NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1'
- name: E2E tests (full suite, single job, 4 workers)
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# No `--` separator: pnpm would forward it verbatim to playwright and

3
.gitignore vendored
View file

@ -124,6 +124,9 @@ next-env.d.ts
# Proprietary design font files kept only in the local workspace
/public/arial-mt/
# S15 — MediaPipe wasm runtime, copied from node_modules at dev/build time
/public/cards/mediapipe/
/data/encode/
/data/poster-candidates/

View file

@ -43,6 +43,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
# the deploy script applies them BEFORE this build (OPS66: static routes read
# the new schema, so the build must see a migrated DB).
#
# S15 — copy the MediaPipe wasm runtime into public/ so the card cutout runs
# same-origin (the files are gitignored build artifacts, never committed).
RUN node scripts/copy-card-vision-assets.mjs
# OPS99: Generate importMap with dummy S3_* envs BEFORE next build.
# This ensures the production image always has the S3ClientUploadHandler entry,
# eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73).

View file

@ -0,0 +1 @@
**S15 — Card "Time de você": quarto modelo auto-serviço no funil público (2026-09-18):** o funil de cards (`#cards` da home + `/cards`) ganha o quarto modelo: nome + foto de busto, remoção de fundo 100% no aparelho (nenhum byte sai), auto-enquadramento pelo bbox alfa (topo no slot, centrado, com pan/zoom existentes como escape) e o card `TIME DE <NOME>` composto na ordem base → recorte → overlay → banners (geometria medida: vermelho `#E50E2F`/`#FFEC01` −3,5°, azul `#0061A5`/branco −4,1°; nome em 1 linha, `too-long` com a mesma mensagem do modelo de nome). Motor MediaPipe `selfie_segmenter` (Apache-2.0, float16): wasm ~12MB lazy e same-origin copiado de `node_modules` em dev/build (`scripts/copy-card-vision-assets.mjs`; gitignored), modelo de 244KB commitado com NOTICE, sem COOP/COEP (precedente c167) e sem CDN de terceiros; `@imgly` (AGPL) rejeitado para o bundle. Catálogo puro ganha `kind: 'team'` + `overlaySrc`/`previewSrc`/`badge`, `renderTeamCard`/`drawCardBanner` com ops de rotação no ctx estrutural, `frameCardPhotoOnBbox` e o hook `useCardCutout` (progresso, retry com o mesmo arquivo, run-id, fail-closed em bbox degenerado); e2e determinístico via stub (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, só dev/e2e). Sem migration, sem collection/Consent novo, sem persistência — `Contact`/Payload intocados. Design gate certificado pelo `designer` (cenas 1–7). Verificação: unit dos 4 specs puros, `pnpm gate:fast`, e2e do `frontend` (29 verdes), motor real exercitado no browser em 390/1280 com download de PNG.

View file

@ -0,0 +1,196 @@
# Impl: Card "Time de você": quarto modelo auto-serviço no funil público
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1161
Intenção: docs/plans/cards-time-de-voce.md
Appetite restante: herdado — ~2–3 dias eng (3 fases somam ≤3 dias; sem migration)
## Leitura da intenção
- **Outcome:** o funil de cards (home `#cards` + `/cards`, S13/S14) ganha o quarto modelo — `Time de você`: nome + foto de busto, fundo removido no aparelho, card `TIME DE <NOME>` com a foto recortada no centro sobre o fundo oficial e os banners no topo, baixável em PNG — sem conta e sem nenhum byte da foto saindo do aparelho.
- **O que NÃO negociar:**
- 100% client-side: sem upload, sem persistência, sem analytics de PII; nome/foto nunca saem do aparelho.
- Um único editor/fluxo: quarto item do catálogo no MESMO `CardsStudio`/`CardComposer`; `/cards` e `?model=time-de-voce` continuam.
- Artes-mestre intocadas (compor por cima, nunca redesenhar); geometria literal medida: janela da foto `{286,439,592,577}`; banner `TIME DE` centro (545,162) ≈693×118, −3,5°, `#E50E2F`, texto `#FFEC01` cap ≈93; banner do nome centro (545,313) ≈509×176, −4,1°, `#0061A5`, nome `#FFFFFF` cap ≈130, tinta ≤470, 1 linha, cap mín ≈56.
- Nome nunca cortado em silêncio: se não couber no cap mínimo → mesma mensagem de nome curto do modelo de nome.
- Recorte local com progresso; falha → erro claro com retry, sem beco sem saída.
- Sem migration/collection/CMS/Consent novo; sem headers globais que quebrem o embed (precedente c167).
- **O que reavaliar (hipóteses da intenção):**
- **Motor recomendado (`@imgly/background-removal`)**: reavaliado e rejeitado — o `LICENSE` do repo é proprietário ("All Rights Reserved") e o pacote é AGPL-3.0; detalhe na Decisão 1. A escolha final era explicitamente do plano de implementação.
- **“O composer carrega UMA imagem base e ramifica por kind”**: o time precisa de DOIS assets (base + overlay frontal) e de um estado de processamento — é um ramo de compositor/tempo, não um `photoWindow` a mais.
- **“3 ids pinados”**: os pins de unit (`tests/unit/cardModels.unit.spec.ts:7`) e o loop de tiles do e2e (:1712) quebram com o 4º modelo; atualizá-los faz parte da entrega.
## Abordagem recomendada
```mermaid
flowchart LR
H["home: seção cards / rota /cards"] --> S["CardsStudio — ilha única (S13/S14)"]
S --> G["CardModelGallery — 4 tiles, grid 4x1 no lg"]
G -->|"tile Time de você"| C["CardComposer — editor único"]
C --> I["nome + foto de busto"]
I --> HK["useCardCutout — idle → processing → ready | error"]
HK --> AD["cardCutout — adapter DOM (src/components/cards)"]
AD --> MP["MediaPipe selfie segmenter — wasm + tflite same-origin"]
MP -->|"máscara de confiança"| AD
AD -->|"cutout canvas + bbox alfa"| HK
HK --> FR["frameCardPhotoOnBbox — puro (cardPhotoTransform)"]
FR --> RT["renderTeamCard — puro (base → foto → overlay → banners)"]
RT --> PV["prévia canvas + controles existentes"]
C -->|"Criar meu card"| R["resultado"]
R --> DL["Baixar PNG no aparelho"]
CM["cardModels — catálogo único"] -.-> G
CM -.-> RT
```
**Opções consideradas:** A) estender o funil S13/S14 (catálogo + composer + render puros + adapter DOM novo) | B) rota/editor novo só para o time | C) recorte como serviço/API/iframe.
**Recomendação:** A — o quarto modelo é um item do catálogo num funil que já tem editor único, shells e núcleo puro; reusar mantém o anti-goal "segundo editor" cortado e cabe no appetite.
**Rejeitadas:** B porque cria dois fluxos (o rabbit hole de produto) e duplica shells/estado; C porque viola o aceite "nenhum byte sai do aparelho".
### Decisões de engenharia
1. **Motor de remoção de fundo — MediaPipe selfie segmenter (Apache-2.0), 100% self-hosted.**
Opções: A) `@imgly/background-removal` no navegador (CDN padrão ou self-host do pacote de dados; ~40MB no 1º uso; progresso nativo; AGPL-3.0) | B) `@mediapipe/tasks-vision` `ImageSegmenter` + `selfie_segmenter.tflite` float16 256×256 (Apache-2.0; wasm SIMD ~11,7MB + modelo ~244KB; sem COOP/COEP) | C) recorte no servidor/API | D) ONNX Runtime Web + modelo próprio.
Recomendação: B — Apache-2.0 compatível com o `LICENSE` proprietário do repo (imgly é AGPL-3.0; o repo é público mas "All Rights Reserved": visibilidade do código ≠ concessão de direitos AGPL, então a §13 não está satisfeita; o `@imgly/background-removal-node` de `scripts/resize-images.mjs` é ferramenta local de script, não distribuída ao usuário, e não vale como precedente de bundle); primeiro uso de ~12MB de wasm (cacheado) + 244KB em vez de ~40MB no celular; roda sem COOP/COEP (wasm single-thread, sem SharedArrayBuffer — respeita o c167); assets same-origin sob `public/cards/` (sem CDN em runtime; e2e hermético); a expectativa honesta de busto/fundo simples já está no aceite.
Rejeitadas: A por licença + peso no celular + CDN de terceiro executando JS no browser (o self-host do pacote de dados imgly é grande demais para repo/imagem); C por PII/serviço; D por reimplementar runtime/wasm/hosting/modelo (fora do appetite) sem ganho sobre B.
Hospedagem/carregamento: `selfie_segmenter.tflite` commitado em `public/cards/` (origem e Apache-2.0 documentados no código); wasm copiado de `node_modules/@mediapipe/tasks-vision/wasm/` para `public/cards/mediapipe/wasm/` (gitignored) por `scripts/copy-card-vision-assets.mjs`, disparado no preflight do `scripts/dev.mjs`, no `prebuild` (CI) e num `RUN` do estágio builder do Dockerfile (o runner copia `public/`). `await import('@mediapipe/tasks-vision')` dentro do adapter (code-split; só o modelo time paga); `FilesetResolver.forVisionTasks('/cards/mediapipe/wasm')` + `createFromOptions({ baseOptions: { modelAssetBuffer }, outputConfidenceMasks: true, runningMode: 'IMAGE' })`; main thread com yield antes do `segment()` (bloqueio de poucas centenas de ms); worker só com jank medido (gatilho).
Progresso/falha: pré-aquecimento do wasm com fetch streaming por bytes + modelo passado como buffer → ratio real; adapter devolve `{ ok:true; canvas; size; bbox } | { ok:false; reason:'engine'|'empty' }`; um único estado de erro recuperável (`Tentar de novo` com o mesmo File guardado / `Escolher outra foto`); nenhum log em console (fixture e2e).
2. **Quarto modelo — `kind: 'team'` explícito + campos aditivos, sem pipeline genérico.**
Opções: A) terceiro valor de `kind` + `overlaySrc`/`previewSrc`/`badge` opcionais + `renderTeamCard` reusando a math existente | B) manter `kind:'photo'` e detectar o time por flags (`photoWindow`+`nameSlot`) | C) refatorar para um pipeline de camadas (`layers[]`) para os 4 modelos | D) render/editor dedicado por id.
Recomendação: A — o modelo tem foto + nome + overlay + processamento, então não é nenhum dos dois kinds atuais; o discriminante explícito mantém os 3 modelos entregues no mesmo caminho (mesmos unit pins de render) e deixa o composer ramificar uma vez. `renderTeamCard` (base → foto → overlay → banners) reusa `cardPhotoDrawRect`/`clampCardPhotoTransform`, `fitCardName` e um helper novo de banner; `overlaySrc`/`previewSrc` aditivos não mudam os modelos existentes. Fato verificado nos assets: `team-card-base.png` é opaco, `team-card-front.png` só cobre y≥971 e nenhum asset contém os banners — os dois banners (retângulo + texto) são desenhados pelo render com os literais medidos.
Rejeitadas: B porque esconde um fluxo novo (processar/recortar) atrás de flags de um tipo antigo; C porque refatora o render dos 3 modelos por generalidade especulativa (risco + reescreve pins); D porque duplica editor/render (anti-goal).
3. **Auto-enquadramento — bbox alfa no adapter + math pura reusando o transform existente.**
Opções: A) math nova em `cardPhotoTransform.ts` (`frameCardPhotoOnBbox`) convertendo para o `CardPhotoTransform` atual e passando pelo `clampCardPhotoTransform`; bbox no adapter via canvas `getImageData` | B) calcular um draw rect direto, sem o transform (perde pan/zoom reusados) | C) enquadrar "no olho" no adapter, sem função pura.
Recomendação: A — `zoom = clamp(coverNecessário(bbox→janela) / coverScale(fonte,janela), 1, CARD_PHOTO_MAX_ZOOM)`; topo do bbox encosta no topo da janela e o centro x do bbox alinha ao centro da janela; clamp final garante cobertura, e os controles de arrastar/zoom/setas do S13/S14 seguem funcionando com a transform resultante. Bbox: canvas de trabalho limitado a 1600px na maior aresta (memória/scan), varredura única de alpha>32; degenerado (vazio ou < ~24px) OU zoom necessário > 4 → `null` → erro recuperável (`empty-cutout`) — nunca card com foto invisível/minúscula.
Rejeitadas: B porque joga fora o escape de ajuste e a garantia de cobertura; C porque não é testável puro e o adapter é DOM.
4. **Fit do nome — reusar `fitCardName` com slot novo; rotação via ops no ctx estrutural.**
Opções: A) `TEAM_CARD_NAME_SLOT` (1 linha, centrado, cap 130, tinta 470, cap mín 56, rotação −4,1°) + união `CardNameSlot` discriminada por `align` (`'left'` | `'center'`) + `CardDrawContext` ganhando `save/restore/translate/rotate/fillRect` | B) `fitTeamName` nova | C) desenhar o nome sem centralizar/rotacionar.
Recomendação: A — a máquina de fit (probe de cap, shrink, mínimo, `too-long`) é a mesma; `NAME_CARD_SLOT` ganha `align:'left'` sem mudar comportamento e os pins atuais continuam passando; `drawCardName` ramifica por `align`; um helper `drawCardBanner` desenha retângulo e texto centrados/rotacionados (label fixo `TIME DE` cap 93/−3,5° e nome com o cap do fit); o fake ctx dos units registra a sequência `save → translate → rotate → fillRect → fillText → restore`, mantendo os testes existentes.
Rejeitadas: B (twin da mesma matemática), C (viola a fidelidade medida do exemplo).
5. **Estado do composer — hook `useCardCutout` + estado de UI no composer.**
Opções: A) hook `src/components/cards/useCardCutout.ts` dono do ciclo do motor (`idle → processing → ready | error`, retry, run id, desmontagem) consumido pelo `CardComposer` | B) `useState` no `CardComposer` | C) store/contexto global.
Recomendação: A — o ciclo do engine (import dinâmico, fases, progresso, guarda de corrida/desmontagem, File do retry, dispose do segmenter) é uma preocupação coesa vizinha do adapter; o composer já tem ~500 linhas e ganharia mais 6 estados. CTA `Criar meu card` só com `cutout.status==='ready'` + `nameFit.ok` + assets prontos; passo `result` inalcançável antes disso; `Baixar meu card` só no result (nunca baixa exemplo/estado intermediário). A11y: picker/retry são `<button>`; progresso em `role="status"`/`aria-live="polite"`; erro em `role="alert"`; o preview pronto reusa `tabIndex`/setas/zoom do bloco de foto; `Cancelar`/fechar funcionam em qualquer fase (não aborta o promise; run id ignora resultado tardio).
Rejeitadas: B (espalha o ciclo e a guarda de corrida no componente grande), C (não há segundo consumidor).
6. **Testes — unit puro obrigatório; e2e com seam do motor; int não se aplica.**
Opções: A) estender os specs de unit e o describe do `frontend.e2e.spec.ts` com stub do motor via flag de build | B) spec e2e novo com engine real no CI | C) testar pixels/jsdom.
Recomendação: A — o stub (flag `NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, nunca em produção; comportamento por `window.__cardsCutoutStub = 'ok' | 'slow' | 'error'` via `addInitScript`) evita baixar/rodar o wasm no CI e torna os estados (processing/ready/error/retry) determinísticos; o engine real é verificado no craft/UAT. Envs do stub: webServer do `playwright.config.ts` (dev) + step "Build for e2e" do `ci-pr.yml` e do `deploy.yml` (prod mode); documentar em `.env.example`.
Rejeitadas: B porque o CI baixaria ~12MB de wasm e a inferência sobre fixture sintético é não-determinística; C porque jsdom não tem canvas e fidelidade se prova no craft + download real.
Manifest: nenhuma entrada nova — `src/components/cards` e `src/lib/card` já mapeiam para `frontend` (`scripts/lib/e2e-affected-manifest.mjs:71-76`); `cardCutout`/`useCardCutout`/specs caem nesses prefixos, e `public/cards/*.tflite` não entra no classificador de e2e.
7. **Copy — “três” → “quatro” e literais das cenas.**
Opções: A) editar os dois pontos canônicos (`cards/page.tsx:11` intro + `CardModelGallery.tsx:71` dica) e portar os literais das cenas 3–7 do design | B) centralizar tudo num arquivo de copy novo | C) reescrever a copy.
Recomendação: A — a intro é metadata (`metadata.description = intro`) e a dica é a única outra menção; o compositor do time usa os literais do design: títulos "Entre para o time"/"Preparando sua foto"/"Confira seu card"; ajuda do nome "Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido."; bloco "Sua foto de busto" + "Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples." + "Escolher foto"; processamento "Baixando o modelo de recorte…" + "Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto no seu aparelho. Isso pode levar um pouco."; pronto "O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles." + "Arraste para ajustar" + "Trocar foto"; erro "Não foi possível remover o fundo desta foto." / "Tente de novo ou escolha outra foto." / "Fotos de busto, nítidas e com fundo simples costumam funcionar melhor." + "Tentar de novo"/"Escolher outra foto"; nome longo reusa a mensagem exata existente ("Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.") + "A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você corrige o nome."; privacidade reusa `CARD_PRIVACY_NOTE`/`CARD_PHOTO_PRIVACY_NOTE`; eyebrow/título/corpo da home não mudam.
Rejeitadas: B (1–2 call sites cada, sem compartilhamento), C (o gate é o design).
### Componentes / mudanças
- **`src/lib/cardModels.ts`** (editar): `kind` vira `'name' | 'photo' | 'team'`; 4º modelo `time-de-voce` (`assetSrc` base, `overlaySrc` front, `previewSrc` example, 1080×1440, `photoWindow {286,439,592,577}`, `badge 'NOVO'`); `TEAM_CARD_NAME_SLOT` (align center, centerX 545, capTop 248, capHeight 130, maxInkWidth 470, minCapHeight 56, maxLines 1, fill `#FFFFFF`, rotationDeg −4.1), `TEAM_CARD_NAME_BANNER` (545,313, 509×176, `#0061A5`, −4.1°) e `TEAM_CARD_LABEL` (693×118 centro (545,162) `#E50E2F`, texto `TIME DE`, cap 93, `#FFEC01`, −3.5°); `CardModel` ganha `overlaySrc?/previewSrc?/badge?`; `isCardModelId` cobre o novo id de graça.
- **`src/lib/cardNameFit.ts`** (editar): `CardNameSlot` vira união discriminada por `align` (`left`+`leftX` | `center`+`centerX`+`rotationDeg`); matemática de fit intocada.
- **`src/lib/cardPhotoTransform.ts`** (editar): `CardAlphaBbox` + `frameCardPhotoOnBbox(source, window, bbox): CardPhotoTransform | null`.
- **`src/lib/cardRender.ts`** (editar): `CardDrawContext` ganha `save/restore/translate/rotate/fillRect`; `drawCardName` ramifica `align`; `drawCardBanner`; `renderTeamCard` (base → foto pelo transform → front → banner `TIME DE` + nome fitado; devolve fit/clamp; fit falho desenha os banners fixos sem o nome — nunca corta).
- **`src/components/cards/cardCutout.ts`** (novo, adapter client-safe): dynamic import do tasks-vision; prefetch streaming do wasm; modelo como buffer; `segment()` após yield; confiança→alpha (`destination-in` bilinear), bbox (alpha>32, canvas ≤1600px), higiene de memória (`close()` de máscaras/segmenter); branch do stub.
- **`src/components/cards/useCardCutout.ts`** (novo, client): máquina de estados, progresso por fase, `process(file)`, `retry()` (mesmo File), `reset()`, run id, dispose.
- **`src/components/cards/CardComposer.tsx`** (editar): ramo `isTeamModel` com as cenas (idle exemplo + nome + foto; processing base + status/barra; ready canvas + controles + `Trocar foto`; erro de recorte; nome longo inline com CTA off); carrega `overlaySrc` junto do `assetSrc`; `canAdvance`/download condicionados ao cutout ready.
- **`src/components/cards/CardModelTile.tsx`** (editar): `src={model.previewSrc ?? model.assetSrc}`, `sizes` para 4 colunas (~260px no lg), selo `NOVO`.
- **`src/components/cards/CardModelGallery.tsx`** (editar): grid desktop `lg:grid-cols-4`; dica "Deslize para ver os quatro modelos".
- **`src/app/(frontend)/(home)/cards/page.tsx`** (editar): intro/metadata "Escolha um dos quatro modelos…".
- **`src/app/(frontend)/styles.css`** (editar): tokens `--team-red/--team-yellow/--team-blue` no tema `campaign-site` (port do artefato; o canvas mantém os literais no lib).
- **`scripts/copy-card-vision-assets.mjs`** (novo; idempotente, importável) + **`scripts/dev.mjs`** (preflight) + **`package.json`** (`prebuild`) + **`Dockerfile`** (`RUN` no builder antes do `next build`) + **`.gitignore`** (`/public/cards/mediapipe/`) + **`.env.example`** (flag do stub).
- **`public/cards/selfie_segmenter.tflite`** (novo, ~244KB, Apache-2.0, origem documentada).
- **`playwright.config.ts`**, **`.github/workflows/ci-pr.yml`**, **`.github/workflows/deploy.yml`** (editar): `NEXT_PUBLIC_CARDS_CUTOUT_STUB=1` no webServer dev e nos builds de e2e (nunca em produção).
- **Testes** (editar): `tests/unit/cardModels.unit.spec.ts`, `cardNameFit.unit.spec.ts`, `cardPhotoTransform.unit.spec.ts`, `cardRender.unit.spec.ts`; `tests/e2e/frontend.e2e.spec.ts` (describe novo + loop de tiles do S14).
- **`docs/changelog/2026-09-18-s15-card-time-de-voce.md`** (novo).
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
- **Access / Consent:** não se aplica — nenhuma superfície Payload; nome/foto nunca saem do aparelho; sem chave nova de Consent e sem persistência.
- **UI:** Impeccable C — shape (artefato aprovado, cenas 1–7) → craft (port class-by-class; grid 4×1; engine real em fotos de busto; progresso/erro) → critique (a11y, mobile 390, copy) → polish. Shells reusados: `CardsStudio`, `ui/dialog`, `ui/Drawer`, `CardPreviewCanvas`; tokens `--pt-red`/`--campaign-*` + `--team-*`; alvos ≥44px, contraste ≥4,5:1, `role=status`/`role=alert`, foco preso e retorno ao tile.
### 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 (literais medidos) e o progresso do processamento. Nome e foto são insumos locais da imagem — nunca dados apresentados, coletados ou enviados.
## Fases verificáveis
1. **Tracer da geometria + catálogo** — quota ~0,75 dia. Catálogo com o 4º modelo/slots; `CardNameSlot` com `align`; `CardDrawContext` com ops; `drawCardBanner` + `renderTeamCard`; `frameCardPhotoOnBbox`; units atualizados. O composer abre o time e desenha base+banners+nome (sem recorte) para calibrar contra `team-card-example.jpg` (posição/rotação/corpo/cores).
Prova: `pnpm gate:fast` verde; `/cards?model=time-de-voce` mostra `TIME DE MARIA` nas posições medidas; pin dos 4 ids passa.
2. **Motor + auto-enquadramento + estados** — quota ~1–1,5 dia. Dependência + script de cópia + tflite; `cardCutout` + `useCardCutout`; auto-frame; controles reusados; processing/ready/error/retry; stub seam + envs de e2e.
Prova: `pnpm gate:fast` verde; no browser, fotos de busto reais recortam e enquadram (topo encostado/centrado), progresso aparece, foto ruim cai no erro com retry, sem COOP/COEP; wasm servido same-origin.
3. **UI/copy/galeria + e2e + gates** — quota ~0,5–0,75 dia. Grid 4×1 + selo NOVO + `sizes`; dica/intro "quatro"; copy das cenas; craft/critique/polish; e2e do describe novo; changelog; `pnpm gate:fast`; e2e local (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Time de você"`); `pnpm push`.
Prova: e2e verde com stub (galeria 4 tiles + selo; `/cards?model=time-de-voce` idle → processing → ready → resultado → download `card-jorge-solla-time-de-voce.png` com assinatura PNG; falha do recorte → `role=alert` + `Tentar de novo`/`Escolher outra foto` e retry vira ready; nome longo → mensagem de nome curto com CTA desabilitado; drawer mobile); unit dos 4 specs; fixture de console-error intacto.
## Rabbit holes / Não escopo (engenharia)
- Worker/OffscreenCanvas/COOP+COEP/multithread sem jank medido — o engine roda single-thread same-origin; gatilho: bloqueio perceptível de main thread no craft.
- Máscara manual (pincel/borracha), detecção de rosto, múltiplas pessoas, "recorte perfeito", correção automática de foto.
- Segundo editor/rota/fluxo; abrir o motor para os 3 modelos existentes; 5º modelo; CMS de modelos/métricas.
- Refatorar `cardRender` para pipeline genérico de camadas.
- Compartilhamento social, persistência de nome/foto/arte, upload, conta, analytics de PII.
- Substituir o `@imgly/background-removal-node` dos scripts (gatilho: se o tooling deixar de ser interno); nenhum pacote AGPL no bundle do navegador.
- Testes de pixel no jsdom/snapshot visual/e2e com o engine real (fidelidade se prova no craft + download real).
- Headers globais no `next.config.mjs` (COOP/COEP quebraria o embed do YouTube — c167).
## Revisão da sessão (simplify) — resolvido, adiado com gatilho, fora
**Resolvido no simplify (não reabrir):** janela do banner embutida no slot (`slot.banner`,
`maxLines: 1` literal) em vez de constante global; transform do recorte derivado
(`photoTransform ?? ready.transform`) sem efeito-espelho (fim do frame de base puro); helper
`cardPhotoTransformsEqual` compartilhado; `try/finally` fechando `MPMask`/resultado/bitmap no
adapter; throttle de progresso dono único (adapter, 100 ms) com o hook sem dedupe duplicado;
`bbox` removido do estado pronto do hook; campo do nome extraído (`TeamNameField` + mensagem
única) e renderizado também no estado pronto com nome longo (não é mais beco sem saída);
título/descrição/larguras por IIFE/mapa em vez de ternários aninhados; `role=status` só no
rótulo da fase (leitor de tela não anuncia o percentual a cada tick); um único botão primário
(`Processando…` via label); `handleTeamPhotoFile` não bloqueia o input; `void buffer` morto;
NOTICE Apache-2.0 do `selfie_segmenter.tflite`; CardNameSlot importado só de `cardModels`;
tipo do slot centralizado (`align` + banner); eyebrow/erro sem duplicação.
**Adiados com gatilho (engenharia):**
- **Fatiar o `CardComposer` em subcomponentes de estado** (processing/error/ready) — adjudicado
durante a revisão como polish de legibilidade com risco de plumbing; hoje o arquivo tem
~830 linhas e 3 ramos de time. Gatilho: um 5º modelo OU folga de ~0,5 dia no próximo
item do funil de cards.
- **`disposeCardCutout()` no unmount** — o singleton do segmenter é cache de vida da página
(recriar a sessão wasm por abertura custaria segundos); máscaras/bitmaps são liberados por
resultado. Gatilho: memória do tab virar problema medido (heap profiler) em uso prolongado.
- **Stub de e2e em módulo lazy** — o stub é pequeno e o flag é build-time (`NEXT_PUBLIC_*`);
mover para import dinâmico só se o bundle inicial do cards crescer além do appetite.
- **`slot?` opcional do `renderTeamCard`/`slot` do adaptador de e2e pinado por import** —
flexibilidade sem call site; documentado, sem Issue (score ≤2).
**Explicitamente fora (não reabrir):** detalhe do erro do motor na UI (engine/empty usam a
mesma copy recuperável por decisão de produto); `CardModel` como união discriminada por kind
(Decisão 2 registrou campos aditivos); união/dispose do segmenter (acima); preview sem
`text-balance` no drawer (herdado do shell, acessível); trigger duplicado do wasm em dev/build
(Dockerfile não roda `prebuild` — comentado nos três pontos).
## Riscos e mitigação
- **Qualidade de borda do selfie segmenter (cabelo) abaixo do ISNet:** o aceite é honesto (busto/fundo simples, sem recorte perfeito); craft com fotos reais; alpha da máscara de confiança + escala bilinear suaviza a borda. Gatilho de revisão: se o UAT reprovar, o adapter é o único ponto a trocar (decisão de motor reaberta com o custo de licença explícito).
- **Licença AGPL do pacote já existente:** `@imgly/background-removal-node` continua restrito a scripts/dev (não distribuído ao usuário).
- **Build standalone sem os wasm:** o script de cópia roda no builder e no `prebuild`; o aceite verifica fetch 200 same-origin das URLs do wasm no deploy.
- **Wasm grande no primeiro uso (~12MB):** lazy (nada no bundle inicial), cache HTTP, progresso explícito; sem headers globais.
- **Bbox degenerado/segmentação vazia:** fail-closed com erro recuperável (nunca card com foto invisível); unit cobre o frame e e2e cobre o estado de erro.
- **Corrida entre tentativas/troca de foto:** run id no hook; resultado antigo ignorado; desmontar não seta estado e faz dispose seguro.
- **e2e instável:** stub determinístico evita wasm/inferência/console logs no CI; o describe S14 existente é ajustado (loop com 4 ids) no mesmo PR.
- **`prebuild` novo no fluxo de build:** script idempotente/rápido, roda em CI/dev/Docker; falha de cópia falha o build (não silencia).
- **iOS/Safari:** wasm SIMD suportado nas versões atuais; `destination-in`/`getImageData` ok; validar no craft (iPhone físico, como no S13).
- **Fonte no canvas:** banners só desenham depois de `ensureCardFont` — sem fallback medido errado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: 4º modelo `Time de você` na home e `/cards` (mesmo editor/shells); nome + foto de busto; `TIME DE <NOME>` nos banners com a foto recortada no centro sobre o fundo oficial; recorte no aparelho com progresso e retry; auto-enquadrado com pan/zoom como escape; nome em 1 linha com erro acionável (mesma mensagem); `/cards?model=time-de-voce`; toque e teclado; sem conta/analytics de PII; expectativa honesta de recorte.
- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent; `src/lib` puro e sem DOM (engine no adapter em `src/components/cards`); copy pt-BR/identificadores em inglês; sem `as never`; knip sem órfãos; artes-mestre intocadas; `pnpm gate:fast` + e2e da superfície + `pnpm push` verdes.
- [ ] Testes de domínio previstos: unit dos módulos puros (catálogo 4 ids, `frameCardPhotoOnBbox`, fit do slot do time, ordem/ops do `renderTeamCard`) obrigatório; e2e no spec `frontend` com stub do motor (processing/ready/download real/erro/retry/nome longo/mobile); **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:** motor (licença/hosting/progresso), 4º kind, auto-frame, fit/rotação, estado/hook, harness de e2e e copy — todas com Opções/Recomendação/Rejeitadas.
2. **Cabe no appetite:** 3 fases ≤3 dias, sem migration/infra nova; tracer de geometria antes do motor pesado.
3. **Rabbit holes nomeados:** worker/COOP, máscara manual, segundo editor, pipeline genérico, engine real no CI, CDN de terceiros.
4. **Depth check:** reusa `CardsStudio`/shells, `fitCardName`, `cardPhotoTransform`/clamp, `cardRender`, `loadCardImage`/`loadCardPhoto`/`ensureCardFont`, prewarm/manifest/specs; nada de abstração especulativa.
5. **Intenção preservada:** aceite de produto intacto (outcome, geometria literal, privacidade, expectativa honesta); a engenharia só resolveu a escolha de motor que a intenção deixou explicitamente aberta.

View file

@ -253,6 +253,8 @@ const eslintConfig = [
'coverage/**',
// Vendored agent material (same carve-out as .prettierignore):
'.agents/**',
// S15 — MediaPipe wasm glue copied from node_modules (generated, gitignored):
'public/cards/mediapipe/**',
]),
]

View file

@ -21,6 +21,7 @@
"license": "UNLICENSED",
"type": "module",
"scripts": {
"prebuild": "node scripts/copy-card-vision-assets.mjs",
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"db:start": "node scripts/db-start.mjs",
"db:stop": "docker compose -p teqo down",
@ -101,6 +102,7 @@
"@fullcalendar/react": "7.0.2",
"@hookform/resolvers": "^5.2.2",
"@imgly/background-removal-node": "^1.4.5",
"@mediapipe/tasks-vision": "^1.0.1",
"@payloadcms/db-postgres": "3.82.0",
"@payloadcms/email-resend": "3.82.0",
"@payloadcms/live-preview-react": "3.82.0",

View file

@ -220,6 +220,13 @@ export default defineConfig({
env: {
DATABASE_URL: process.env.DATABASE_URL as string,
PORT: webServerPort,
/*
* S15 — the card-cutout engine is replaced by a deterministic stub in
* tests: no 12 MB wasm download, no inference, no console noise. The
* real engine is exercised manually (craft/UAT). CI's prod build passes
* the same flag in its "Build for e2e" step.
*/
NEXT_PUBLIC_CARDS_CUTOUT_STUB: process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB ?? '1',
/*
* C122 — the fake service-account key lets the agenda Google mirror
* derive real states (synced/disabled/paused) in the server process.

View file

@ -29,6 +29,9 @@ importers:
'@imgly/background-removal-node':
specifier: ^1.4.5
version: 1.4.5
'@mediapipe/tasks-vision':
specifier: ^1.0.1
version: 1.0.1
'@payloadcms/db-postgres':
specifier: 3.82.0
version: 3.82.0(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))
@ -1789,6 +1792,9 @@ packages:
peerDependencies:
yjs: '>=13.5.22'
'@mediapipe/tasks-vision@1.0.1':
resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==}
'@modelcontextprotocol/sdk@1.27.1':
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
engines: {node: '>=18'}
@ -9490,6 +9496,8 @@ snapshots:
lexical: 0.41.0
yjs: 13.6.29
'@mediapipe/tasks-vision@1.0.1': {}
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.11(hono@4.12.7)

View file

@ -0,0 +1,25 @@
selfie_segmenter.tflite
=======================
Model: MediaPipe Selfie Segmenter (float16, 256x256)
Source: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/1/selfie_segmenter.tflite
SHA-256: 191ac9529ae506ee0beefa6b2c945a172dab9d07d1e802a290a4e4038226658b
Copyright 2023 The MediaPipe Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The MediaPipe Vision wasm runtime is not vendored here: the published npm
package `@mediapipe/tasks-vision` (also Apache-2.0) is copied into
public/cards/mediapipe/wasm/ at dev/build time by
scripts/copy-card-vision-assets.mjs.

Binary file not shown.

View file

@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* S15 — copies the MediaPipe Vision wasm runtime into `public/cards/mediapipe/`
* so the card cutout runs 100% same-origin (no CDN executing JS in the
* visitor's browser; the e2e suite never touches the network).
*
* The wasm is a gitignored build artifact: this script runs in `pnpm dev`
* (preflight), in `pnpm build` (npm `prebuild`) and in the Docker builder
* before `next build`, so both the dev server and the standalone image serve
* the files. Idempotent and cheap: a file already present with the same byte
* size is skipped.
*/
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const WASM_FILES = [
'vision_wasm_internal.js',
'vision_wasm_internal.wasm',
'vision_wasm_nosimd_internal.js',
'vision_wasm_nosimd_internal.wasm',
]
export const copyCardVisionAssets = async (repoRoot) => {
const sourceDir = path.join(repoRoot, 'node_modules/@mediapipe/tasks-vision/wasm')
const targetDir = path.join(repoRoot, 'public/cards/mediapipe/wasm')
await mkdir(targetDir, { recursive: true })
for (const name of WASM_FILES) {
const source = path.join(sourceDir, name)
const target = path.join(targetDir, name)
try {
const [sourceStat, targetStat] = await Promise.all([
stat(source),
stat(target).catch(() => null),
])
if (targetStat && targetStat.size === sourceStat.size) continue
await writeFile(target, await readFile(source))
console.log(`[copy-card-vision-assets] ${name}`)
} catch (error) {
throw new Error(
`[copy-card-vision-assets] failed to copy ${name}: ${error instanceof Error ? error.message : error}`,
)
}
}
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isDirectRun) {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
await copyCardVisionAssets(repoRoot)
}

View file

@ -14,6 +14,7 @@
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyCardVisionAssets } from './copy-card-vision-assets.mjs'
import './guard-dev-db.mjs'
import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs'
@ -22,6 +23,14 @@ const die = dieWithLabel('dev')
loadCliEnv()
// S15 — the card-cutout wasm runtime is a gitignored artifact: make sure the dev
// server serves it same-origin before Next boots.
try {
await copyCardVisionAssets(process.cwd())
} catch (error) {
die(error.message)
}
let port
try {
port = resolveDevPort(process.env)

View file

@ -8,7 +8,7 @@ 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.'
'Escolha um dos quatro modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
export const metadata: Metadata = {
title: 'Crie seu card de apoio | Jorge Solla',

View file

@ -184,6 +184,11 @@ body,
--campaign-band: #ebe9e9;
--campaign-line: rgb(0 0 0 / 12%);
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
/* S15 — literal colors of the "Time de você" card banners, measured from the
reference card. The canvas renderer keeps the same literals in `src/lib`. */
--team-red: #e50e2f;
--team-yellow: #ffec01;
--team-blue: #0061a5;
/* S9 — the "novidades" capture form sits on the light band, so the field
border must be visible against it (the :root default is a white border
for the dark WhatsApp page). */

View file

@ -5,6 +5,7 @@ import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
CheckIcon,
MinusIcon,
PlusIcon,
} from 'lucide-react'
@ -20,20 +21,27 @@ import {
} from '@/components/cards/cardCanvas'
import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
import { useCardCutout } from '@/components/cards/useCardCutout'
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 { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels'
import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
import {
CARD_PHOTO_MAX_ZOOM,
CARD_PHOTO_MIN_ZOOM,
cardPhotoTransformsEqual,
centerCardPhotoTransform,
panCardPhotoTransform,
zoomCardPhotoTransform,
type CardPhotoSize,
type CardPhotoTransform,
} from '@/lib/cardPhotoTransform'
import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender'
import {
createCardMeasure,
renderNameCard,
renderPhotoCard,
renderTeamCard,
} from '@/lib/cardRender'
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'
@ -42,6 +50,57 @@ const secondaryButtonClassName =
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'
const NAME_TOO_LONG_MESSAGE =
'Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.'
/** S15 design gate: the team preview width per state. */
type TeamPreviewStage = 'idle' | 'processing' | 'ready' | 'result'
const TEAM_PREVIEW_WIDTH: Record<TeamPreviewStage, string> = {
idle: 'max-w-[10.75rem]',
processing: 'max-w-[9rem]',
ready: 'max-w-[15rem]',
result: 'max-w-[11.875rem]',
}
const TeamNameField = ({
id,
value,
onChange,
error,
inputClassName,
}: {
id: string
value: string
onChange: (value: string) => void
error: boolean
inputClassName: string
}) => (
<div>
<label htmlFor={id} className="block text-sm font-bold text-(--campaign-ink)">
Seu nome
</label>
<input
id={id}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
maxLength={60}
placeholder="Como você é chamado"
aria-invalid={error}
className={inputClassName}
/>
<p className="mt-2 text-xs leading-5 text-(--campaign-muted)">
Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido.
</p>
{error ? (
<p role="alert" className="mt-2 text-sm leading-5 font-semibold text-(--pt-red)">
{NAME_TOO_LONG_MESSAGE}
</p>
) : null}
</div>
)
type CardComposerProps = {
model: CardModel
shell: 'dialog' | 'drawer'
@ -58,6 +117,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const [photoError, setPhotoError] = useState<string | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null)
const [overlayImage, setOverlayImage] = useState<HTMLImageElement | null>(null)
const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
@ -68,10 +129,32 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const zoomInputId = useId()
const photoWindow = model.photoWindow
const isNameModel = model.kind === 'name'
const isTeamModel = model.kind === 'team'
const cutout = useCardCutout(photoWindow)
const cutoutState = cutout.state
const teamProcessing = cutoutState.status === 'processing' ? cutoutState : null
const teamError = cutoutState.status === 'error' ? cutoutState : null
const teamReady = cutoutState.status === 'ready' ? cutoutState : null
const photoSize = useMemo<CardPhotoSize | null>(
() => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null),
[photo],
)
const transformSourceSize = useMemo<CardPhotoSize | null>(() => {
if (!isTeamModel) return photoSize
return teamReady ? { width: teamReady.width, height: teamReady.height } : null
}, [isTeamModel, photoSize, teamReady])
/**
* The cutout carries the initial framing; local state takes over after the
* first pan/zoom. Deriving (instead of mirroring through an effect) keeps the
* first ready paint composed — no bare-base frame.
*/
const effectiveTransform = isTeamModel
? (photoTransform ?? teamReady?.transform ?? null)
: photoTransform
// Assets + font load once per model: typing/panning must not flip the preview
// back into the loading state (flicker) nor re-announce the live region.
@ -79,14 +162,22 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
let cancelled = false
setLoadState('loading')
setBaseImage(null)
setOverlayImage(null)
setPreviewImage(null)
const load = async () => {
try {
const fontReady = await ensureCardFont(fontFamily)
if (!fontReady) throw new Error('card-font-unavailable')
const image = await loadCardImage(model.assetSrc)
const [image, overlay, preview] = await Promise.all([
loadCardImage(model.assetSrc),
model.overlaySrc ? loadCardImage(model.overlaySrc) : Promise.resolve(null),
model.previewSrc ? loadCardImage(model.previewSrc) : Promise.resolve(null),
])
if (cancelled) return
setBaseImage(image)
setOverlayImage(overlay)
setPreviewImage(preview)
setLoadState('ready')
} catch {
if (!cancelled) setLoadState('error')
@ -97,7 +188,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
return () => {
cancelled = true
}
}, [model.assetSrc, fontFamily])
}, [model.assetSrc, model.overlaySrc, model.previewSrc, fontFamily])
useEffect(() => {
const ctx = canvasRef.current?.getContext('2d')
@ -105,7 +196,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
ctx.clearRect(0, 0, model.width, model.height)
if (model.kind === 'name') {
if (isNameModel) {
setNameFit(
renderNameCard(ctx, model, {
image: baseImage,
@ -117,20 +208,44 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
return
}
if (photo && photoTransform && photoWindow && photoSize) {
if (isTeamModel) {
const measure = createCardMeasure(ctx, fontFamily)
if (teamReady && overlayImage && effectiveTransform && photoWindow) {
const result = renderTeamCard(ctx, model, {
base: baseImage,
overlay: overlayImage,
photo: teamReady.canvas,
photoSize: { width: teamReady.width, height: teamReady.height },
transform: effectiveTransform,
window: photoWindow,
name,
fontFamily,
measure,
})
setNameFit(result.fit)
if (!cardPhotoTransformsEqual(result.transform, effectiveTransform)) {
setPhotoTransform(result.transform)
}
return
}
setNameFit(fitCardName(name, measure, TEAM_CARD_NAME_SLOT))
const idleImage = cutoutState.status === 'idle' && previewImage ? previewImage : baseImage
ctx.drawImage(idleImage, 0, 0, model.width, model.height)
return
}
if (photo && effectiveTransform && photoWindow && photoSize) {
const clamped = renderPhotoCard(ctx, {
photo,
frame: baseImage,
photoSize,
frameSize: { width: model.width, height: model.height },
window: photoWindow,
transform: photoTransform,
transform: effectiveTransform,
})
if (
clamped.zoom !== photoTransform.zoom ||
clamped.offsetX !== photoTransform.offsetX ||
clamped.offsetY !== photoTransform.offsetY
) {
if (!cardPhotoTransformsEqual(clamped, effectiveTransform)) {
setPhotoTransform(clamped)
}
return
@ -141,7 +256,23 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
ctx.fillRect(0, 0, model.width, model.height)
ctx.drawImage(baseImage, 0, 0, model.width, model.height)
}
}, [baseImage, loadState, model, name, fontFamily, photo, photoSize, photoTransform, photoWindow])
}, [
baseImage,
overlayImage,
previewImage,
loadState,
isNameModel,
isTeamModel,
teamReady,
cutoutState.status,
model,
name,
fontFamily,
photo,
photoSize,
effectiveTransform,
photoWindow,
])
const handlePhotoFile = async (file: File | undefined) => {
if (!file || !photoWindow) return
@ -165,8 +296,31 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
}
const handleTeamPhotoFile = (file: File | undefined) => {
if (!file) return
setPhotoError(null)
setPhotoTransform(null)
void cutout.start(file)
if (fileInputRef.current) fileInputRef.current.value = ''
}
/** Applies a pan/zoom update over the derived or user-owned transform. */
const withTransform = (
update: (
transform: CardPhotoTransform,
size: CardPhotoSize,
window: CardRect,
) => CardPhotoTransform,
) => {
if (!transformSourceSize || !photoWindow) return
setPhotoTransform((current) => {
const base = current ?? (isTeamModel ? (teamReady?.transform ?? null) : null)
return base ? update(base, transformSourceSize, photoWindow) : base
})
}
const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
if (!photoSize || !photoWindow) return
if (!transformSourceSize || !photoWindow) return
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
event.currentTarget.setPointerCapture(event.pointerId)
}
@ -174,14 +328,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current
const canvas = canvasRef.current
if (!drag || drag.pointerId !== event.pointerId || !photoSize || !photoWindow || !canvas) return
if (!drag || drag.pointerId !== event.pointerId || !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((current) =>
current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
withTransform((transform, size, window) =>
panCardPhotoTransform(transform, size, window, dx, dy),
)
}
@ -190,16 +344,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
const panBy = (dx: number, dy: number) => {
if (!photoSize || !photoWindow) return
setPhotoTransform((current) =>
current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
withTransform((transform, size, window) =>
panCardPhotoTransform(transform, size, window, dx, dy),
)
}
const zoomTo = (zoom: number) => {
if (!photoSize || !photoWindow) return
setPhotoTransform((current) =>
current ? zoomCardPhotoTransform(current, photoSize, photoWindow, zoom) : current,
withTransform((transform, size, window) =>
zoomCardPhotoTransform(transform, size, window, zoom),
)
}
@ -219,31 +371,64 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
}
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 canAdvance = isNameModel
? nameCanAdvance
: isTeamModel
? teamReady !== null && nameCanAdvance
: photo !== null
const nameError =
(isNameModel || isTeamModel) && name.trim().length > 0 && nameFit !== null && !nameFit.ok
const adjustable = isTeamModel ? teamReady !== null : photo !== null
const title = (() => {
if (step === 'result') return 'Seu card está pronto para compartilhar.'
if (isNameModel) return 'Personalize com seu nome'
if (!isTeamModel) return 'Enquadre sua foto'
if (teamProcessing) return 'Preparando sua foto'
if (teamError) return 'Vamos tentar outra vez'
if (nameError) return 'Encurte o nome'
if (teamReady) return 'Confira seu card'
return 'Entre para o time'
})()
const description = (() => {
if (step === 'result' || isNameModel) return null
if (isTeamModel) {
return teamReady && !nameError
? 'O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.'
: null
}
return 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
})()
const eyebrow = isTeamModel
? step === 'result'
? 'Tudo certo'
: teamError
? 'Falha no recorte'
: nameError
? 'Nome muito longo'
: 'Time de você'
: null
const eyebrowNode = eyebrow ? (
<p className="text-[10px] font-black tracking-[0.1em] text-(--pt-red) uppercase sm:text-xs">
{eyebrow}
</p>
) : null
// The drawer header defaults to centered + `shrink-0`, so a long description
// would overflow the popup; `text-left!` + `flex-1` port the design gate
// (left-aligned, wrapped header) for every model in the shared composer.
const header =
shell === 'dialog' ? (
<DialogHeader className="min-w-0 text-left">
{eyebrowNode}
<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">
<DrawerHeader className="min-w-0 flex-1 text-left!">
{eyebrowNode}
<DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance">
{title}
</DrawerTitle>
@ -251,97 +436,17 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
</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>
const teamPreviewStage: TeamPreviewStage =
step === 'result' ? 'result' : teamReady ? 'ready' : teamProcessing ? 'processing' : 'idle'
const previewWidthClassName = isTeamModel ? TEAM_PREVIEW_WIDTH[teamPreviewStage] : 'max-w-[22rem]'
<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>
const nameInputClassName = `mt-2 h-11 w-full rounded-lg bg-white px-3 text-base text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
nameError
? 'border-2 border-(--pt-red)'
: 'border border-(--field-border) focus-visible:border-(--pt-red)'
}`
<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)">{CARD_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'compose' && !isNameModel && photoWindow ? (
<div className="mt-5">
{photo && photoTransform ? (
const zoomAndPanControls = effectiveTransform ? (
<>
<div className="flex items-center gap-3">
<label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)">
@ -350,7 +455,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
<button
type="button"
aria-label="Diminuir zoom"
onClick={() => zoomTo(photoTransform.zoom - 0.1)}
onClick={() => zoomTo(effectiveTransform.zoom - 0.1)}
className={controlButtonClassName}
>
<MinusIcon className="size-4" aria-hidden="true" />
@ -361,14 +466,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
min={CARD_PHOTO_MIN_ZOOM}
max={CARD_PHOTO_MAX_ZOOM}
step={0.05}
value={photoTransform.zoom}
value={effectiveTransform.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)}
onClick={() => zoomTo(effectiveTransform.zoom + 0.1)}
className={controlButtonClassName}
>
<PlusIcon className="size-4" aria-hidden="true" />
@ -413,22 +518,246 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
</div>
</div>
</>
) : null
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 ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}`}
tabIndex={adjustable ? 0 : undefined}
role={adjustable ? 'group' : undefined}
aria-label={adjustable ? 'Ajuste da foto' : undefined}
onKeyDown={
adjustable
? (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={adjustable ? handlePointerDown : undefined}
onPointerMove={adjustable ? handlePointerMove : undefined}
onPointerUp={adjustable ? handlePointerEnd : undefined}
onPointerCancel={adjustable ? handlePointerEnd : undefined}
/>
{teamReady && step === 'compose' && !nameError ? (
<span className="pointer-events-none absolute right-2 bottom-2 rounded bg-black/70 px-2 py-1 text-[10px] font-bold text-white">
Arraste para ajustar
</span>
) : null}
</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)">
{NAME_TOO_LONG_MESSAGE}
</p>
) : null}
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'compose' && isTeamModel && !teamProcessing && !teamReady && !teamError ? (
<div className="mt-5">
<TeamNameField
id={nameInputId}
value={name}
onChange={setName}
error={nameError}
inputClassName={nameInputClassName}
/>
<div className="mt-5 rounded-lg border border-(--campaign-line) bg-(--campaign-surface) p-4">
<p className="text-sm font-bold text-(--campaign-ink)">Sua foto de busto</p>
<p className="mt-1 text-sm leading-5 text-(--campaign-muted)">
Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples.
</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-3 inline-flex min-h-11 w-full 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:outline-none"
>
Escolher foto
</button>
</div>
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'compose' && teamProcessing ? (
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<p
role="status"
aria-live="polite"
className="text-sm font-bold text-(--campaign-ink)"
>
{teamProcessing.phase === 'download'
? 'Baixando o modelo de recorte…'
: 'Removendo o fundo da foto…'}
</p>
<p aria-hidden="true" className="text-sm font-black text-(--pt-red)">
{Math.round(teamProcessing.ratio * 100)}%
</p>
</div>
<div
role="progressbar"
aria-label={`Progresso do recorte: ${Math.round(teamProcessing.ratio * 100)}%`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(teamProcessing.ratio * 100)}
className="mt-2 h-2 overflow-hidden rounded-full bg-(--campaign-band)"
>
<div
className="h-full rounded-full bg-(--pt-red) transition-all"
style={{ width: `${Math.round(teamProcessing.ratio * 100)}%` }}
/>
</div>
<p className="mt-3 text-sm leading-5 text-(--campaign-muted)">
Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto
no seu aparelho. Isso pode levar um pouco.
</p>
<div className="mt-4 flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
<span
aria-hidden="true"
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-(--team-blue) text-white"
>
<CheckIcon className="size-3" aria-hidden="true" />
</span>
<p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p>
</div>
</div>
) : null}
{step === 'compose' && teamError ? (
<div className="mt-5">
<div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4">
<p className="text-sm font-black text-(--pt-red)">
Não foi possível remover o fundo desta foto.
</p>
<p className="mt-1 text-sm leading-5 text-(--campaign-ink)">
Tente de novo ou escolha outra foto.
</p>
</div>
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
</p>
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_PHOTO_PRIVACY_NOTE}
</p>
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={secondaryButtonClassName}
>
Escolher outra foto
</button>
<button type="button" onClick={cutout.retry} className={primaryButtonClassName}>
Tentar de novo
</button>
</div>
</div>
) : null}
{step === 'compose' && teamReady && photoWindow && transformSourceSize ? (
<div className="mt-5">
{nameError ? (
<>
<TeamNameField
id={nameInputId}
value={name}
onChange={setName}
error={nameError}
inputClassName={nameInputClassName}
/>
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">
A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você
corrige o nome.
</p>
</>
) : (
<>
{zoomAndPanControls}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={`${secondaryButtonClassName} mt-4 w-full`}
>
Trocar foto
</button>
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_PHOTO_PRIVACY_NOTE}
</p>
</>
)}
</div>
) : null}
{step === 'compose' && !isNameModel && !isTeamModel && photoWindow ? (
<div className="mt-5">
{photo && effectiveTransform ? (
zoomAndPanControls
) : (
<p className="text-sm text-(--campaign-muted)">
Escolha uma foto vertical ou horizontal para começar.
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
tabIndex={-1}
aria-hidden="true"
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>
@ -452,9 +781,35 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
</div>
) : null}
<input
ref={fileInputRef}
type="file"
accept="image/*"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={(event) =>
isTeamModel
? handleTeamPhotoFile(event.target.files?.[0])
: void handlePhotoFile(event.target.files?.[0])
}
/>
{step === 'result' ? (
<div className="mt-5">
{isTeamModel ? (
<div className="flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
<span
aria-hidden="true"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-green-700 text-white"
>
<CheckIcon className="size-3.5" aria-hidden="true" />
</span>
<p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
) : (
<p className="text-sm text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
)}
{downloadError ? (
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
{downloadError}
@ -476,7 +831,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
onClick={() => setStep('result')}
className={primaryButtonClassName}
>
Criar meu card
{teamProcessing ? 'Processando…' : 'Criar meu card'}
</button>
</>
) : (

View file

@ -12,10 +12,10 @@ const tileClassName =
'group flex h-full w-full flex-col rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:ring-offset-(--campaign-cream)'
/**
* S14 — the three models side by side on desktop and a one-item-with-peek snap
* track on mobile, as buttons for the studio island: selecting a tile opens the
* single composer in place (home or `/cards`). Both tracks come from the same
* catalog (S13).
* S14/S15 — the four models side by side on desktop and a one-item-with-peek
* snap track on mobile, as buttons for the studio island: selecting a tile
* opens the single composer in place (home or `/cards`). Both tracks come from
* the same catalog (S13).
*/
export const CardModelGallery = ({
ariaLabel,
@ -47,7 +47,7 @@ export const CardModelGallery = ({
<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"
className="m-0 hidden list-none grid-cols-4 gap-5 p-0 sm:gap-6 lg:grid"
>
{CARD_MODELS.map((model) => (
<li key={model.id} className="m-0">
@ -68,7 +68,7 @@ export const CardModelGallery = ({
</ul>
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
Deslize para ver os três modelos
Deslize para ver os quatro modelos
</p>
</div>
)

View file

@ -6,10 +6,11 @@ import type { CardModel } from '@/lib/cardModels'
import { cn } from '@/lib/utils'
/**
* S13 — artwork preview of one card model, shared by the home section and the
* `/cards` gallery. The name tile reuses the empty master with a `SEU NOME`
* S13/S15 — artwork preview of one card model, shared by the home section and
* the `/cards` gallery. The name tile reuses the empty master with a `SEU NOME`
* preview drawn by the real composer pipeline (S14); the photo tiles show the
* official transparent overlay above a neutral photo slot.
* official transparent overlay above a neutral photo slot; the team tile shows
* the filled example (`previewSrc`) with its `NOVO` badge.
*/
export const CardModelTile = ({
model,
@ -50,10 +51,10 @@ export const CardModelTile = ({
) : null}
<Image
src={model.assetSrc}
src={model.previewSrc ?? model.assetSrc}
alt=""
fill
sizes="(min-width: 1024px) 340px, 78vw"
sizes="(min-width: 1024px) 260px, 78vw"
className="object-cover"
/>
@ -63,6 +64,10 @@ export const CardModelTile = ({
<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>
) : model.badge ? (
<span className="absolute top-3 right-3 rounded-full bg-white/95 px-2.5 py-1 text-[10px] font-black text-(--team-blue) shadow">
{model.badge}
</span>
) : null}
</span>
)

View file

@ -0,0 +1,324 @@
/**
* S15 — on-device background removal for the `Time de você` card. The visitor's
* photo never leaves the browser: the model + wasm are fetched from our own
* `/cards` static assets and inference runs locally. Kept out of `src/lib`
* because it touches DOM/worker APIs; the pure framing math stays in
* `cardPhotoTransform`.
*
* Engine: MediaPipe `@mediapipe/tasks-vision` ImageSegmenter with the Apache-2.0
* `selfie_segmenter.tflite` (float16, 256×256) — no COOP/COEP, single-threaded
* wasm, lazily imported only when the visitor picks the team model. The
* `@imgly` packages stay out of the bundle (AGPL + ~40 MB on mobile).
*/
import type { ImageSegmenter, MPMask } from '@mediapipe/tasks-vision'
import type { CardAlphaBbox } from '@/lib/cardPhotoTransform'
declare global {
interface Window {
/** Test seam: the e2e build stubs the engine through this flag. */
__cardsCutoutStub?: 'ok' | 'slow' | 'error'
}
}
const WASM_DIR = '/cards/mediapipe/wasm'
const MODEL_URL = '/cards/selfie_segmenter.tflite'
const CUTOUT_MAX_EDGE = 1600
const ALPHA_THRESHOLD = 32
const STUB_ENABLED = process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB === '1'
const STUB_DELAY_MS = 1500
const STUB_PROGRESS = 0.36
/** Upscale target so the synthetic bbox passes the real cutout checks. */
const STUB_MIN_EDGE = 400
export type CardCutoutProgress = {
phase: 'download' | 'process'
/** 0..1 — real bytes while downloading, a coarse step while processing. */
ratio: number
}
type CardCutoutFailureReason = 'engine' | 'empty'
export type CardCutoutResult =
| {
ok: true
canvas: HTMLCanvasElement
width: number
height: number
bbox: CardAlphaBbox
}
| { ok: false; reason: CardCutoutFailureReason }
/**
* Removes the background of `file` on this device. Resolves with the cutout
* canvas + its alpha bbox, or fails with `engine` (decode/inference/asset
* failure) or `empty` (blank segmentation) — both recoverable in the composer.
*/
export const removeCardPhotoBackground = async (
file: File,
onProgress: (progress: CardCutoutProgress) => void,
): Promise<CardCutoutResult> => {
const report = createProgressReporter(onProgress)
try {
return STUB_ENABLED ? await stubCutout(file, report) : await engineCutout(file, report)
} catch {
return { ok: false, reason: 'engine' }
}
}
const PROGRESS_MIN_INTERVAL_MS = 100
/**
* Streaming a ~12 MB wasm fires a progress event per chunk; without throttle
* that is hundreds of React updates in one microtask chain (the dev build
* reports "Maximum update depth exceeded"). One report per 100 ms — always
* emitting phase changes and completion — keeps the bar honest and the UI calm.
*/
const createProgressReporter = (onProgress: (progress: CardCutoutProgress) => void) => {
let lastAt = 0
let lastPhase: CardCutoutProgress['phase'] | null = null
return (progress: CardCutoutProgress) => {
const now = Date.now()
const phaseChanged = progress.phase !== lastPhase
const finished = progress.ratio >= 1
if (!phaseChanged && !finished && now - lastAt < PROGRESS_MIN_INTERVAL_MS) return
lastAt = now
lastPhase = progress.phase
onProgress(progress)
}
}
let segmenterPromise: Promise<ImageSegmenter> | null = null
const loadSegmenter = (onProgress: (progress: CardCutoutProgress) => void) => {
if (!segmenterPromise) {
segmenterPromise = createSegmenter(onProgress).catch((error: unknown) => {
segmenterPromise = null
throw error
})
}
return segmenterPromise
}
const createSegmenter = async (onProgress: (progress: CardCutoutProgress) => void) => {
const wasmFiles = supportsWasmSimd()
? ['vision_wasm_internal.js', 'vision_wasm_internal.wasm']
: ['vision_wasm_nosimd_internal.js', 'vision_wasm_nosimd_internal.wasm']
await prefetchAssets([...wasmFiles.map((name) => `${WASM_DIR}/${name}`), MODEL_URL], onProgress)
const vision = await import('@mediapipe/tasks-vision')
const fileset = await vision.FilesetResolver.forVisionTasks(WASM_DIR)
const modelResponse = await fetch(MODEL_URL)
if (!modelResponse.ok) throw new Error(`card-cutout-model:${modelResponse.status}`)
const modelAssetBuffer = new Uint8Array(await modelResponse.arrayBuffer())
return vision.ImageSegmenter.createFromOptions(fileset, {
baseOptions: { modelAssetBuffer },
runningMode: 'IMAGE',
outputConfidenceMasks: true,
outputCategoryMask: false,
})
}
/**
* Streams each asset once so the progress bar reflects real bytes — and warms
* the HTTP cache for the runtimes that later fetch the same URLs themselves.
*/
const prefetchAssets = async (
urls: string[],
onProgress: (progress: CardCutoutProgress) => void,
) => {
let loadedFiles = 0
for (const url of urls) {
const response = await fetch(url)
if (!response.ok) throw new Error(`card-cutout-asset:${response.status}`)
const size = Number(response.headers.get('content-length')) || 0
if (!response.body) {
await response.arrayBuffer()
loadedFiles += 1
onProgress({ phase: 'download', ratio: Math.min(1, loadedFiles / urls.length) })
continue
}
const reader = response.body.getReader()
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
received += value?.length ?? 0
const fileRatio = size > 0 ? Math.min(1, received / size) : 0.5
onProgress({ phase: 'download', ratio: Math.min(1, (loadedFiles + fileRatio) / urls.length) })
}
loadedFiles += 1
onProgress({ phase: 'download', ratio: Math.min(1, loadedFiles / urls.length) })
}
}
const engineCutout = async (
file: File,
onProgress: (progress: CardCutoutProgress) => void,
): Promise<CardCutoutResult> => {
const segmenter = await loadSegmenter(onProgress)
onProgress({ phase: 'process', ratio: 0.5 })
const bitmap = await createImageBitmap(file)
const { width, height } = cappedSize(bitmap.width, bitmap.height)
// Let the processing state paint before the synchronous inference.
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
const result = segmenter.segment(bitmap)
let mask: MPMask | null = null
try {
mask = result.confidenceMasks?.[0] ?? null
if (!mask) return { ok: false, reason: 'engine' }
const canvas = composeCutout(bitmap, width, height, mask)
const bbox = readAlphaBbox(canvas)
if (!bbox) return { ok: false, reason: 'empty' }
onProgress({ phase: 'process', ratio: 1 })
return { ok: true, canvas, width, height, bbox }
} finally {
mask?.close()
result.close()
bitmap.close()
}
}
const composeCutout = (
image: ImageBitmap,
width: number,
height: number,
mask: MPMask,
): HTMLCanvasElement => {
const maskCanvas = document.createElement('canvas')
maskCanvas.width = mask.width
maskCanvas.height = mask.height
const maskCtx = maskCanvas.getContext('2d')
if (!maskCtx) throw new Error('card-cutout-mask-context')
const confidence = mask.getAsFloat32Array()
const maskImage = maskCtx.createImageData(mask.width, mask.height)
for (let index = 0, alpha = 3; index < confidence.length; index += 1, alpha += 4) {
const value = confidence[index] ?? 0
maskImage.data[alpha] = value <= 0 ? 0 : value >= 1 ? 255 : Math.round(value * 255)
}
maskCtx.putImageData(maskImage, 0, 0)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('card-cutout-context')
ctx.drawImage(image, 0, 0, width, height)
ctx.globalCompositeOperation = 'destination-in'
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(maskCanvas, 0, 0, width, height)
ctx.globalCompositeOperation = 'source-over'
return canvas
}
const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => {
const ctx = canvas.getContext('2d')
if (!ctx) return null
const { width, height } = canvas
const pixels = ctx.getImageData(0, 0, width, height).data
let minX = width
let minY = height
let maxX = -1
let maxY = -1
for (let y = 0; y < height; y += 1) {
const row = y * width * 4
for (let x = 0; x < width; x += 1) {
if (pixels[row + x * 4 + 3]! > ALPHA_THRESHOLD) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
if (maxX < minX || maxY < minY) return null
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }
}
const cappedSize = (width: number, height: number) => {
const longest = Math.max(width, height)
const scale = longest > CUTOUT_MAX_EDGE ? CUTOUT_MAX_EDGE / longest : 1
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
}
const supportsWasmSimd = (): boolean => {
// Minimal wasm module using `v128.const` — validates only where SIMD exists.
const probe = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b, 0x03,
0x02, 0x01, 0x00, 0x0a, 0x0a, 0x01, 0x08, 0x00, 0x41, 0x00, 0xfd, 0x0f, 0x1a, 0x0b,
])
try {
return WebAssembly.validate(probe)
} catch {
return false
}
}
const readStubMode = (): 'ok' | 'slow' | 'error' => {
if (typeof window === 'undefined') return 'ok'
const mode = window.__cardsCutoutStub
return mode === 'slow' || mode === 'error' ? mode : 'ok'
}
/**
* Test seam (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, e2e builds only): a
* deterministic cutout with the same shape as the engine result. `slow` keeps
* the processing state visible; `error` exercises the retry path.
*/
const stubCutout = async (
file: File,
onProgress: (progress: CardCutoutProgress) => void,
): Promise<CardCutoutResult> => {
const mode = readStubMode()
onProgress({ phase: 'download', ratio: mode === 'slow' ? STUB_PROGRESS : 1 })
await new Promise((resolve) => setTimeout(resolve, mode === 'slow' ? STUB_DELAY_MS : 20))
if (mode === 'error') return { ok: false, reason: 'engine' }
onProgress({ phase: 'process', ratio: 0.8 })
const bitmap = await createImageBitmap(file)
const capped = cappedSize(bitmap.width, bitmap.height)
const stubScale = Math.max(1, STUB_MIN_EDGE / Math.max(capped.width, capped.height))
const width = Math.round(capped.width * stubScale)
const height = Math.round(capped.height * stubScale)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('card-cutout-context')
ctx.drawImage(bitmap, 0, 0, width, height)
ctx.globalCompositeOperation = 'destination-in'
ctx.beginPath()
ctx.ellipse(width / 2, height / 2, width * 0.45, height * 0.45, 0, 0, Math.PI * 2)
ctx.fill()
ctx.globalCompositeOperation = 'source-over'
bitmap.close()
return { ok: true, canvas, width, height, bbox: { x: 0, y: 0, width, height } }
}

View file

@ -0,0 +1,88 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { removeCardPhotoBackground } from '@/components/cards/cardCutout'
import type { CardRect } from '@/lib/cardModels'
import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
export type CardCutoutState =
| { status: 'idle' }
| { status: 'processing'; phase: 'download' | 'process'; ratio: number }
| {
status: 'ready'
canvas: HTMLCanvasElement
width: number
height: number
/** Initial framing: cutout top on the slot top, centered, covering it. */
transform: CardPhotoTransform
}
| { status: 'error'; reason: 'engine' | 'empty' }
/**
* S15 — owns the cutout lifecycle for the team model: engine import, progress,
* degenerate framing (fail-closed), retry with the same file and run-id guards
* so a stale inference or an unmount never writes state. The engine itself is a
* page-lifetime singleton in the adapter (re-creating the wasm session per open
* would cost seconds); each result's masks/bitmap are released there.
*/
export const useCardCutout = (photoWindow: CardRect | undefined) => {
const [state, setState] = useState<CardCutoutState>({ status: 'idle' })
const fileRef = useRef<File | null>(null)
const runRef = useRef(0)
useEffect(
() => () => {
runRef.current += 1
},
[],
)
const start = useCallback(
async (file: File) => {
runRef.current += 1
const run = runRef.current
fileRef.current = file
setState({ status: 'processing', phase: 'download', ratio: 0 })
const result = await removeCardPhotoBackground(file, (progress) => {
if (runRef.current !== run) return
setState({ status: 'processing', phase: progress.phase, ratio: progress.ratio })
})
if (runRef.current !== run) return
if (!result.ok) {
setState({ status: 'error', reason: result.reason })
return
}
const transform = photoWindow
? frameCardPhotoOnBbox(
{ width: result.width, height: result.height },
photoWindow,
result.bbox,
)
: null
if (!transform) {
setState({ status: 'error', reason: 'empty' })
return
}
setState({
status: 'ready',
canvas: result.canvas,
width: result.width,
height: result.height,
transform,
})
},
[photoWindow],
)
const retry = useCallback(() => {
const file = fileRef.current
if (file) void start(file)
}, [start])
return { state, start, retry }
}

View file

@ -1,19 +1,25 @@
/**
* S13 — the three personalized-card models of the campaign site. Pure data so
* S13/S15 — the 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.
* real output size in pixels and, for photo/team 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.
* `docs/plans/cards-personalizados-campanha-impl.md` and
* `docs/plans/cards-time-de-voce-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
const CARD_MODEL_IDS = [
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
'time-de-voce',
] as const
export type CardModelId = (typeof CARD_MODEL_IDS)[number]
type CardModelKind = 'name' | 'photo'
type CardModelKind = 'name' | 'photo' | 'team'
export type CardRect = {
x: number
@ -29,8 +35,14 @@ export type CardModel = {
assetSrc: string
width: number
height: number
/** Photo models only: the transparent window the photo must cover. */
/** Photo and team models: the transparent window the photo must cover. */
photoWindow?: CardRect
/** Team model: the master front overlay drawn above the cutout photo. */
overlaySrc?: string
/** Team model: the filled example shown before a cutout is ready. */
previewSrc?: string
/** Optional gallery badge (e.g. `NOVO`). */
badge?: string
}
export const CARD_MODELS: readonly CardModel[] = [
@ -60,8 +72,56 @@ export const CARD_MODELS: readonly CardModel[] = [
height: 1440,
photoWindow: { x: 0, y: 0, width: 1000, height: 1044 },
},
{
id: 'time-de-voce',
kind: 'team',
label: 'Time de você',
assetSrc: '/cards/team-card-base.png',
overlaySrc: '/cards/team-card-front.png',
previewSrc: '/cards/team-card-example.jpg',
badge: 'NOVO',
width: 1080,
height: 1440,
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
},
]
/** Fields every name slot shares, whatever the drawing alignment. */
type CardNameSlotBase = {
capHeight: number
minCapHeight: number
maxInkWidth: number
maxLines: number
fill: string
}
/** Left-aligned slot with a two-line band (S13 name model). */
export type CardNameBlockSlot = CardNameSlotBase & {
align: 'left'
leftX: number
capTop: number
maxBlockBottom: number
}
/** Rotated rectangle + background the renderer draws for a banner. */
export type CardBanner = {
centerX: number
centerY: number
width: number
height: number
rotationDeg: number
background: string
}
/** Centered single-line slot drawn on a rotated banner (S15 team model). */
export type CardNameBannerSlot = CardNameSlotBase & {
align: 'center'
maxLines: 1
banner: CardBanner
}
export type CardNameSlot = CardNameBlockSlot | CardNameBannerSlot
/**
* Name model slot, pixel-measured from the filled master (`FULANO` example):
* the `#SOU` line and the visitor's name share the same left border at x=213
@ -71,6 +131,7 @@ export const CARD_MODELS: readonly CardModel[] = [
* over the base; the fixed lines already live in the art.
*/
export const NAME_CARD_SLOT = {
align: 'left',
leftX: 213,
capTop: 430,
capHeight: 106,
@ -79,8 +140,47 @@ export const NAME_CARD_SLOT = {
minCapHeight: 48,
fill: '#ffec01',
maxLines: 2,
} as const satisfies CardNameBlockSlot
/**
* S15 — geometry pixel-measured from `team-card-example.jpg` (the reference
* filled card). Neither master carries the top banners, so the renderer draws
* both rectangles + text over the composed art: the red `TIME DE` label and the
* blue name banner. `rotationDeg` is counter-clockwise, matching the masters'
* built-in tilt (the photo window sits inside the sky area at x=286 y=439).
*/
export const TEAM_CARD_LABEL = {
text: 'TIME DE',
centerX: 545,
centerY: 162,
width: 693,
height: 118,
rotationDeg: -3.5,
background: '#e50e2f',
fill: '#ffec01',
capHeight: 93,
} as const
export const TEAM_CARD_NAME_BANNER = {
centerX: 545,
centerY: 313,
width: 509,
height: 176,
rotationDeg: -4.1,
background: '#0061a5',
fill: '#ffffff',
} as const
export const TEAM_CARD_NAME_SLOT = {
align: 'center',
banner: TEAM_CARD_NAME_BANNER,
capHeight: 130,
minCapHeight: 56,
maxInkWidth: 470,
fill: TEAM_CARD_NAME_BANNER.fill,
maxLines: 1,
} as const satisfies CardNameBannerSlot
export const BRAND_FALLBACK_FONT = 'sans-serif'
export const isCardModelId = (value: unknown): value is CardModelId =>

View file

@ -9,7 +9,7 @@
* DOM and unit-testable with a deterministic fake.
*/
import { NAME_CARD_SLOT } from './cardModels'
import { NAME_CARD_SLOT, type CardNameBlockSlot, type CardNameSlot } from './cardModels'
export type CardTextMetrics = {
width: number
@ -29,8 +29,6 @@ export type CardNameFit =
}
| { 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
@ -73,7 +71,7 @@ const fitSingleLine = (
const fitTwoLines = (
words: string[],
measure: CardMeasureText,
slot: CardNameSlot,
slot: CardNameBlockSlot,
): { fontSize: number; lines: [string, string] } | null => {
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
const maxBlockHeight = slot.maxBlockBottom - slot.capTop
@ -134,7 +132,7 @@ export const fitCardName = (
}
const words = normalized.split(' ')
if (slot.maxLines >= 2 && words.length > 1) {
if (slot.align === 'left' && slot.maxLines >= 2 && words.length > 1) {
const twoLines = fitTwoLines(words, measure, slot)
if (twoLines !== null && twoLines.fontSize >= minFontSize) {
return {

View file

@ -13,6 +13,13 @@ import type { CardRect } from './cardModels'
export const CARD_PHOTO_MIN_ZOOM = 1
export const CARD_PHOTO_MAX_ZOOM = 4
/**
* S15 — a cutout smaller than this (in source pixels) is treated as an empty
* segmentation: the card would show a speck, so the composer fails closed and
* offers a retry instead.
*/
const CARD_CUTOUT_MIN_BBOX = 24
export type CardPhotoSize = {
width: number
height: number
@ -24,6 +31,17 @@ export type CardPhotoTransform = {
offsetY: number
}
/** Alpha bounding box of a cutout photo, in source pixels. */
export type CardAlphaBbox = {
x: number
y: number
width: number
height: number
}
export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean =>
a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max)
@ -81,6 +99,50 @@ export const centerCardPhotoTransform = (
}
}
/**
* S15 — initial framing for a cutout photo: the alpha bbox is scaled to cover
* the window, anchored at the bbox top (head against the top of the slot) and
* centered horizontally on it. Returns `null` when the bbox is degenerate
* (empty segmentation) or would demand more than the max zoom — the composer
* fails closed instead of drawing an invisible or speck-sized photo.
*/
export const frameCardPhotoOnBbox = (
source: CardPhotoSize,
window: CardRect,
bbox: CardAlphaBbox,
): CardPhotoTransform | null => {
if (
source.width <= 0 ||
source.height <= 0 ||
window.width <= 0 ||
window.height <= 0 ||
bbox.width < CARD_CUTOUT_MIN_BBOX ||
bbox.height < CARD_CUTOUT_MIN_BBOX
) {
return null
}
const scale = coverScale(source, window)
const requiredZoom = Math.max(
window.width / bbox.width / scale,
window.height / bbox.height / scale,
)
if (!Number.isFinite(requiredZoom) || requiredZoom > CARD_PHOTO_MAX_ZOOM) return null
const zoom = Math.max(CARD_PHOTO_MIN_ZOOM, requiredZoom)
const applied = scale * zoom
return clampCardPhotoTransform(
{
zoom,
offsetX: window.x + window.width / 2 - (bbox.x + bbox.width / 2) * applied,
offsetY: window.y - bbox.y * applied,
},
source,
window,
)
}
/** Nudges the offset by a step in card pixels, clamped to the window. */
export const panCardPhotoTransform = (
transform: CardPhotoTransform,

View file

@ -7,12 +7,22 @@
* recorder instead of jsdom canvas.
*/
import { BRAND_FALLBACK_FONT, NAME_CARD_SLOT, type CardModel, type CardRect } from './cardModels'
import {
BRAND_FALLBACK_FONT,
NAME_CARD_SLOT,
TEAM_CARD_LABEL,
TEAM_CARD_NAME_SLOT,
type CardBanner,
type CardModel,
type CardNameBannerSlot,
type CardNameSlot,
type CardRect,
} from './cardModels'
import {
fitCardName,
resolveFontSizeForCapHeight,
type CardMeasureText,
type CardNameFit,
type CardNameSlot,
type CardTextMetrics,
} from './cardNameFit'
import {
@ -28,8 +38,13 @@ export type CardDrawContext = {
textAlign: CanvasTextAlign
textBaseline: CanvasTextBaseline
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void
fillRect(x: number, y: number, width: number, height: number): void
fillText(text: string, x: number, y: number): void
measureText(text: string): CardTextMetrics
save(): void
restore(): void
translate(x: number, y: number): void
rotate(angle: number): void
}
export const createCardMeasure = (
@ -62,15 +77,58 @@ export type CardNameDrawArgs = {
slot?: CardNameSlot
}
type CardBannerDrawArgs = {
banner: CardBanner
text: string
fill: string
fontSize: number
capHeight: number
fontFamily: string
}
/**
* S14 — draws only the fitted name (no base image) so the home tile preview
* S15 — draws one official top banner: the rotated rectangle plus its centered
* cap-height text. Both team banners (the fixed `TIME DE` label and the fitted
* name) share this so the measured geometry lives once.
*/
const drawCardBanner = (ctx: CardDrawContext, args: CardBannerDrawArgs): void => {
const { banner } = args
ctx.save()
ctx.translate(banner.centerX, banner.centerY)
ctx.rotate((banner.rotationDeg * Math.PI) / 180)
ctx.fillStyle = banner.background
ctx.fillRect(-banner.width / 2, -banner.height / 2, banner.width, banner.height)
ctx.font = `700 ${args.fontSize}px ${args.fontFamily}`
ctx.fillStyle = args.fill
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.fillText(args.text, 0, args.capHeight / 2)
ctx.restore()
}
/**
* S14/S15 — draws only the fitted name (no base image) so the home tile preview
* reuses the exact composer geometry. `fit.ok` is the caller's contract: a
* failed fit never draws (names are never cut silently).
* failed fit never draws (names are never cut silently). Left slots draw on the
* master's border; the team slot draws the blue banner + centered name.
*/
export const drawCardName = (ctx: CardDrawContext, args: CardNameDrawArgs): void => {
const slot = args.slot ?? NAME_CARD_SLOT
const { fit } = args
if (slot.align === 'center') {
drawCardBanner(ctx, {
banner: slot.banner,
text: fit.lines[0],
fill: slot.fill,
fontSize: fit.fontSize,
capHeight: fit.capHeight,
fontFamily: args.fontFamily,
})
return
}
ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}`
ctx.fillStyle = slot.fill
ctx.textAlign = 'left'
@ -125,3 +183,58 @@ export const renderPhotoCard = (
return clampCardPhotoTransform(args.transform, args.photoSize, args.window)
}
export type TeamCardRenderArgs = {
base: CanvasImageSource
overlay: CanvasImageSource
photo: CanvasImageSource
photoSize: CardPhotoSize
transform: CardPhotoTransform
window: CardRect
name: string
fontFamily: string
measure: CardMeasureText
slot?: CardNameBannerSlot
}
export type TeamCardRenderResult = {
fit: CardNameFit
transform: CardPhotoTransform
}
/**
* S15 — the team card composition, in the measured order: master base → cutout
* photo (framed by the transform) → master front overlay → `TIME DE` banner →
* blue name banner (only when the name fits; never cut silently). The two
* banners are drawn by the renderer because neither master carries them.
*/
export const renderTeamCard = (
ctx: CardDrawContext,
model: CardModel,
args: TeamCardRenderArgs,
): TeamCardRenderResult => {
ctx.drawImage(args.base, 0, 0, model.width, model.height)
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window)
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
ctx.drawImage(args.overlay, 0, 0, model.width, model.height)
drawCardBanner(ctx, {
banner: TEAM_CARD_LABEL,
text: TEAM_CARD_LABEL.text,
fill: TEAM_CARD_LABEL.fill,
fontSize: resolveFontSizeForCapHeight(args.measure, TEAM_CARD_LABEL.capHeight),
capHeight: TEAM_CARD_LABEL.capHeight,
fontFamily: args.fontFamily,
})
const slot = args.slot ?? TEAM_CARD_NAME_SLOT
const fit = fitCardName(args.name, args.measure, slot)
if (fit.ok) drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot })
return {
fit,
transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window),
}
}

View file

@ -1707,11 +1707,14 @@ test.describe('Cards personalizados (S14)', () => {
await expect(
section.getByRole('heading', { name: 'Mostre que você está com Solla' }),
).toBeVisible()
// S14 — the CTA is gone: the three models are the only trigger.
// S14/S15 — the CTA is gone: the four models are the only trigger.
await expect(section.getByRole('link', { name: 'Criar meu card' })).toHaveCount(0)
for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular']) {
for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular', 'time-de-voce']) {
await expect(section.locator(`[data-card-model-tile="${model}"]`).first()).toBeAttached()
}
await expect(section.locator('[data-card-model-tile="time-de-voce"]').first()).toContainText(
'NOVO',
)
// S14 — the name tile reproduces the real result: `SEU NOME` drawn by the
// composer pipeline, left-aligned with the `SOU` border (x≈213 at 1080).
@ -1820,3 +1823,127 @@ test.describe('Cards personalizados (S14)', () => {
await expect(drawer).toHaveCount(0)
})
})
/**
* S15 — the team model runs its engine in an e2e build with the deterministic
* stub (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`): no wasm download, no inference. The
* real engine is exercised manually (craft/UAT); the stub keeps the states
* (progress, ready, error/retry) reproducible.
*/
/** Mirrors the stub's deterministic `slow` download progress (cardCutout.ts). */
const STUB_CUTOUT_PERCENT = 36
const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error') =>
page.addInitScript((value: string) => {
;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value
}, mode)
const uploadBustPhoto = (dialog: Locator) =>
dialog.locator('input[type="file"]').setInputFiles({
name: 'busto-e2e.png',
mimeType: 'image/png',
buffer: TEST_PHOTO_PNG,
})
test.describe('Cards personalizados (S15 — Time de você)', () => {
test('cuts the photo on device, reaches the result and downloads a real PNG', async ({
page,
}) => {
await setCutoutStub(page, 'slow')
await page.goto('/cards?model=time-de-voce')
const dialog = page.getByRole('dialog')
await expect(dialog.getByRole('heading', { name: 'Entre para o time' })).toBeVisible()
const canvas = dialog.locator('canvas')
await expect(canvas).toHaveAttribute('width', '1080')
await expect(canvas).toHaveAttribute('height', '1440')
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
await expect(primary).toBeDisabled()
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria')
await uploadBustPhoto(dialog)
// Processing is observable (first run: model download + local cutout).
await expect(dialog.getByText('Baixando o modelo de recorte…')).toBeVisible()
await expect(dialog.getByText(`${STUB_CUTOUT_PERCENT}%`)).toBeVisible()
await expect(dialog.getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
String(STUB_CUTOUT_PERCENT),
)
// The cutout lands framed: CTA enabled, pan/zoom escape offered.
await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible()
await expect(primary).toBeEnabled()
await expect(dialog.getByText('Arraste para ajustar')).toBeVisible()
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-time-de-voce.png')
const downloadPath = await download.path()
expect(downloadPath).toBeTruthy()
expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true)
})
test('a failed cutout is recoverable: retry with the same photo reaches the result', async ({
page,
}) => {
await setCutoutStub(page, 'error')
await page.goto('/cards?model=time-de-voce')
const dialog = page.getByRole('dialog')
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria')
await uploadBustPhoto(dialog)
await expect(dialog.getByRole('heading', { name: 'Vamos tentar outra vez' })).toBeVisible()
await expect(dialog.getByRole('alert')).toContainText(
'Não foi possível remover o fundo desta foto.',
)
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled()
// The retry re-runs the SAME file; no re-upload, no lost state.
await page.evaluate(() => {
;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = 'ok'
})
await dialog.getByRole('button', { name: 'Tentar de novo' }).click()
await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled()
})
test('a name that cannot fit fails closed and keeps the cutout', async ({ page }) => {
await setCutoutStub(page, 'ok')
await page.goto('/cards?model=time-de-voce')
const dialog = page.getByRole('dialog')
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente')
await uploadBustPhoto(dialog)
await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible()
await expect(dialog.getByRole('alert')).toContainText('nome mais curto')
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
await expect(primary).toBeDisabled()
await expect(dialog.getByText('permanecem salvos neste aparelho')).toBeVisible()
// The cutout is kept and the name stays editable: fixing it re-enables the card.
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria')
await expect(dialog.getByRole('alert')).toHaveCount(0)
await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible()
await expect(primary).toBeEnabled()
})
test('mobile opens the team composer in the bottom drawer', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/cards?model=time-de-voce')
const drawer = page.locator('[data-slot="drawer-popup"]')
await expect(drawer).toBeVisible()
await expect(drawer.getByRole('heading', { name: 'Entre para o time' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(drawer).toHaveCount(0)
})
})

View file

@ -1,13 +1,22 @@
import { describe, expect, it } from 'vitest'
import { CARD_MODELS, NAME_CARD_SLOT, getCardModel, isCardModelId } from '@/lib/cardModels'
import {
CARD_MODELS,
NAME_CARD_SLOT,
TEAM_CARD_LABEL,
TEAM_CARD_NAME_BANNER,
TEAM_CARD_NAME_SLOT,
getCardModel,
isCardModelId,
} from '@/lib/cardModels'
describe('card model catalog (S13)', () => {
it('exposes the three shipped models with unique ids', () => {
describe('card model catalog (S13/S15)', () => {
it('exposes the four shipped models with unique ids', () => {
expect(CARD_MODELS.map((model) => model.id)).toEqual([
'eu-sou-solla',
'perfil-quadrado',
'perfil-retangular',
'time-de-voce',
])
expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length)
})
@ -50,6 +59,7 @@ describe('card model catalog (S13)', () => {
it('guards the name slot geometry measured from the filled master', () => {
expect(NAME_CARD_SLOT).toMatchObject({
align: 'left',
leftX: 213,
capTop: 430,
capHeight: 106,
@ -57,10 +67,58 @@ describe('card model catalog (S13)', () => {
})
})
it('pins the team model, its overlay assets and the measured photo window', () => {
expect(getCardModel('time-de-voce')).toMatchObject({
kind: 'team',
label: 'Time de você',
assetSrc: '/cards/team-card-base.png',
overlaySrc: '/cards/team-card-front.png',
previewSrc: '/cards/team-card-example.jpg',
badge: 'NOVO',
width: 1080,
height: 1440,
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
})
expect(getCardModel('eu-sou-solla')?.overlaySrc).toBeUndefined()
})
it('guards the team banners and name slot measured from the example card', () => {
expect(TEAM_CARD_LABEL).toMatchObject({
text: 'TIME DE',
centerX: 545,
centerY: 162,
width: 693,
height: 118,
rotationDeg: -3.5,
background: '#e50e2f',
fill: '#ffec01',
capHeight: 93,
})
expect(TEAM_CARD_NAME_BANNER).toMatchObject({
centerX: 545,
centerY: 313,
width: 509,
height: 176,
rotationDeg: -4.1,
background: '#0061a5',
fill: '#ffffff',
})
expect(TEAM_CARD_NAME_SLOT).toMatchObject({
align: 'center',
banner: TEAM_CARD_NAME_BANNER,
capHeight: 130,
minCapHeight: 56,
maxInkWidth: 470,
maxLines: 1,
fill: '#ffffff',
})
})
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('time-de-voce')).toBe(true)
expect(isCardModelId('modelo-inventado')).toBe(false)
expect(isCardModelId(['perfil-quadrado'])).toBe(false)
expect(isCardModelId(undefined)).toBe(false)

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { NAME_CARD_SLOT } from '@/lib/cardModels'
import { NAME_CARD_SLOT, TEAM_CARD_NAME_SLOT } from '@/lib/cardModels'
import {
fitCardName,
normalizeCardName,
@ -116,4 +116,44 @@ describe('fitCardName', () => {
expect(fit.ok).toBe(true)
if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO')
})
describe('team slot (single line, centered)', () => {
it('keeps the measured cap for a short name and never wraps', () => {
const fit = fitCardName('Ana', measure, TEAM_CARD_NAME_SLOT)
expect(fit).toMatchObject({ ok: true, lines: ['ANA'] })
if (fit.ok) {
expect(fit.capHeight).toBeCloseTo(TEAM_CARD_NAME_SLOT.capHeight, 5)
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(
TEAM_CARD_NAME_SLOT.maxInkWidth,
)
}
})
it('shrinks a name that overflows the ink width down to the readable minimum', () => {
const fit = fitCardName('Maria', measure, TEAM_CARD_NAME_SLOT)
expect(fit).toMatchObject({ ok: true, lines: ['MARIA'] })
if (fit.ok) {
expect(fit.capHeight).toBeLessThan(TEAM_CARD_NAME_SLOT.capHeight)
expect(fit.fontSize).toBeGreaterThanOrEqual(
resolveFontSizeForCapHeight(measure, TEAM_CARD_NAME_SLOT.minCapHeight),
)
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(
TEAM_CARD_NAME_SLOT.maxInkWidth,
)
}
})
it('fails with too-long instead of wrapping below the readable minimum', () => {
expect(fitCardName('Maria da Conceição', measure, TEAM_CARD_NAME_SLOT)).toEqual({
ok: false,
reason: 'too-long',
})
expect(fitCardName('Bartolomeucostajunior', measure, TEAM_CARD_NAME_SLOT)).toEqual({
ok: false,
reason: 'too-long',
})
})
})
})

View file

@ -8,8 +8,10 @@ import {
centerCardPhotoTransform,
clampCardPhotoTransform,
coverScale,
frameCardPhotoOnBbox,
panCardPhotoTransform,
zoomCardPhotoTransform,
type CardAlphaBbox,
type CardPhotoSize,
} from '@/lib/cardPhotoTransform'
@ -53,6 +55,64 @@ describe('centerCardPhotoTransform', () => {
})
})
describe('frameCardPhotoOnBbox', () => {
const TEAM_WINDOW: CardRect = { x: 286, y: 439, width: 592, height: 577 }
it('anchors the bbox top on the window top and centers it horizontally', () => {
const source: CardPhotoSize = { width: 900, height: 1200 }
const bbox: CardAlphaBbox = { x: 260, y: 140, width: 380, height: 520 }
const transform = frameCardPhotoOnBbox(source, TEAM_WINDOW, bbox)
expect(transform).not.toBeNull()
if (!transform) return
const rect = cardPhotoDrawRect(transform, source, TEAM_WINDOW)
const scale = rect.width / source.width
// bbox top lands exactly on the window top, bbox center on the window center
expect(rect.y + bbox.y * scale).toBeCloseTo(TEAM_WINDOW.y, 5)
expect(rect.x + (bbox.x + bbox.width / 2) * scale).toBeCloseTo(
TEAM_WINDOW.x + TEAM_WINDOW.width / 2,
5,
)
// the cutout itself covers the whole slot
expect(bbox.width * scale).toBeGreaterThanOrEqual(TEAM_WINDOW.width - 1e-6)
expect(bbox.height * scale).toBeGreaterThanOrEqual(TEAM_WINDOW.height - 1e-6)
expect(coversWindow(rect, TEAM_WINDOW)).toBe(true)
})
it('keeps the bottom of a full-height bbox visible when the zoom allows it', () => {
const source: CardPhotoSize = { width: 800, height: 1200 }
const bbox: CardAlphaBbox = { x: 200, y: 0, width: 400, height: 1000 }
const transform = frameCardPhotoOnBbox(source, TEAM_WINDOW, bbox)
expect(transform).not.toBeNull()
if (!transform) return
const rect = cardPhotoDrawRect(transform, source, TEAM_WINDOW)
const scale = rect.width / source.width
expect(rect.y + (bbox.y + bbox.height) * scale).toBeGreaterThanOrEqual(
TEAM_WINDOW.y + TEAM_WINDOW.height,
)
})
it('fails closed on a degenerate bbox (empty segmentation)', () => {
const source: CardPhotoSize = { width: 900, height: 1200 }
expect(
frameCardPhotoOnBbox(source, TEAM_WINDOW, { x: 0, y: 0, width: 0, height: 0 }),
).toBeNull()
expect(
frameCardPhotoOnBbox(source, TEAM_WINDOW, { x: 10, y: 10, width: 10, height: 30 }),
).toBeNull()
})
it('fails closed when the bbox is so small it would need more than the max zoom', () => {
const source: CardPhotoSize = { width: 4000, height: 4000 }
const bbox: CardAlphaBbox = { x: 100, y: 100, width: 80, height: 80 }
expect(frameCardPhotoOnBbox(source, TEAM_WINDOW, bbox)).toBeNull()
})
})
describe('clampCardPhotoTransform', () => {
it('clamps the zoom to [1, 4]', () => {
expect(

View file

@ -1,18 +1,32 @@
import { describe, expect, it } from 'vitest'
import { NAME_CARD_SLOT, getCardModel, type CardRect } from '@/lib/cardModels'
import {
NAME_CARD_SLOT,
TEAM_CARD_LABEL,
TEAM_CARD_NAME_BANNER,
TEAM_CARD_NAME_SLOT,
getCardModel,
type CardRect,
} from '@/lib/cardModels'
import { fitCardName } from '@/lib/cardNameFit'
import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform'
import {
centerCardPhotoTransform,
frameCardPhotoOnBbox,
type CardPhotoSize,
} from '@/lib/cardPhotoTransform'
import {
createCardMeasure,
drawCardName,
renderNameCard,
renderPhotoCard,
renderTeamCard,
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 }
type RectCall = { x: number; y: number; width: number; height: number }
type OpCall = { op: string; args: number[] }
const fontSizeFrom = (font: string): number => {
const match = /(\d+(?:\.\d+)?)px/.exec(font)
@ -22,6 +36,8 @@ const fontSizeFrom = (font: string): number => {
const createFakeContext = () => {
const drawCalls: DrawCall[] = []
const textCalls: TextCall[] = []
const rectCalls: RectCall[] = []
const ops: OpCall[] = []
const ctx: CardDrawContext = {
font: '',
@ -31,6 +47,9 @@ const createFakeContext = () => {
drawImage: (image, dx, dy, dw, dh) => {
drawCalls.push({ image, dx, dy, dw, dh })
},
fillRect: (x, y, width, height) => {
rectCalls.push({ x, y, width, height })
},
fillText: (text, x, y) => {
textCalls.push({ text, x, y })
},
@ -42,13 +61,26 @@ const createFakeContext = () => {
actualBoundingBoxDescent: size * 0.2,
}
},
save: () => {
ops.push({ op: 'save', args: [] })
},
restore: () => {
ops.push({ op: 'restore', args: [] })
},
translate: (x, y) => {
ops.push({ op: 'translate', args: [x, y] })
},
rotate: (angle) => {
ops.push({ op: 'rotate', args: [angle] })
},
}
return { ctx, drawCalls, textCalls }
return { ctx, drawCalls, textCalls, rectCalls, ops }
}
const nameModel = getCardModel('eu-sou-solla')!
const squareModel = getCardModel('perfil-quadrado')!
const teamModel = getCardModel('time-de-voce')!
describe('renderNameCard', () => {
it('draws the master base first and then the name on the left border', () => {
@ -133,6 +165,110 @@ describe('drawCardName', () => {
})
})
describe('renderTeamCard', () => {
const window = teamModel.photoWindow!
const photoSize: CardPhotoSize = { width: 400, height: 600 }
const transform = frameCardPhotoOnBbox(photoSize, window, {
x: 80,
y: 40,
width: 240,
height: 420,
})!
const render = (name: string) => {
const fake = createFakeContext()
const base = { id: 'base' } as unknown as CanvasImageSource
const overlay = { id: 'overlay' } as unknown as CanvasImageSource
const photo = { id: 'photo' } as unknown as CanvasImageSource
const result = renderTeamCard(fake.ctx, teamModel, {
base,
overlay,
photo,
photoSize,
transform,
window,
name,
fontFamily: 'Brexter',
measure: createCardMeasure(fake.ctx, 'Brexter'),
})
return { ...fake, result, base, overlay, photo }
}
it('draws base → framed photo → overlay, then the label and the name banner', () => {
const { drawCalls, textCalls, rectCalls, ops, result, base, overlay, photo } = render('Maria')
expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] })
expect(drawCalls.map((call) => call.image)).toEqual([base, photo, overlay])
expect(drawCalls[0]).toEqual({ image: base, dx: 0, dy: 0, dw: 1080, dh: 1440 })
expect(drawCalls[2]).toEqual({ image: overlay, dx: 0, dy: 0, dw: 1080, dh: 1440 })
// Both banners are drawn as rotated rectangles: two save/restore pairs.
expect(rectCalls).toHaveLength(2)
expect(rectCalls[0]).toMatchObject({
width: TEAM_CARD_LABEL.width,
height: TEAM_CARD_LABEL.height,
})
expect(rectCalls[1]).toMatchObject({
width: TEAM_CARD_NAME_BANNER.width,
height: TEAM_CARD_NAME_BANNER.height,
})
expect(ops.filter((op) => op.op === 'save')).toHaveLength(2)
expect(ops.filter((op) => op.op === 'restore')).toHaveLength(2)
expect(ops.map((op) => op.op)).toEqual([
'save',
'translate',
'rotate',
'restore',
'save',
'translate',
'rotate',
'restore',
])
expect(ops[1]!.args).toEqual([TEAM_CARD_LABEL.centerX, TEAM_CARD_LABEL.centerY])
expect(ops[5]!.args).toEqual([TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY])
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE', 'MARIA'])
expect(textCalls.every((call) => call.x === 0)).toBe(true)
})
it('draws the label but no name banner when the name cannot fit (never cuts silently)', () => {
const { textCalls, rectCalls, result } = render('Bartolomeucostajunior')
expect(result.fit).toEqual({ ok: false, reason: 'too-long' })
expect(rectCalls).toHaveLength(1)
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE'])
})
})
describe('drawCardName (centered team slot)', () => {
it('draws the blue banner with the centered name through the same banner helper', () => {
const { ctx, textCalls, rectCalls, ops } = createFakeContext()
const fit = fitCardName('Maria', createCardMeasure(ctx, 'Brexter'), TEAM_CARD_NAME_SLOT)
expect(fit.ok).toBe(true)
if (!fit.ok) return
drawCardName(ctx, { fit, fontFamily: 'Brexter', slot: TEAM_CARD_NAME_SLOT })
expect(rectCalls).toEqual([
{
x: -TEAM_CARD_NAME_BANNER.width / 2,
y: -TEAM_CARD_NAME_BANNER.height / 2,
width: TEAM_CARD_NAME_BANNER.width,
height: TEAM_CARD_NAME_BANNER.height,
},
])
expect(ops).toEqual([
{ op: 'save', args: [] },
{ op: 'translate', args: [TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY] },
{ op: 'rotate', args: [(TEAM_CARD_NAME_BANNER.rotationDeg * Math.PI) / 180] },
{ op: 'restore', args: [] },
])
expect(textCalls).toEqual([{ text: 'MARIA', x: 0, y: fit.capHeight / 2 }])
expect(ctx.fillStyle).toBe('#ffffff')
})
})
describe('renderPhotoCard', () => {
it('draws the photo full-bleed under the official overlay', () => {
const { ctx, drawCalls } = createFakeContext()