From 2ad36d09cf81d72fc5926204e77f129c344725de Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 12 Sep 2026 02:31:49 -0300 Subject: [PATCH] =?UTF-8?q?refactor(S13):=20endurece=20o=20compositor=20ap?= =?UTF-8?q?=C3=B3s=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../cards-personalizados-campanha-impl.md | 12 +- src/app/(frontend)/(home)/cards/page.tsx | 5 +- src/app/(payload)/admin/importMap.js | 2 - src/components/cards/CardComposer.tsx | 150 ++++++++++-------- src/components/cards/CardModelGallery.tsx | 2 + src/components/cards/CardModelTile.tsx | 12 +- src/components/cards/CardsStudio.tsx | 14 ++ src/components/cards/cardCanvas.ts | 13 +- src/components/cards/cardCopy.ts | 8 + src/lib/cardNameFit.ts | 2 +- src/lib/cardRender.ts | 9 +- tests/e2e/frontend.e2e.spec.ts | 6 +- tests/unit/cardNameFit.unit.spec.ts | 24 +++ tests/unit/cardPhotoTransform.unit.spec.ts | 7 + 14 files changed, 173 insertions(+), 93 deletions(-) create mode 100644 src/components/cards/cardCopy.ts 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' ? ( 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) + }) })