refactor(S13): endurece o compositor após review

- font: espera a família primária (check falhava pela fallback do next/font)
- load de assets/fonte separado do redraw (sem flicker/live-region por tecla)
- pan/zoom com updates funcionais; photoSize memoizado
- foco volta ao tile do catálogo ao fechar (trigger da home desmonta)
- copy de privacidade centralizada; tile deriva dims/cor do modelo
- aria do input de arquivo sem colidir com o botão; +2 unit tests
This commit is contained in:
Francisco Solla 2026-09-12 02:31:49 -03:00
parent 5b4f91ca3c
commit 2ad36d09cf
14 changed files with 173 additions and 93 deletions

View file

@ -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=<id>`).
- **`src/app/(frontend)/(home)/page.tsx`** (editar): `<CampaignCardsSection />` logo após `<CampaignNewsletterSection … />`, dentro do `<main>`, antes de `</main>`/`CampaignFooter`.
- **`src/app/(frontend)/fonts.ts`** + **`src/app/(frontend)/fonts/Brexter-Bold.ttf`** (novos): `export const brexterBold = localFont({ src: './fonts/Brexter-Bold.ttf', weight: '700', style: 'normal', display: 'swap' })`; só a família (string) cruza para os componentes.
- **`src/lib/cardModels.ts`** (novo, puro): `CardModelId`, `CardModel`, `CARD_MODELS`, `isCardModelId`, `NAME_SLOT` (centerX 570 / capHeight 106 / maxInkWidth 714 / bandTop 430 / bandBottom 536 / ink `#ffec01`), `PHOTO_WINDOWS` (quadrada 1000×1000 janela y[0..740]; vertical 1000×1440 janela y[0..1044]), `OUTPUT` dims e paths ASCII. Fonte única do catálogo (home + `/cards` + render).
- **`src/lib/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 → `<a download>`** (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.

View file

@ -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
</h1>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">{intro}</p>
<p className="mt-2 text-xs text-(--campaign-muted)">{privacyNote}</p>
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />

View file

@ -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
}

View file

@ -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<CardPhotoTransform | null>(null)
const [photoError, setPhotoError] = useState<string | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(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<CardPhotoSize | null>(
() => (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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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.
</p>
) : null}
<p className="mt-2 text-xs text-(--campaign-muted)">{PRIVACY_NOTE}</p>
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
) : 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
</button>
</div>
<p className="mt-3 text-xs text-(--campaign-muted)">{PHOTO_PRIVACY_NOTE}</p>
<p className="mt-3 text-xs text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'result' ? (
<div className="mt-5">
<p className="text-sm text-(--campaign-muted)">{PRIVACY_NOTE}</p>
<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}

View file

@ -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 = ({
<Link
href={cardModelHref(model.id)}
aria-label={`${model.label} — criar meu card`}
data-card-model-tile={model.id}
className={tileClassName}
>
{content}

View file

@ -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' ? (
<span
aria-hidden="true"
className="absolute -translate-x-1/2 font-[family-name:var(--font-exo2)] leading-none font-black tracking-[-0.02em] text-[#ffec01]"
style={{ left: NAME_OVERLAY_LEFT, top: NAME_OVERLAY_TOP, fontSize: '11.5cqw' }}
className="absolute -translate-x-1/2 font-[family-name:var(--font-exo2)] leading-none font-black tracking-[-0.02em]"
style={{
left: `${(NAME_CARD_SLOT.centerX / model.width) * 100}%`,
top: `${(NAME_CARD_SLOT.capTop / model.height) * 100}%`,
fontSize: '11.5cqw',
color: NAME_CARD_SLOT.fill,
}}
>
SEU NOME
</span>

View file

@ -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<HTMLElement>(`[data-card-model-tile="${selectedId}"]`)
const visibleTile = Array.from(tiles).find((tile) => tile.getClientRects().length > 0)
visibleTile?.focus()
}
return (
<div>
<CardModelGallery
@ -62,6 +72,10 @@ export const CardsStudio = ({
<DialogContent
data-theme="campaign-site"
showCloseButton={false}
onCloseAutoFocus={(event) => {
event.preventDefault()
focusSelectedTile()
}}
className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:max-w-lg"
>
<CardComposer

View file

@ -42,13 +42,20 @@ export const loadCardPhoto = (file: File): Promise<HTMLImageElement> =>
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<boolean> => {
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
}

View file

@ -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.'

View file

@ -11,7 +11,7 @@
import { NAME_CARD_SLOT } from './cardModels'
type CardTextMetrics = {
export type CardTextMetrics = {
width: number
actualBoundingBoxAscent: number
actualBoundingBoxDescent: number

View file

@ -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 = (

View file

@ -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 ({

View file

@ -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)

View file

@ -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)
})
})