diff --git a/docs/plans/cards-personalizados-campanha-impl.md b/docs/plans/cards-personalizados-campanha-impl.md
index 7c487727..fd258dde 100644
--- a/docs/plans/cards-personalizados-campanha-impl.md
+++ b/docs/plans/cards-personalizados-campanha-impl.md
@@ -101,7 +101,7 @@ flowchart LR
- **`src/app/(frontend)/(home)/CampaignCardsSection.tsx`** (novo): seção server da home no molde de `CampaignNewsletterSection.tsx` — `id="cards"`, `aria-labelledby`, `data-home-section="cards"`, `mx-auto max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16`, eyebrow/título/corpo com `.campaign-section-*`, CTA `--pt-red`; usa `CardModelGallery variant="link"` (tiles levam a `/cards?model=`).
- **`src/app/(frontend)/(home)/page.tsx`** (editar): `` logo após ``, dentro do ``, antes de ``/`CampaignFooter`.
- **`src/app/(frontend)/fonts.ts`** + **`src/app/(frontend)/fonts/Brexter-Bold.ttf`** (novos): `export const brexterBold = localFont({ src: './fonts/Brexter-Bold.ttf', weight: '700', style: 'normal', display: 'swap' })`; só a família (string) cruza para os componentes.
-- **`src/lib/cardModels.ts`** (novo, puro): `CardModelId`, `CardModel`, `CARD_MODELS`, `isCardModelId`, `NAME_SLOT` (centerX 570 / capHeight 106 / maxInkWidth 714 / bandTop 430 / bandBottom 536 / ink `#ffec01`), `PHOTO_WINDOWS` (quadrada 1000×1000 janela y[0..740]; vertical 1000×1440 janela y[0..1044]), `OUTPUT` dims e paths ASCII. Fonte única do catálogo (home + `/cards` + render).
+- **`src/lib/cardModels.ts`** (novo, puro): `CardModelId`, `CardModel`, `CARD_MODELS`, `isCardModelId`, `NAME_CARD_SLOT` (centerX 570 / capTop 430 / capHeight 106 / maxInkWidth 714 / maxBlockBottom 630 para 2 linhas / minCapHeight 48 / ink `#ffec01`), janelas de foto em `CARD_MODELS[i].photoWindow` (quadrada 1000×1000 janela y[0..740]; vertical 1000×1440 janela y[0..1044]), dims de saída nos próprios modelos e paths ASCII. Fonte única do catálogo (home + `/cards` + render).
- **`src/lib/cardNameFit.ts`** (novo, puro): `fitCardName(name, measure, slot)` + `resolveFontSizeForCapHeight`; retorno `{ ok: true; lines; fontSize; lineHeight } | { ok: false; reason: 'too-long' }`; `MIN_CAP_HEIGHT` fill-in (~48px, validado no craft); nunca trunca.
- **`src/lib/cardPhotoTransform.ts`** (novo, puro): cover scale na janela, `clampPhotoTransform`, `photoDrawRect`; zoom 1–4; pan clampado para nunca abrir gap na janela (inclui a contra-forma transparente dentro dos limites da janela).
- **`src/lib/cardRender.ts`** (novo, puro): interfaces estruturais mínimas `CardRenderContext`/`CardTextMetrics` (o `CanvasRenderingContext2D` real as satisfaz); `renderNameCard` (base → nome) e `renderPhotoCard` (foto → máscara, nessa ordem); sem dependência de DOM/Next.
@@ -155,7 +155,6 @@ Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “f
4. **Craft/Critique/Polish + e2e + gates** — quota ~0,5 dia. Impeccable C; e2e novo + prewarm + manifest; changelog; `pnpm gate:fast`; e2e local do describe (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Cards personalizados"`); `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
-
- Não criar collection/global/migration/CMS para modelos (o catálogo é código).
- Não criar um segundo compositor na home: o tile da home é link e só `/cards` edita.
- Não recriar faixas/contra-formas das artes em CSS/SVG; overlay é o PNG mestre.
@@ -167,7 +166,7 @@ Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “f
## Riscos e mitigação
-- **Fonte não pronta no draw** (medida com fallback desalinha o fit): só desenhar após `document.fonts.load` e `document.fonts.check` na família primária; estado de carregamento no preview.
+- **Fonte não pronta no draw** (medida com fallback desalinha o fit): só desenhar após `document.fonts.load` da família primária (`check` falha pela fallback gerada do next/font); estado de carregamento no preview.
- **Canvas tainted/cross-origin**: assets same-origin em `public/cards/`; nunca URL externa.
- **Foto HEIC/grande/corrompida**: decode em try/catch com erro recuperável (`Escolher outra foto`); formatos suportados pelo browser seguem funcionando; limite defensivo de tamanho sem bloquear caso comum.
- **Gap no pan/zoom**: clamp puro testado; a foto é desenhada antes do overlay, então nem uma borda vazaria.
@@ -177,6 +176,13 @@ Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “f
- **Cold compile do `/cards` no e2e dev**: entrada no prewarm do `setup.e2e.spec.ts`.
- **Formato TTF no `next/font/local`**: se o build reclamar, converter para WOFF2 na mesma fase (família/permissão inalteradas).
+## Débitos diferidos (triage pós-review, 2026-09-12)
+
+- **Extrair `PhotoControls`/`NameField` do `CardComposer`** (cheap_polish, score 2) — gatilho: próximo toque funcional no composer (novo passo/controle) ou regressão de interação no bloco de foto.
+- **Helper único do idioma download Blob → object URL → ``** (2 call sites hoje) — gatilho: 3º call site.
+- **Cobertura de pan por teclado no e2e e dos re-checks do fit de duas linhas no unit** — gatilho: próximo toque em `cardNameFit`/`CardComposer` ou regressão de teclado/duas linhas em UAT.
+- Descartados na triagem: união discriminada do `CardModelGallery` e inline do `CardPreviewCanvas` (polish sem gatilho; invariantes já garantidas pelos call sites atuais).
+
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: seção na home após a newsletter; 3 modelos (grid/carrossel com peek); CTA e tiles levam a `/cards`; composer modal desktop/bottom drawer mobile; nome só na banda do mestre, sem corte silencioso; foto com pan/zoom sobre a máscara; aviso de privacidade; resultado + download `PNG`; erro recuperável; toque e teclado.
diff --git a/src/app/(frontend)/(home)/cards/page.tsx b/src/app/(frontend)/(home)/cards/page.tsx
index 8a83b6b6..c0e16153 100644
--- a/src/app/(frontend)/(home)/cards/page.tsx
+++ b/src/app/(frontend)/(home)/cards/page.tsx
@@ -3,13 +3,12 @@ import Link from 'next/link'
import { brexterBold } from '@/app/(frontend)/fonts'
import { CampaignFooter } from '@/components/CampaignFooter'
+import { CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import { CardsStudio } from '@/components/cards/CardsStudio'
import { isCardModelId } from '@/lib/cardModels'
const intro =
'Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
-const privacyNote =
- 'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
export const metadata: Metadata = {
title: 'Crie seu card de apoio | Jorge Solla',
@@ -52,7 +51,7 @@ export default async function CardsPage({ searchParams }: CardsPageProps) {
Crie seu card de apoio
{intro}
- {privacyNote}
+ {CARD_PRIVACY_NOTE}
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index 097d2c4e..ed8c41ef 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -36,7 +36,6 @@ import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
-import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
@@ -79,7 +78,6 @@ export const importMap = {
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
"./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
- "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx
index 0442cd8b..150682c0 100644
--- a/src/components/cards/CardComposer.tsx
+++ b/src/components/cards/CardComposer.tsx
@@ -8,7 +8,7 @@ import {
MinusIcon,
PlusIcon,
} from 'lucide-react'
-import { useEffect, useId, useRef, useState, type PointerEvent } from 'react'
+import { useEffect, useId, useMemo, useRef, useState, type PointerEvent } from 'react'
import {
canvasToPngBlob,
@@ -18,6 +18,7 @@ import {
loadCardImage,
loadCardPhoto,
} from '@/components/cards/cardCanvas'
+import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
@@ -29,14 +30,11 @@ import {
centerCardPhotoTransform,
panCardPhotoTransform,
zoomCardPhotoTransform,
+ type CardPhotoSize,
type CardPhotoTransform,
} from '@/lib/cardPhotoTransform'
import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender'
-const PRIVACY_NOTE =
- 'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
-const PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
-
const primaryButtonClassName =
'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-(--pt-red) px-5 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
const secondaryButtonClassName =
@@ -59,6 +57,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const [photoTransform, setPhotoTransform] = useState(null)
const [photoError, setPhotoError] = useState(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
+ const [baseImage, setBaseImage] = useState(null)
const [downloadError, setDownloadError] = useState(null)
const [isDownloading, setIsDownloading] = useState(false)
@@ -69,65 +68,80 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const zoomInputId = useId()
const photoWindow = model.photoWindow
- const photoSize = photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null
+ const photoSize = useMemo(
+ () => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null),
+ [photo],
+ )
+ // 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.
useEffect(() => {
let cancelled = false
+ setLoadState('loading')
+ setBaseImage(null)
- const draw = async () => {
- const ctx = canvasRef.current?.getContext('2d')
- if (!ctx) return
-
- setLoadState('loading')
+ const load = async () => {
try {
- await ensureCardFont(fontFamily)
- const base = await loadCardImage(model.assetSrc)
+ const fontReady = await ensureCardFont(fontFamily)
+ if (!fontReady) throw new Error('card-font-unavailable')
+ const image = await loadCardImage(model.assetSrc)
if (cancelled) return
-
- ctx.clearRect(0, 0, model.width, model.height)
-
- if (model.kind === 'name') {
- const fit = renderNameCard(ctx, model, {
- image: base,
- name,
- fontFamily,
- measure: createCardMeasure(ctx, fontFamily),
- })
- if (!cancelled) setNameFit(fit)
- } else if (photo && photoTransform && photoWindow) {
- const clamped = renderPhotoCard(ctx, {
- photo,
- frame: base,
- photoSize: { width: photo.naturalWidth, height: photo.naturalHeight },
- frameSize: { width: model.width, height: model.height },
- window: photoWindow,
- transform: photoTransform,
- })
- if (
- !cancelled &&
- (clamped.zoom !== photoTransform.zoom ||
- clamped.offsetX !== photoTransform.offsetX ||
- clamped.offsetY !== photoTransform.offsetY)
- ) {
- setPhotoTransform(clamped)
- }
- } else if (photoWindow) {
- ctx.fillStyle = '#ded8d2'
- ctx.fillRect(0, 0, model.width, model.height)
- ctx.drawImage(base, 0, 0, model.width, model.height)
- }
-
- if (!cancelled) setLoadState('ready')
+ setBaseImage(image)
+ setLoadState('ready')
} catch {
if (!cancelled) setLoadState('error')
}
}
- void draw()
+ void load()
return () => {
cancelled = true
}
- }, [model, name, photo, photoTransform, photoWindow, fontFamily])
+ }, [model.assetSrc, fontFamily])
+
+ useEffect(() => {
+ const ctx = canvasRef.current?.getContext('2d')
+ if (!ctx || !baseImage || loadState !== 'ready') return
+
+ ctx.clearRect(0, 0, model.width, model.height)
+
+ if (model.kind === 'name') {
+ setNameFit(
+ renderNameCard(ctx, model, {
+ image: baseImage,
+ name,
+ fontFamily,
+ measure: createCardMeasure(ctx, fontFamily),
+ }),
+ )
+ return
+ }
+
+ if (photo && photoTransform && photoWindow && photoSize) {
+ const clamped = renderPhotoCard(ctx, {
+ photo,
+ frame: baseImage,
+ photoSize,
+ frameSize: { width: model.width, height: model.height },
+ window: photoWindow,
+ transform: photoTransform,
+ })
+ if (
+ clamped.zoom !== photoTransform.zoom ||
+ clamped.offsetX !== photoTransform.offsetX ||
+ clamped.offsetY !== photoTransform.offsetY
+ ) {
+ setPhotoTransform(clamped)
+ }
+ return
+ }
+
+ if (photoWindow) {
+ ctx.fillStyle = '#ded8d2'
+ 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])
const handlePhotoFile = async (file: File | undefined) => {
if (!file || !photoWindow) return
@@ -152,7 +166,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
const handlePointerDown = (event: PointerEvent) => {
- if (!photoSize || !photoTransform || !photoWindow) return
+ if (!photoSize || !photoWindow) return
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
event.currentTarget.setPointerCapture(event.pointerId)
}
@@ -160,21 +174,15 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const handlePointerMove = (event: PointerEvent) => {
const drag = dragRef.current
const canvas = canvasRef.current
- if (
- !drag ||
- drag.pointerId !== event.pointerId ||
- !photoSize ||
- !photoTransform ||
- !photoWindow
- )
- return
- if (!canvas) return
+ if (!drag || drag.pointerId !== event.pointerId || !photoSize || !photoWindow || !canvas) return
const scale = model.width / Math.max(1, canvas.getBoundingClientRect().width)
const dx = (event.clientX - drag.x) * scale
const dy = (event.clientY - drag.y) * scale
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
- setPhotoTransform(panCardPhotoTransform(photoTransform, photoSize, photoWindow, dx, dy))
+ setPhotoTransform((current) =>
+ current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
+ )
}
const handlePointerEnd = () => {
@@ -182,13 +190,17 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
}
const panBy = (dx: number, dy: number) => {
- if (!photoSize || !photoTransform || !photoWindow) return
- setPhotoTransform(panCardPhotoTransform(photoTransform, photoSize, photoWindow, dx, dy))
+ if (!photoSize || !photoWindow) return
+ setPhotoTransform((current) =>
+ current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
+ )
}
const zoomTo = (zoom: number) => {
- if (!photoSize || !photoTransform || !photoWindow) return
- setPhotoTransform(zoomCardPhotoTransform(photoTransform, photoSize, photoWindow, zoom))
+ if (!photoSize || !photoWindow) return
+ setPhotoTransform((current) =>
+ current ? zoomCardPhotoTransform(current, photoSize, photoWindow, zoom) : current,
+ )
}
const handleDownload = async () => {
@@ -323,7 +335,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
chamado.
) : null}
- {PRIVACY_NOTE}
+ {CARD_PRIVACY_NOTE}
) : null}
@@ -411,6 +423,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
ref={fileInputRef}
type="file"
accept="image/*"
+ tabIndex={-1}
+ aria-hidden="true"
className="sr-only"
onChange={(event) => void handlePhotoFile(event.target.files?.[0])}
/>
@@ -434,13 +448,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
- {PHOTO_PRIVACY_NOTE}
+ {CARD_PHOTO_PRIVACY_NOTE}
) : null}
{step === 'result' ? (
-
{PRIVACY_NOTE}
+
{CARD_PRIVACY_NOTE}
{downloadError ? (
{downloadError}
diff --git a/src/components/cards/CardModelGallery.tsx b/src/components/cards/CardModelGallery.tsx
index cd7c8da3..85e6b4c8 100644
--- a/src/components/cards/CardModelGallery.tsx
+++ b/src/components/cards/CardModelGallery.tsx
@@ -41,6 +41,7 @@ export const CardModelGallery = ({
type="button"
aria-pressed={selected}
aria-label={`${model.label}${selected ? ' (selecionado)' : ''}`}
+ data-card-model-tile={model.id}
onClick={() => onSelect?.(model.id)}
className={tileClassName}
>
@@ -53,6 +54,7 @@ export const CardModelGallery = ({
{content}
diff --git a/src/components/cards/CardModelTile.tsx b/src/components/cards/CardModelTile.tsx
index d3577673..8db7fa86 100644
--- a/src/components/cards/CardModelTile.tsx
+++ b/src/components/cards/CardModelTile.tsx
@@ -4,9 +4,6 @@ import Image from 'next/image'
import { NAME_CARD_SLOT, type CardModel } from '@/lib/cardModels'
import { cn } from '@/lib/utils'
-const NAME_OVERLAY_LEFT = `${(NAME_CARD_SLOT.centerX / 1080) * 100}%`
-const NAME_OVERLAY_TOP = `${(NAME_CARD_SLOT.capTop / 1440) * 100}%`
-
/**
* S13 — artwork preview of one card model, shared by the home section and the
* `/cards` gallery. The name tile reuses the empty master with an HTML
@@ -63,8 +60,13 @@ export const CardModelTile = ({
{model.kind === 'name' ? (
SEU NOME
diff --git a/src/components/cards/CardsStudio.tsx b/src/components/cards/CardsStudio.tsx
index f1ffe50b..845be10d 100644
--- a/src/components/cards/CardsStudio.tsx
+++ b/src/components/cards/CardsStudio.tsx
@@ -33,6 +33,16 @@ export const CardsStudio = ({
setOpen(true)
}
+ // Radix restores focus to the element that opened the dialog, but the home
+ // tile that deep-linked here is unmounted after navigation. Send focus back
+ // to the catalog tile of the selected model instead (visible list only).
+ const focusSelectedTile = () => {
+ if (!selectedId) return
+ const tiles = document.querySelectorAll(`[data-card-model-tile="${selectedId}"]`)
+ const visibleTile = Array.from(tiles).find((tile) => tile.getClientRects().length > 0)
+ visibleTile?.focus()
+ }
+
return (
{
+ event.preventDefault()
+ focusSelectedTile()
+ }}
className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:max-w-lg"
>
=>
image.src = url
})
-/** Canvas does not wait for CSS fonts: the family must be loaded before measuring. */
+/**
+ * Canvas does not wait for CSS fonts: the family must be loaded before
+ * measuring. `next/font` hands a family list (primary + generated fallback),
+ * and `document.fonts.check` fails closed on the unloaded fallback — so only
+ * the primary family is awaited here.
+ */
export const ensureCardFont = async (fontFamily: string): Promise => {
if (typeof document === 'undefined' || !document.fonts) return false
+ const primaryFamily = fontFamily.split(',')[0]?.trim() || fontFamily
+
try {
- await document.fonts.load(`700 100px ${fontFamily}`)
- return document.fonts.check(`700 100px ${fontFamily}`)
+ const faces = await document.fonts.load(`700 100px ${primaryFamily}`)
+ return faces.length > 0
} catch {
return false
}
diff --git a/src/components/cards/cardCopy.ts b/src/components/cards/cardCopy.ts
new file mode 100644
index 00000000..7936f145
--- /dev/null
+++ b/src/components/cards/cardCopy.ts
@@ -0,0 +1,8 @@
+/**
+ * S13 — shared card-studio copy. The privacy notes are literals of the intent
+ * and must stay identical between the `/cards` page and the composer.
+ */
+
+export const CARD_PRIVACY_NOTE =
+ 'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
+export const CARD_PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
diff --git a/src/lib/cardNameFit.ts b/src/lib/cardNameFit.ts
index f14d172b..e03d42e6 100644
--- a/src/lib/cardNameFit.ts
+++ b/src/lib/cardNameFit.ts
@@ -11,7 +11,7 @@
import { NAME_CARD_SLOT } from './cardModels'
-type CardTextMetrics = {
+export type CardTextMetrics = {
width: number
actualBoundingBoxAscent: number
actualBoundingBoxDescent: number
diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts
index b4aac640..68b3dfd5 100644
--- a/src/lib/cardRender.ts
+++ b/src/lib/cardRender.ts
@@ -13,6 +13,7 @@ import {
type CardMeasureText,
type CardNameFit,
type CardNameSlot,
+ type CardTextMetrics,
} from './cardNameFit'
import {
cardPhotoDrawRect,
@@ -28,13 +29,7 @@ export type CardDrawContext = {
textBaseline: CanvasTextBaseline
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void
fillText(text: string, x: number, y: number): void
- measureText(text: string): CardTextMetricsLike
-}
-
-type CardTextMetricsLike = {
- width: number
- actualBoundingBoxAscent: number
- actualBoundingBoxDescent: number
+ measureText(text: string): CardTextMetrics
}
export const createCardMeasure = (
diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts
index 30ad291f..4b409e02 100644
--- a/tests/e2e/frontend.e2e.spec.ts
+++ b/tests/e2e/frontend.e2e.spec.ts
@@ -1675,12 +1675,16 @@ test.describe('Cards personalizados (S13)', () => {
await expect(section.locator(`a[href="/cards?model=${model}"]`).first()).toBeAttached()
}
- await section.getByRole('link', { name: /Moldura quadrada/ }).click()
+ const trigger = section.getByRole('link', { name: /Moldura quadrada/ })
+ await trigger.click()
await expect(page).toHaveURL(/\/cards\?model=perfil-quadrado$/)
const dialog = page.getByRole('dialog')
await expect(dialog.getByRole('heading', { name: 'Enquadre sua foto' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
+ await expect(
+ page.locator('[data-card-model-tile="perfil-quadrado"]:visible').first(),
+ ).toBeFocused()
})
test('name card fits the master, reaches the result and downloads a real PNG', async ({
diff --git a/tests/unit/cardNameFit.unit.spec.ts b/tests/unit/cardNameFit.unit.spec.ts
index 05b192ee..214707bb 100644
--- a/tests/unit/cardNameFit.unit.spec.ts
+++ b/tests/unit/cardNameFit.unit.spec.ts
@@ -86,6 +86,30 @@ describe('fitCardName', () => {
expect(fitCardName(' ', measure)).toEqual({ ok: false, reason: 'empty' })
})
+ it('fails closed when shrinking cannot reach the ink width (adversarial metrics)', () => {
+ const rigid: CardMeasureText = (_text, fontSize) => ({
+ width: 5000,
+ actualBoundingBoxAscent: fontSize * 0.72,
+ actualBoundingBoxDescent: fontSize * 0.2,
+ })
+ const rigidCase = fitCardName('João', rigid)
+
+ expect(rigidCase.ok).toBe(false)
+ if (!rigidCase.ok) expect(rigidCase.reason).toBe('too-long')
+ })
+
+ it('fails closed when the shrink factor collapses to zero', () => {
+ const absurd: CardMeasureText = (_text, fontSize) => ({
+ width: 1_000_000_000,
+ actualBoundingBoxAscent: fontSize * 0.72,
+ actualBoundingBoxDescent: fontSize * 0.2,
+ })
+ const absurdCase = fitCardName('João', absurd)
+
+ expect(absurdCase.ok).toBe(false)
+ if (!absurdCase.ok) expect(absurdCase.reason).toBe('too-long')
+ })
+
it('preserves Portuguese accents in the fitted lines', () => {
const fit = fitCardName('Coração', measure)
diff --git a/tests/unit/cardPhotoTransform.unit.spec.ts b/tests/unit/cardPhotoTransform.unit.spec.ts
index 3d864253..c29d50cc 100644
--- a/tests/unit/cardPhotoTransform.unit.spec.ts
+++ b/tests/unit/cardPhotoTransform.unit.spec.ts
@@ -115,4 +115,11 @@ describe('zoomCardPhotoTransform', () => {
expect(zoomed.zoom).toBe(CARD_PHOTO_MAX_ZOOM)
expect(coversWindow(cardPhotoDrawRect(zoomed, PORTRAIT, WINDOW), WINDOW)).toBe(true)
})
+
+ it('returns the clamped transform untouched when the zoom does not change', () => {
+ const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
+ const same = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, start.zoom)
+
+ expect(same).toEqual(start)
+ })
})