mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
5b4f91ca3c
commit
2ad36d09cf
14 changed files with 173 additions and 93 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
8
src/components/cards/cardCopy.ts
Normal file
8
src/components/cards/cardCopy.ts
Normal 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.'
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
import { NAME_CARD_SLOT } from './cardModels'
|
||||
|
||||
type CardTextMetrics = {
|
||||
export type CardTextMetrics = {
|
||||
width: number
|
||||
actualBoundingBoxAscent: number
|
||||
actualBoundingBoxDescent: number
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue