From e652f0ae459f6c087999024c51510b14844da717 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 19 Sep 2026 16:13:49 -0300 Subject: [PATCH] feat(S20): ajuste lateral da foto do card time sem limite artificial (Closes #1216) --- .../2026-09-19-s20-card-ajuste-lateral.md | 1 + .../cards-time-de-voce-ajuste-lateral-impl.md | 172 ++++++++++++++++ src/components/cards/CardComposer.tsx | 22 +- src/components/cards/useCardCutout.ts | 12 +- src/lib/cardPhotoTransform.ts | 107 ++++++++-- src/lib/cardRender.ts | 22 +- tests/unit/cardPhotoTransform.unit.spec.ts | 193 +++++++++++++++++- tests/unit/cardRender.unit.spec.ts | 32 +++ 8 files changed, 524 insertions(+), 37 deletions(-) create mode 100644 docs/changelog/2026-09-19-s20-card-ajuste-lateral.md create mode 100644 docs/plans/cards-time-de-voce-ajuste-lateral-impl.md diff --git a/docs/changelog/2026-09-19-s20-card-ajuste-lateral.md b/docs/changelog/2026-09-19-s20-card-ajuste-lateral.md new file mode 100644 index 00000000..5dc4f0fc --- /dev/null +++ b/docs/changelog/2026-09-19-s20-card-ajuste-lateral.md @@ -0,0 +1 @@ +**S20 — Card "Time de você": ajuste lateral da foto sem limite artificial (2026-09-19):** o visitante empurrava a própria foto e ela parava no meio do caminho (curso lateral de 248px de card, ≈±124px do centro, numa foto 3:4) porque o clamp do S13/S18 prendia o canvas inteiro do recorte à janela da arte — o padding transparente consumia o gesto. A math pura `clampCardPhotoTransform` ganha o contexto opcional `CardPhotoClamp = { minZoom?, anchorBox? }` (omitido = S13/S18 byte a byte, os 3 modelos e o `renderPhotoCard` intocados): no eixo X o range vira a **união** do alcance de hoje com o da âncora — o intervalo em que a caixa-âncora toca as duas bordas da janela `{286,439,592,577}` — e o eixo Y permanece o de hoje (ajuste lateral, sem surpresa vertical). A âncora é `resolveCardPhotoAnchor(bbox, face)` (regra `face usável ? face : silhueta`, fonte única reusada pelo enquadramento S18 via `isUsableCardFace`, agora exigindo `x/y/width/height` finitos); caixa mais larga que a janela ou fora da foto cai no clamp de hoje (nunca pior, sem gap). O `CardCutoutState.ready` passa a guardar `bbox`/`face` (antes descartados), o composer memoiza a âncora e leva o mesmo contexto ao arrasto, setas, teclado e zoom, e `TeamCardRenderArgs.anchorBox` mantém desenho e write-back idempotentes — sem snap-back; o PNG continua saindo do canvas já pintado. Por construção o rosto desenhado na referência 103 dá curso `592 − 103 = 489px` (≈±244,5 do centro inicial, ~2× o baseline) e o fallback sem rosto herda o piso pela união. Sem controle/copy/migration/Consent, 100% no aparelho; detector, harmonização, banners, troca de foto e demais cards intocados. Testes: unit do clamp com âncora (curso derivado, grid zoom×âncora×offset "nunca pior", range disjunto fora da fonte, idempotência, Y intocado) + `renderTeamCard` com/sem âncora; e2e `frontend` 32/32 sem edição (S14/S15/S17/S18 pinam o inicial e o write-back). Craft no browser com stub determinístico (foto 3:4): 21 passos à direita + 41 à esquerda até as bordas (~489px), sem snap-back e sem erro de console. Verificação: `pnpm gate:fast` verde (4182 unit), e2e da superfície verde, knip/cycles verdes. diff --git a/docs/plans/cards-time-de-voce-ajuste-lateral-impl.md b/docs/plans/cards-time-de-voce-ajuste-lateral-impl.md new file mode 100644 index 00000000..f37d4b13 --- /dev/null +++ b/docs/plans/cards-time-de-voce-ajuste-lateral-impl.md @@ -0,0 +1,172 @@ +# Impl: Card "Time de você": ajuste lateral da foto sem limite artificial + +Status: aprovado +Atualizado em: 2026-09-19 +Issue: #1216 +Intenção: docs/plans/cards-time-de-voce-ajuste-lateral.md +Appetite restante: herdado — ~0,5–1 dia eng; sem migration; 3 fases somam ≤1 dia (tracer puro ~0,4 + threading ~0,3–0,4 + gates ~0,2) + +## Leitura da intenção + +- **Outcome:** o visitante empurra a própria foto lateralmente até o rosto medido (caixa do detector) tocar as bordas da área livre da arte (janela `{x:286,y:439,w:592,h:577}`); por construção o curso X vira `592 − largura do rosto desenhado` (≈489px com a referência 103 → ≈±244px a partir do centro inicial, ~2× os ~248px de hoje). Sem controle novo; sem rosto medido a âncora é a silhueta e o alcance nunca fica pior que o de hoje; enquadramento automático S18, banners, harmonização, os 3 modelos anteriores e o download ficam intocados; tudo no aparelho. +- **O que NÃO negociar:** + - O enquadramento inicial continua o de hoje, centrado, com o clamp de hoje — o alcance maior é só do ajuste do visitante (a S18 e o e2e do slider não podem mudar). + - Os 3 modelos S13/S14 (minZoom default 1, clamp cover/contain) e `renderPhotoCard` ficam intocados; `frameCardPhotoOnBbox` tem assinatura/comportamento pinados; `frameCardPhotoOnFace` é o caminho do time e pode evoluir. + - Sem rosto: âncora pela silhueta (alpha bbox), alcance NUNCA pior que o de hoje, sem aviso/erro novo. + - Sem controle/copy novos, sem migration/Consent/access, sem telemetria/persistência; a caixa do rosto é a do momento do recorte (nunca re-detecta em pan/zoom). + - Ajuste LATERAL (X); Y do time não regride. +- **O que reavaliar (hipóteses da intenção):** + - "O clamp é local a `cardPhotoTransform.ts`": o clamp precisa chegar também a `cardRender.renderTeamCard` — o render re-limita e devolve (`cardRender.ts:240-271`), e o composer grava de volta (`CardComposer.tsx:276-278`); se divergirem, o alcance é desfeito no paint seguinte. + - "O state `ready` já tem o que precisa": hoje `useCardCutout` **descarta** `bbox`/`face` (`useCardCutout.ts:88-95`) — sem eles não há âncora no momento do pan/zoom. + - "±244px é simétrico": é a viagem a partir do centro inicial, com o rosto desenhado no tamanho da referência; o invariante real é o **curso total = janela − largura da caixa-âncora desenhada** (muda com o zoom; a âncora é a caixa inteira dentro da janela — é por isso 244 e não 296). + - "A janela garante não cobrir rostos dos candidatos": não garante sozinha (c2/Solla/c4 invadem a janela — achado 9 da exploração). A barreira aprovada no gate é o limite da janela; colisão por caixa vira **limite registrado**, não código (ver D2/D4 e Riscos). + +## Abordagem recomendada + +```mermaid +flowchart LR + CUT["cardCutout — canvas + bbox + face"] --> HOOK["useCardCutout — ready guarda bbox + face"] + HOOK --> RES["resolveCardPhotoAnchor — face usável ? face : bbox"] + RES --> CLAMP["clampCardPhotoTransform — contexto { minZoom, anchorBox }"] + CLAMP -->|"X: união (alcance de hoje ∪ âncora)"| PAN["pan/zoom — arrasto, setas, teclado"] + CLAMP -->|"Y: clamp de hoje"| PAN + PAN --> REN["renderTeamCard — mesmo contexto no desenho e no retorno"] + REN --> WB["write-back idempotente — sem snap-back"] + REN --> DL["PNG do canvas já pintado (sem re-render)"] + TODAY["sem anchorBox — 3 modelos S13/S14"] -.-> CLAMP + INIT["frameCardPhotoOnFace — enquadramento inicial de hoje"] -.->|"só minZoom"| CLAMP +``` + +**Opções consideradas:** A) régua pura parametrizada por uma caixa-âncora (rosto ou silhueta) + contexto de clamp opcional threaded até o render, com X = união do alcance de hoje | B) liberar o pan do time só no composer e parar de re-limitar no render | C) clamp com colisão contra as caixas dos rostos dos candidatos | D) mexer no enquadramento inicial ou no clamp global dos 3 modelos. +**Recomendação:** A — concentra a política no dono (`src/lib/cardPhotoTransform.ts`), preserva os 3 modelos e o `renderPhotoCard` pelo default omitido, e cobre o aceite por construção: a união nunca remove um transform válido de hoje, então o enquadramento inicial da S18 (clampado pela regra de hoje) nunca é reescrito no write-back e o e2e do slider permanece verde. +**Rejeitadas:** B porque o render é auto-suficiente por contrato (desenha e devolve o transform para o write-back) e um pan não re-clampado seria desfeito no paint seguinte; C porque a análise mostra que seria um novo limite artificial (evitar Solla `x336-485` cortaria ~200px do alcance à esquerda com o rosto na referência 103; evitar c4 `x762-865` cortaria ~115px à direita) e ainda não protegeria o corpo, além de contrariar a regra aprovada ("rosto em qualquer ponto da área livre"); D porque reabre a S18, muda o e2e do slider e o claim dos 3 modelos sem evidência. + +### Decisões de engenharia + +1. **Onde vive a regra e a forma do contexto: `CardPhotoClamp` opcional no dono.** + Opções: A) contexto opcional `CardPhotoClamp = { minZoom?: number; anchorBox?: CardAlphaBbox | null }` como último parâmetro de `clampCardPhotoTransform`, `cardPhotoDrawRect` e `panCardPhotoTransform` (substituindo o `minZoom` posicional), e campo `anchorBox?` nas options já existentes de `zoomCardPhotoTransform`; omitido = comportamento de hoje | B) `anchorBox` posicional depois de `minZoom` | C) funções gêmeas do time (`clampTeamCardPhotoTransform`/`cardTeamPhotoDrawRect`) | D) clamp calculado no composer e transform já clampado entregue ao render. + Recomendação: A — uma única política no dono, sem `undefined`/`null` posicional no slot do meio (a limpeza que a S18 fez no `zoom`); o default omitido preserva literalmente os 3 modelos, `renderPhotoCard`, `frameCardPhotoOnBbox` e o inicial; a âncora é uma `CardAlphaBbox` (o `CardFaceBox` tem a mesma forma `{x,y,width,height}` — sem tipo novo). Nome: `anchorBox` para a âncora de enquadramento; `anchor` continua sendo o pivô do zoom (colisão evitada e comentada). + Rejeitadas: B — `pan` chegaria a 7 posicionais e o call site compartilhado dos 3 modelos passaria `undefined` no meio; C — gêmea rasa que duplicaria a regra de Y e criaria superfície nova no knip; D — o render é a fonte do PNG e do retorno do write-back; tem de re-clampar com a MESMA política. + +2. **Regra do X: união do alcance de hoje com o alcance da âncora.** + Opções: A) `newX = união(todayX, anchorX)` quando `anchorX` existe, senão `todayX` | B) X = só `anchorX` | C) X = hoje (mexer só no inicial) | D) âncora + colisão por caixa de candidato. + Recomendação: A — cobre o aceite e garante "nunca pior que hoje" por construção. Fórmula (por eixo X; `start=window.x`, `size=window.width`, `a=coverScale(source,window)·zoom`): + + ```text + drawnW = source.width · a + todayX = [start + size − drawnW, start] se drawnW ≥ size (cover, S13) + [start, start + size − drawnW] se drawnW < size (contain, S18) + anchorX = [start − box.x·a, start + size − (box.x + box.width)·a] + quando box.width·a ≤ size; senão não existe (caixa mais larga que a janela) + newX = união(todayX, anchorX) quando anchorX existe + todayX caso contrário + ``` + + Propriedades que o unit pina: (i) `anchorX` sempre intersecta `todayX` quando existe (`A.min ≤ T.max` e `A.max ≥ T.min` valem nos dois regimes, porque `0 ≤ box.x` e `box.x + box.width ≤ source.width`), então a união é um **intervalo único** (sem buraco); (ii) `anchorX ⊇ todayX` no regime contain, logo o curso nunca encolhe; (iii) com o rosto na referência (103), curso `= 592 − 103 = 489` e viagem a partir do centro inicial `≈±244,5`; (iv) a caixa inteira fica dentro da janela quando o visitante está dentro de `anchorX` — o que toca a borda é a caixa, não o centro. Nota: no regime cover (zoom alto) a união pode manter alcance de hoje além de `anchorX` em um dos lados — é o "nunca pior" e o comportamento aceito da S13, não uma liberdade nova. + Rejeitadas: B — em zoom ≥ 1 (cover) um rosto fora do centro teria a âncora **cortando** alcance de hoje (ex.: rosto centrado a 900px de uma fonte 1200px em zoom 2: hoje o X vai até 286; só a âncora pararia em 174) e poderia invalidar o próprio transform inicial → write-back e regressão da S18; C — não resolve o relato (o visitante quer empurrar **depois** do automático) e mexer no inicial contraria o aceite; D — os números acima (~200px/~115px) mostram que a colisão destruiria o ganho; a barreira aprovada é a janela. + +3. **Sem rosto: âncora = bbox da silhueta com a MESMA regra; piso de hoje preservado pela união.** + Opções: A) `anchorBox = face usável ? face : bbox`, mesma regra X da decisão 2 | B) sem rosto = clamp de hoje literal (sem âncora) | C) âncora sempre a bbox (ignorar o rosto) | D) sem rosto = "âncora no desenho inteiro" (o contain de hoje). + Recomendação: A — uma régua só, sem caminho especial: no enquadramento S15 a bbox cobre a janela (âncora mais larga que a janela → `anchorX` inexistente → clamp de hoje literal); quando o visitante afasta o zoom e a silhueta cabe na janela, o intervalo da âncora é superconjunto do contain de hoje (propriedade ii). "Nunca pior" é propriedade de construção, não caso de teste solto. + Rejeitadas: B — duplicaria a política de clamp e não daria âncora nenhuma à silhueta quando ela cabe na janela (quem não tem rosto medido também quer posicionar a pessoa); C — perde a âncora do caso principal (o relato é sobre o rosto); D — é exatamente o limite de hoje (nada muda). + +4. **Y permanece o de hoje — decisão explícita.** + Opções: A) Y = `clampPhotoOffset` atual (cover/contain por eixo, `minZoom` do time) | B) Y também ancorado pela caixa | C) Y livre até as bordas do card | D) congelar o pan vertical. + Recomendação: A — a intenção é lateral; a Cena 3 fala "posição horizontal"; o clamp de Y protege a composição vertical (topo do recorte abaixo do banner, base dentro da arte) e o "nunca pior" fica literal também no eixo Y; nenhuma evidência de produto para mexer. Registrado como decisão (não silenciosa): só X ganha âncora. + Rejeitadas: B — permitiria o recorte flutuar/sair da janela verticalmente sem pedido e com risco visual novo (céu aparecendo sob o busto); C — descobre a arte sem caso de uso e contraria a barreira; D — removeria um controle que existe hoje (regressão de capacidade). + +5. **Threading até o render/write-back: mesmo contexto no pan, no zoom e no `renderTeamCard`.** + Opções: A) `TeamCardRenderArgs` ganha `anchorBox?`; `renderTeamCard` usa o contexto em `cardPhotoDrawRect` (`:240-245`) **e** no retorno (`:266-271`); o composer passa o mesmo contexto no arrasto (`:387-389`), nas setas (`:396-400`), no teclado e no zoom (`:402-406`) | B) o render recebe um transform já clampado e não re-clampa | C) wrapper de render só do time. + Recomendação: A — clamp de intervalo é idempotente (`clamp(clamp(t)) = clamp(t)`), então o `cardPhotoTransformsEqual` do composer (`:276-278`) nunca dispara write-back espúrio; o download não re-renderiza (`handleDownload` faz `toBlob` do canvas já pintado), logo preview e PNG ficam idênticos por construção; `renderPhotoCard` intocado. + Rejeitadas: B — o render deixaria de ser auto-suficiente (o retorno é o contrato do write-back) e um futuro chamador desenharia diferente do que devolve; C — superfície nova sem necessidade (o `renderTeamCard` já é o caminho do time). + +6. **State `ready`: guardar `bbox` + `face` crus; a âncora é derivada numa função pura.** + Opções: A) `CardCutoutState.ready` ganha `bbox: CardAlphaBbox` e `face: CardFaceBox | null` (espelho fiel do `CardCutoutResult`); o composer resolve `resolveCardPhotoAnchor(bbox, face)` (face usável ? face : bbox) com `useMemo` por recorte; um predicado privado `isUsableCardFace` é extraído e reusado por `frameCardPhotoOnFace` | B) `ready` guarda a âncora já resolvida | C) guardar bbox + face + âncora. + Recomendação: A — o hook continua espelho do resultado (não vira dono da política de clamp); a regra de escolha da âncora vive uma vez na lib pura (mesma validação do inicial); o composer já é o dono do contexto. A caixa é usada como veio do detector/segmentador (coords da fonte), sem reescala nem re-detecção. + Rejeitadas: B — esconde a regra no hook e duplica conhecimento com `frameCardPhotoOnFace`; C — campo derivado redundante. + +7. **e2e: nenhum teste novo ou alterado.** + Opções: A) manter o describe S18 como está (o `ok`/`noface` já atravessa os dois ramos de âncora pelo composer + render) | B) estender com um arrasto e asserção de posição no canvas | C) novo describe S20 com stub dedicado. + Recomendação: A — o e2e não mede alcance em pixels de card sem um seam novo (frágil, mediria a geometria do stub, não a regra); o que importa no e2e é que o inicial e o write-back não regridem, e é exatamente o que os asserts do slider (`noface=1`, `ok<0.5`) e da amostra harmonizada em `(582,550)` já pinam; a régua fica pinada em unit (grid de fontes/zooms/âncoras). O e2e da superfície roda de qualquer forma pelo mapping `src/components/cards` + `src/lib/card` → `frontend` (`scripts/lib/e2e-affected-manifest.mjs:92-98`). + Rejeitadas: B/C — custo de CI e superfície de seam para uma propriedade já coberta em unit. + +### Componentes / mudanças + +- **`CardPhotoClamp` + `resolveCardPhotoAnchor` + `isUsableCardFace`** (`src/lib/cardPhotoTransform.ts`): o contexto de clamp (`{ minZoom?: number; anchorBox?: CardAlphaBbox | null }`), a resolução canônica da âncora (face usável ? face : bbox; a âncora reusa `CardAlphaBbox`, sem tipo novo) e o predicado de rosto usável extraído de `frameCardPhotoOnFace` e reusado por ambos. Reusa `CardAlphaBbox`/`CardFaceBox`, `coverScale`. +- **`clampCardPhotoTransform`, `cardPhotoDrawRect`, `panCardPhotoTransform`, `zoomCardPhotoTransform`** (`src/lib/cardPhotoTransform.ts`): ganham o contexto opcional; X = união (fórmula da D2), Y = `clampPhotoOffset` de hoje; `minZoom` default `CARD_PHOTO_MIN_ZOOM` preservado. `frameCardPhotoOnBbox` intocado; `frameCardPhotoOnFace` só troca a chamada interna para `{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }` (inicial **sem** âncora). +- **`CardCutoutState.ready`** (`src/components/cards/useCardCutout.ts:13-26`): ganha `bbox`/`face`; a chamada de enquadramento (`:69-77`), run id/retry/progresso e o `error: 'empty'` continuam iguais. +- **`CardComposer`** (`src/components/cards/CardComposer.tsx`): `photoAnchorBox` memoizado de `teamReady`; o contexto `{ minZoom: photoMinZoom, anchorBox: photoAnchorBox }` (âncora `null` nos modelos de foto = default) entra em `withTransform`/arrasto/setas/teclado/zoom; a chamada de `renderTeamCard` (`:263-274`) ganha `anchorBox`. Nenhuma mudança de UI, controle, copy ou estado de erro. +- **`renderTeamCard` / `TeamCardRenderArgs`** (`src/lib/cardRender.ts`): campo `anchorBox?`; desenho (`:240-245`) e retorno (`:266-271`) com o mesmo contexto. `renderPhotoCard` (`:198-207`) intocado. +- **Testes** (editar/novo): `tests/unit/cardPhotoTransform.unit.spec.ts` (forma das chamadas que passam `minZoom` + describe novo da âncora: união/curso ±244,5, superconjunto, âncora mais larga → hoje, Y intocado, idempotência); `tests/unit/cardRender.unit.spec.ts` (novo: `renderTeamCard` com `anchorBox` preserva o X fora do alcance antigo e sem `anchorBox` clampa como hoje). `cardModels.unit.spec.ts`, `cardFaceDetection.unit.spec.ts` e o e2e ficam como estão. +- **`docs/changelog/2026-09-19-s20.md`** (novo). +- **Migration:** sem migration — nenhuma collection/global/field; `push:false` intocado (100% client-side). +- **Access / Consent:** não se aplica — nenhuma superfície Payload; a âncora é geometria efêmera no aparelho; sem chave de Consent. +- **UI:** Impeccable C — comportamento, sem controle novo. O artefato `docs/plans/cards-time-de-voce-ajuste-lateral-ui-design.html` (Cenas 3/4) é a referência de gate; craft no browser com fotos reais (com/sem rosto, arrasto até as bordas, zoom antes/depois) como na S18. Sem shape novo. + +### Dados → forma (se aplicável) + +Não se aplica — nenhum KPI, mapa ou série; nenhum dado apresentado/coletado/enviado. A única "forma" é geometria do card (literais medidos) e a posição efêmera no aparelho, exatamente como a intenção fixou. + +## Fases verificáveis + +1. **Tracer — régua pura do clamp** — quota ~0,4 dia. `CardPhotoClamp`, `resolveCardPhotoAnchor`/`isUsableCardFace`; X = união e Y = hoje em `clampCardPhotoTransform`/`cardPhotoDrawRect`/`panCardPhotoTransform`/`zoomCardPhotoTransform`; units novos. + Prova: `pnpm gate:fast` verde; asserts existentes seguem valendo (só muda a forma das chamadas que passam `minZoom`); unit pina: com rosto na referência 103 o intervalo X tem largura 489 e a viagem a partir do centro é ≈±244,5; o alcance de hoje é subconjunto do novo num grid de fontes/zooms/âncoras; âncora mais larga que a janela → byte a byte o clamp de hoje; Y idêntico; `clamp(clamp(t)) = clamp(t)`. +2. **Threading — hook → composer → render** — quota ~0,3–0,4 dia. `ready` com `bbox`/`face`; memo da âncora no composer; contexto no arrasto/setas/teclado/zoom; `TeamCardRenderArgs.anchorBox`; testes de render com/sem âncora. + Prova: `pnpm gate:fast` verde; unit do render; no browser (engine real + stub): com rosto, arrastar até a caixa do rosto tocar a borda da janela, sem snap do write-back e com zoom/harmonização/CTA intactos; sem rosto, o alcance é ao menos o de hoje; modelos quadrado/retangular com pan/zoom/slider como antes. +3. **Gates + changelog** — quota ~0,2 dia. e2e frontend local (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts`), changelog, `pnpm gate:fast`, `pnpm push`. + Prova: S14/S15/S17/S18 verdes sem edição (slider `noface=1`, `ok<0.5` e amostra harmonizada intactos); fixture de console/5xx intacta; card baixável. + +## Rabbit holes / Não escopo (engenharia) + +- Colisão geométrica contra as caixas dos rostos dos candidatos / margens para "não chegar perto" — rejeitada na D2 com números; a barreira do gate é a janela. +- Clipar o corpo do visitante na janela — contraria a Cena 3 (o corpo pode avançar sobre ombros). +- Ancorar o eixo Y, "centralizar" por controle novo, ou qualquer mudança de gesto/controle. +- Re-detectar/rastrear o rosto durante o pan/zoom (já rabbit hole da intenção/S18); persistir a âncora; telemetria. +- Mudar o enquadramento inicial, `frameCardPhotoOnBbox`, `renderPhotoCard`, o slider (piso 0.2/teto 4), a arte-mestre, os banners, a janela `{286,439,592,577}` ou a referência 103. +- Generalizar a âncora para os 3 modelos de foto (não têm recorte/rosto) e criar tipo `CardPhotoAnchor` só por pureza. +- Novo modo de stub ou e2e novo (D7); resolver o débito adiado do "piso duplicado em composer/render" (S18) sem gatilho. +- Migration/Consent/access/int; copy nova; estado de erro novo. + +## Riscos e mitigação + +- **Write-back desfaz o alcance / enquadramento inicial mexido** → inicial usa o clamp de hoje sem âncora; pan/zoom/render usam o mesmo contexto; união nunca remove transform de hoje; units de idempotência e de superconjunto; e2e do slider como guarda de integração. +- **Corpo saindo da janela (gap de arte atrás)** → a base `team-card-base.png` é 100% opaca e a referência `team-card-example.jpg` já tem sobreposição leve; o gap lê como céu/arte (mesmo look do proporcional S18). Craft nos dois extremos; **gatilho de revisão:** se o craft reprovar a estética da borda, aplicar uma margem pequena medida na âncora (decisão de produto no gate, não silenciosa). +- **Rosto de candidato coberto no extremo** → limite registrado: a barreira aprovada é a janela, não colisão por caixa (D2). **Gatilho:** UAT com fotos reais; se um rosto de candidato sumir de forma inaceitável, item sucessor com margem/exclusões de candidato. +- **Sem rosto com alcance pior** → propriedade de superconjunto unit-testada num grid; quando a silhueta é maior que a janela, `anchorX` não existe e o clamp de hoje roda literal. +- **Render e pan divergirem** → uma função, um contexto; `cardPhotoDrawRect` e o retorno do render com o mesmo `anchorBox`; unit de render com/sem âncora; e2e S18 cobre o caminho real com os dois ramos. +- **Zoom:** em zoom alto o rosto pode não caber na janela → `anchorX` vazio → hoje; em zoom baixo a âncora abre ainda mais o alcance; limites do slider intocados. Craft de pan → zoom → pan. +- **Colisão de nome (`anchor` pivô × âncora de enquadramento)** → `anchorBox`; comentário no tipo e nos call sites. +- **Regressão dos 3 modelos / knip** → contexto omitido = default exato; `renderPhotoCard` intocado; S13/S15 unit e e2e sem edição; exports novos todos consumidos. +- **E2E/hermeticidade** → nenhum fetch/log/async novo; stub intocado; guard de console intacto. +- **Performance** → aritmética por pointermove; âncora resolvida uma vez por recorte (memo); sem custo perceptível. + +## Triagem de débitos (simplify — 2 revisores paralelos) + +| ID | Resumo | Origem | Score | Tipo | Destino | +| --- | ------------------------------------------------------------------------ | ---------------- | ----- | ------------- | ------------- | +| S1 | Rosto do MediaPipe pode vir fora da fonte → hull podia abrir gap | simplify/estrut. | 3 | cheap_polish | já_resolvido | +| S2 | `x`/`y` NaN na face viraria offset NaN | simplify/qual. | 3 | cheap_polish | já_resolvido | +| S3 | `anchorOffsetRange` sem dizer que é o eixo X | simplify/qual. | 1 | cheap_polish | já_resolvido | +| S4 | `244.5` mágico e grid "nunca pior" prometido viravam 2 casos | simplify/qual. | 2 | cheap_polish | já_resolvido | +| S5 | Asserção tautológica desenho == retorno no teste do render | simplify/qual. | 1 | cheap_polish | já_resolvido | +| S6 | Piso do time duplicado composer/render (débito S18) | simplify/ambos | 2 | defer_trigger | descartar | +| S7 | `useMemo` do `photoClamp` dispensável | simplify/qual. | 1 | cheap_polish | descartar | +| S8 | e2e não mede o curso em pixels (seam novo) | simplify/estrut. | 1 | cheap_polish | descartar | + +- **Já resolvido no simplify (não reabrir):** S1 (guarda de ranges disjuntos em `clampPhotoOffset` + teste), S2 (`isUsableCardFace` exige `x/y/width/height` finitos), S3 (`anchorXOffsetRange` + doc), S4 (curso derivado de `(TEAM_WINDOW.width − REFERENCE) / 2` + grid de zooms/âncoras/offsets), S5 (asserções cross-contexto). +- **Explicitamente fora:** S6 — já é débito registrado na S18 (`docs/plans/cards-time-de-voce-escala-cabeca-impl.md`, gatilho "piso duplicado em composer e render"); o render segue dono do piso do time nesta entrega. S7 — memo barato que estabiliza as deps do efeito. S8 — decidido na D7 (unit pina a régua; e2e pina a integração sem seam novo). +- **Defer com gatilho:** `readLargestFaceBox` copia `originX/originY` crus do detector; o clamp novo torna a exposição inofensiva (fallback), mas o enquadramento S18 ainda usa a caixa crua. Gatilho: um UAT/craft com rosto cortado na borda mostrando enquadramento ruim, ou o próximo item que tocar `readLargestFaceBox` — aí clampar à fonte antes de enquadrar. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: a caixa do rosto alcança as bordas da janela (curso ≈489px, ≈±244px do centro inicial); sem controle novo; inicial centrado e S18 intocados; 3 modelos, banners, harmonização, troca de foto e download intocados; sem rosto nunca pior que hoje; 100% no aparelho. +- [ ] Invariantes AGENTS/engineering-standards: math pura/client-safe em `src/lib` (sem DOM); identificadores em inglês, copy pt-BR; sem migration/Consent/access/int; knip/cycles verdes; arte-mestre intocada; `pnpm gate:fast` + e2e da superfície + `pnpm push`. +- [ ] Testes de domínio previstos: unit do clamp com âncora (união, superconjunto, curso ±244,5, Y, idempotência) e do `renderTeamCard` com/sem `anchorBox`; e2e existente reusa `ok`/`noface`; **int não se aplica** (client-side, sem DB/access/escrita multi-collection). + +## Self-score decision-quality: 5/5 + +1. **Decisões caras com rejeitadas:** API do contexto (A–D), regra do X (A–D), fallback da silhueta (A–D), Y (A–D), threading/write-back (A–C), state/âncora (A–C) e e2e (A–C) — todas com Opções/Recomendação/Rejeitadas; o barato (nome do campo, literais) fica como fill-in. +2. **Cabe no appetite:** ≤1 dia em 3 fases, sem migration/infra; a régua pura vem primeiro (tracer) e o threading depois, com o fallback preservado por construção. +3. **Rabbit holes nomeados:** colisão por caixa, clip do corpo, âncora em Y, re-detecção, e2e/seam novo, generalização para os 3 modelos. +4. **Depth check:** reusa `clampPhotoOffset`/`coverScale`/`CardAlphaBbox`, o `CardCutoutResult`/hook, o contexto de `minZoom` já existente e o padrão de options do `zoomCardPhotoTransform`; nada de abstração nova além da função pura de âncora e sem tipo redundante. +5. **Intenção preservada:** outcome e aceite intactos (curso ≈2×, sem controle novo, inicial/S18 intocados, fallback nunca pior, privacidade); a engenharia só resolveu o que a intenção deixou em aberto (fórmula, fallback, Y, API) e registrou o limite da barreira dos candidatos. diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx index 11cadad8..a03ea43f 100644 --- a/src/components/cards/CardComposer.tsx +++ b/src/components/cards/CardComposer.tsx @@ -38,7 +38,9 @@ import { cardPhotoTransformsEqual, centerCardPhotoTransform, panCardPhotoTransform, + resolveCardPhotoAnchor, zoomCardPhotoTransform, + type CardPhotoClamp, type CardPhotoSize, type CardPhotoTransform, } from '@/lib/cardPhotoTransform' @@ -205,6 +207,18 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer ? (photoTransform ?? teamReady?.transform ?? null) : photoTransform + /** + * S20 — the fine-tuning clamp context: the team's anchor is the detected face + * when usable, else the silhouette; the photo models keep the S13 defaults. + */ + const photoClamp = useMemo( + () => ({ + minZoom: photoMinZoom, + anchorBox: teamReady ? resolveCardPhotoAnchor(teamReady.bbox, teamReady.face) : null, + }), + [photoMinZoom, teamReady], + ) + // 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(() => { @@ -268,6 +282,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer photoSize: { width: teamReady.width, height: teamReady.height }, transform: effectiveTransform, window: photoWindow, + anchorBox: photoClamp.anchorBox, name, fontFamily, measure, @@ -322,6 +337,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer photoSize, effectiveTransform, photoWindow, + photoClamp, ]) const handlePhotoFile = async (file: File | undefined) => { @@ -385,7 +401,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer const dy = (event.clientY - drag.y) * scale dragRef.current = { ...drag, x: event.clientX, y: event.clientY } withTransform((transform, size, window) => - panCardPhotoTransform(transform, size, window, dx, dy, photoMinZoom), + panCardPhotoTransform(transform, size, window, dx, dy, photoClamp), ) } @@ -395,13 +411,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer const panBy = (dx: number, dy: number) => { withTransform((transform, size, window) => - panCardPhotoTransform(transform, size, window, dx, dy, photoMinZoom), + panCardPhotoTransform(transform, size, window, dx, dy, photoClamp), ) } const zoomTo = (zoom: number) => { withTransform((transform, size, window) => - zoomCardPhotoTransform(transform, size, window, zoom, { minZoom: photoMinZoom }), + zoomCardPhotoTransform(transform, size, window, zoom, photoClamp), ) } diff --git a/src/components/cards/useCardCutout.ts b/src/components/cards/useCardCutout.ts index 83e2ea61..4c467119 100644 --- a/src/components/cards/useCardCutout.ts +++ b/src/components/cards/useCardCutout.ts @@ -5,7 +5,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { removeCardPhotoBackground } from '@/components/cards/cardCutout' import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas' import { TEAM_CARD_FACE_REFERENCE_SIZE, type CardRect } from '@/lib/cardModels' -import { frameCardPhotoOnFace, type CardPhotoTransform } from '@/lib/cardPhotoTransform' +import { + frameCardPhotoOnFace, + type CardAlphaBbox, + type CardFaceBox, + type CardPhotoTransform, +} from '@/lib/cardPhotoTransform' export type CardCutoutState = | { status: 'idle' } @@ -23,6 +28,9 @@ export type CardCutoutState = height: number /** Initial framing: cutout top on the slot top, face centered (S18). */ transform: CardPhotoTransform + /** S20 — silhouette and detected face, kept for the fine-tuning anchor. */ + bbox: CardAlphaBbox + face: CardFaceBox | null } | { status: 'error'; reason: 'engine' | 'empty' } @@ -92,6 +100,8 @@ export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenc width: result.width, height: result.height, transform, + bbox: result.bbox, + face: result.face, }) }, [photoWindow, harmonyReferenceSrc], diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts index 412dd5c4..9b12a906 100644 --- a/src/lib/cardPhotoTransform.ts +++ b/src/lib/cardPhotoTransform.ts @@ -8,6 +8,12 @@ * (`CARD_TEAM_PHOTO_MIN_ZOOM`), so its clamp keeps the drawn photo *inside* the * window when it is smaller than it instead of pinning it to the edge. * + * S20 — the team fine-tuning may also anchor on the measured box (the face, + * else the silhouette): the horizontal range becomes the union of the S13/S18 + * rule with the range that lets the anchor box touch both window edges, so the + * visitor is never limited by the transparent canvas around the person. The + * vertical axis keeps the S13/S18 rule. + * * `centerCardPhotoTransform` is the initial framing (cover on the window, not * on the whole card) so the face lands on the visible area of the frame. */ @@ -66,12 +72,40 @@ export type CardFaceBox = { height: number } +/** + * S20 — fine-tuning context of the clamp: `minZoom` widens the zoom floor + * (S18) and `anchorBox` allows the pan/zoom to keep the measured box (face, + * else silhouette) reaching the window edges. Omitted = the S13/S18 rule. + */ +export type CardPhotoClamp = { + minZoom?: number + anchorBox?: CardAlphaBbox | null +} + export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean => a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) +const isUsableCardFace = (face: CardFaceBox | null): face is CardFaceBox => + face !== null && + Number.isFinite(face.x) && + Number.isFinite(face.y) && + Number.isFinite(face.width) && + face.width > 0 && + Number.isFinite(face.height) && + face.height > 0 + +/** + * S20 — the box the fine-tuning anchors on: the detected face when it is + * usable, else the visible silhouette (alpha bbox). Source-pixel coords. + */ +export const resolveCardPhotoAnchor = ( + bbox: CardAlphaBbox, + face: CardFaceBox | null, +): CardAlphaBbox => (isUsableCardFace(face) ? face : bbox) + export const coverScale = (source: CardPhotoSize, window: CardRect): number => { if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1 @@ -82,9 +116,9 @@ export const cardPhotoDrawRect = ( transform: CardPhotoTransform, source: CardPhotoSize, window: CardRect, - minZoom: number = CARD_PHOTO_MIN_ZOOM, + clampOptions?: CardPhotoClamp, ): CardRect => { - const clamped = clampCardPhotoTransform(transform, source, window, minZoom) + const clamped = clampCardPhotoTransform(transform, source, window, clampOptions) const scale = coverScale(source, window) * clamped.zoom return { @@ -100,25 +134,62 @@ export const cardPhotoDrawRect = ( * (the S13 rule) and fully inside it when it is smaller (the S18 team framing, * where the visitor no longer fills the slot). */ -const clampPhotoOffset = (value: number, start: number, size: number, drawn: number): number => - drawn >= size - ? clamp(value, start + size - drawn, start) - : clamp(value, start, start + size - drawn) +const photoOffsetRange = (start: number, size: number, drawn: number): [number, number] => + drawn >= size ? [start + size - drawn, start] : [start, start + size - drawn] + +/** + * S20 — the horizontal offset range that lets the drawn anchor box touch both + * window edges; `null` when the drawn box is wider than the window (no + * placement). + */ +const anchorXOffsetRange = ( + box: CardAlphaBbox, + scale: number, + start: number, + size: number, +): [number, number] | null => + box.width * scale > size + ? null + : [start - box.x * scale, start + size - (box.x + box.width) * scale] + +/** + * S20 — the S13/S18 range, widened by the anchor range when one exists. For a + * box inside the source the two always intersect, so the union is a single + * interval; a detected face may sit at the image edge, so a disjoint anchor + * (outside the photo) falls back to the S13/S18 range instead of hulling a gap. + */ +const clampPhotoOffset = ( + value: number, + start: number, + size: number, + drawn: number, + anchorRange?: [number, number] | null, +): number => { + const [min, max] = photoOffsetRange(start, size, drawn) + if (!anchorRange || anchorRange[1] < min || anchorRange[0] > max) { + return clamp(value, min, max) + } + + return clamp(value, Math.min(min, anchorRange[0]), Math.max(max, anchorRange[1])) +} export const clampCardPhotoTransform = ( transform: CardPhotoTransform, source: CardPhotoSize, window: CardRect, - minZoom: number = CARD_PHOTO_MIN_ZOOM, + { minZoom = CARD_PHOTO_MIN_ZOOM, anchorBox }: CardPhotoClamp = {}, ): CardPhotoTransform => { const zoom = clamp(transform.zoom, minZoom, CARD_PHOTO_MAX_ZOOM) const scale = coverScale(source, window) * zoom const width = source.width * scale const height = source.height * scale + const anchorRange = anchorBox + ? anchorXOffsetRange(anchorBox, scale, window.x, window.width) + : null return { zoom, - offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width), + offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width, anchorRange), offsetY: clampPhotoOffset(transform.offsetY, window.y, window.height, height), } } @@ -204,7 +275,7 @@ export const frameCardPhotoOnFace = ( ): CardPhotoTransform | null => { const baseFraming = frameCardPhotoOnBbox(source, window, bbox) if (!baseFraming) return null - if (!face || !(face.width > 0) || !(face.height > 0) || !(referenceSize > 0)) return baseFraming + if (!isUsableCardFace(face) || !(referenceSize > 0)) return baseFraming const scale = coverScale(source, window) const faceSize = Math.max(face.width, face.height) @@ -223,7 +294,7 @@ export const frameCardPhotoOnFace = ( }, source, window, - CARD_TEAM_PHOTO_MIN_ZOOM, + { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, ) } @@ -234,19 +305,20 @@ export const panCardPhotoTransform = ( window: CardRect, dx: number, dy: number, - minZoom: number = CARD_PHOTO_MIN_ZOOM, + clampOptions?: CardPhotoClamp, ): CardPhotoTransform => clampCardPhotoTransform( { ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy }, source, window, - minZoom, + clampOptions, ) /** * Rescales around a window anchor (used by the zoom control): keeps the point * under `anchor` stable from old to new zoom, then clamps. The team model - * passes its own `minZoom` so the visitor can fine-tune below the cover floor. + * passes its own `minZoom` (and, on S20, the `anchorBox`) so the visitor can + * fine-tune below the cover floor. */ export const zoomCardPhotoTransform = ( transform: CardPhotoTransform, @@ -256,14 +328,17 @@ export const zoomCardPhotoTransform = ( { anchor, minZoom = CARD_PHOTO_MIN_ZOOM, - }: { anchor?: { x: number; y: number }; minZoom?: number } = {}, + anchorBox, + }: { anchor?: { x: number; y: number } } & CardPhotoClamp = {}, ): CardPhotoTransform => { const resolvedAnchor = anchor ?? { x: window.x + window.width / 2, y: window.y + window.height / 2, } const zoom = clamp(nextZoom, minZoom, CARD_PHOTO_MAX_ZOOM) - if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window, minZoom) + if (zoom === transform.zoom) { + return clampCardPhotoTransform(transform, source, window, { minZoom, anchorBox }) + } const ratio = zoom / transform.zoom @@ -275,6 +350,6 @@ export const zoomCardPhotoTransform = ( }, source, window, - minZoom, + { minZoom, anchorBox }, ) } diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index 1818e83c..33beffff 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -29,6 +29,8 @@ import { CARD_TEAM_PHOTO_MIN_ZOOM, cardPhotoDrawRect, clampCardPhotoTransform, + type CardAlphaBbox, + type CardPhotoClamp, type CardPhotoSize, type CardPhotoTransform, } from './cardPhotoTransform' @@ -217,6 +219,8 @@ export type TeamCardRenderArgs = { fontFamily: string measure: CardMeasureText slot?: CardNameBannerSlot + /** S20 — measured anchor (face, else silhouette) of the fine-tuning clamp. */ + anchorBox?: CardAlphaBbox | null } export type TeamCardRenderResult = { @@ -237,12 +241,11 @@ export const renderTeamCard = ( ): TeamCardRenderResult => { ctx.drawImage(args.base, 0, 0, model.width, model.height) - const rect = cardPhotoDrawRect( - args.transform, - args.photoSize, - args.window, - CARD_TEAM_PHOTO_MIN_ZOOM, - ) + const clampOptions: CardPhotoClamp = { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: args.anchorBox, + } + const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window, clampOptions) ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) ctx.drawImage(args.overlay, 0, 0, model.width, model.height) @@ -263,11 +266,6 @@ export const renderTeamCard = ( return { fit, - transform: clampCardPhotoTransform( - args.transform, - args.photoSize, - args.window, - CARD_TEAM_PHOTO_MIN_ZOOM, - ), + transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window, clampOptions), } } diff --git a/tests/unit/cardPhotoTransform.unit.spec.ts b/tests/unit/cardPhotoTransform.unit.spec.ts index 925ac7e8..851711dc 100644 --- a/tests/unit/cardPhotoTransform.unit.spec.ts +++ b/tests/unit/cardPhotoTransform.unit.spec.ts @@ -12,9 +12,11 @@ import { frameCardPhotoOnBbox, frameCardPhotoOnFace, panCardPhotoTransform, + resolveCardPhotoAnchor, zoomCardPhotoTransform, type CardAlphaBbox, type CardFaceBox, + type CardPhotoClamp, type CardPhotoSize, } from '@/lib/cardPhotoTransform' @@ -166,7 +168,9 @@ describe('frameCardPhotoOnFace', () => { expect(transform.zoom).toBeLessThan(CARD_PHOTO_MIN_ZOOM) expect(transform.zoom).toBeGreaterThanOrEqual(CARD_TEAM_PHOTO_MIN_ZOOM) - const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM) + const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + }) const scale = rect.width / SOURCE.width // ...and still lands the face exactly on the reference size expect(face.width * scale).toBeCloseTo(REFERENCE, 5) @@ -190,7 +194,9 @@ describe('frameCardPhotoOnFace', () => { expect(transform.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM) - const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM) + const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + }) const scale = rect.width / SOURCE.width const achieved = face.width * scale @@ -274,11 +280,13 @@ describe('clampCardPhotoTransform', () => { { zoom: 0.1, offsetX: 9999, offsetY: -9999 }, LANDSCAPE, WINDOW, - CARD_TEAM_PHOTO_MIN_ZOOM, + { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, ) expect(shrunk.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM) - const rect = cardPhotoDrawRect(shrunk, LANDSCAPE, WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM) + const rect = cardPhotoDrawRect(shrunk, LANDSCAPE, WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + }) expect(rect.x).toBeGreaterThanOrEqual(WINDOW.x) expect(rect.y).toBeGreaterThanOrEqual(WINDOW.y) expect(rect.x + rect.width).toBeLessThanOrEqual(WINDOW.x + WINDOW.width) @@ -305,13 +313,188 @@ describe('panCardPhotoTransform', () => { WINDOW, 40, 30, - CARD_TEAM_PHOTO_MIN_ZOOM, + { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, ) expect(panned).toEqual({ zoom: 0.3, offsetX: 40, offsetY: 30 }) }) }) +describe('resolveCardPhotoAnchor', () => { + const BBOX: CardAlphaBbox = { x: 10, y: 20, width: 300, height: 400 } + + it('prefers a usable face over the silhouette', () => { + const face: CardFaceBox = { x: 40, y: 50, width: 60, height: 60 } + + expect(resolveCardPhotoAnchor(BBOX, face)).toEqual(face) + expect(resolveCardPhotoAnchor(BBOX, { ...face, width: Number.NaN })).toEqual(BBOX) + expect(resolveCardPhotoAnchor(BBOX, { ...face, width: 0 })).toEqual(BBOX) + expect(resolveCardPhotoAnchor(BBOX, { ...face, height: -1 })).toEqual(BBOX) + expect(resolveCardPhotoAnchor(BBOX, null)).toEqual(BBOX) + }) +}) + +describe('S20 anchor clamp', () => { + const TEAM_WINDOW: CardRect = { x: 286, y: 439, width: 592, height: 577 } + const SOURCE: CardPhotoSize = { width: 1000, height: 1250 } + const BBOX: CardAlphaBbox = { x: 200, y: 100, width: 600, height: 900 } + const REFERENCE = 103 + // Big enough that the face lands on the reference below the cover floor: + // the drawn photo is smaller than the window horizontally (contain regime). + const FACE: CardFaceBox = { x: 400, y: 200, width: 200, height: 200 } + const anchor = resolveCardPhotoAnchor(BBOX, FACE) + + it('lets the anchor box reach both window edges from the initial framing', () => { + const initial = frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, FACE, REFERENCE) + expect(initial).not.toBeNull() + if (!initial) return + + const clampOptions: CardPhotoClamp = { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: anchor, + } + const scale = coverScale(SOURCE, TEAM_WINDOW) * initial.zoom + const toTheLeft = panCardPhotoTransform(initial, SOURCE, TEAM_WINDOW, -9999, 0, clampOptions) + const toTheRight = panCardPhotoTransform(initial, SOURCE, TEAM_WINDOW, 9999, 0, clampOptions) + // course = window − face drawn on the reference size, split evenly around + // the initial framing: the design's ≈±244px per side + const halfCourse = (TEAM_WINDOW.width - REFERENCE) / 2 + + expect(initial.offsetX - toTheLeft.offsetX).toBeCloseTo(halfCourse, 5) + expect(toTheRight.offsetX - initial.offsetX).toBeCloseTo(halfCourse, 5) + // the anchor box touches each edge and never leaves the window + expect(toTheLeft.offsetX + anchor.x * scale).toBeCloseTo(TEAM_WINDOW.x, 5) + expect(toTheRight.offsetX + (anchor.x + anchor.width) * scale).toBeCloseTo( + TEAM_WINDOW.x + TEAM_WINDOW.width, + 5, + ) + }) + + it('accepts on the anchor side what the S18 clamp rejects, without touching Y', () => { + const zoom = REFERENCE / (FACE.width * coverScale(SOURCE, TEAM_WINDOW)) + const scale = coverScale(SOURCE, TEAM_WINDOW) * zoom + const clampOptions: CardPhotoClamp = { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: anchor, + } + + const todayRight = panCardPhotoTransform( + { zoom, offsetX: 0, offsetY: 0 }, + SOURCE, + TEAM_WINDOW, + 9999, + 9999, + { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, + ) + const anchoredRight = panCardPhotoTransform( + { zoom, offsetX: 0, offsetY: 0 }, + SOURCE, + TEAM_WINDOW, + 9999, + 9999, + clampOptions, + ) + + expect(anchoredRight.offsetX).toBeGreaterThan(todayRight.offsetX) + expect(anchoredRight.offsetY).toBe(todayRight.offsetY) + expect(anchoredRight.offsetX + (anchor.x + anchor.width) * scale).toBeCloseTo( + TEAM_WINDOW.x + TEAM_WINDOW.width, + 5, + ) + }) + + it('never rejects a transform that the S18 clamp accepts (grid of zooms and anchors)', () => { + const anchors: CardAlphaBbox[] = [ + anchor, + { x: 40, y: 60, width: 300, height: 300 }, + { x: 0, y: 0, width: 900, height: 900 }, + { x: -400, y: 0, width: 500, height: 500 }, + ] + + for (const zoom of [0.3, 0.87, 1, 1.6, 2.5]) { + for (const anchorBox of anchors) { + for (const offset of [-2000, -500, 0, 300, 900, 2000]) { + const today = clampCardPhotoTransform( + { zoom, offsetX: offset, offsetY: offset }, + SOURCE, + TEAM_WINDOW, + { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, + ) + const anchored = clampCardPhotoTransform(today, SOURCE, TEAM_WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox, + }) + + expect(anchored).toEqual(today) + } + } + } + }) + + it('falls back to the S18 range when the anchor lies outside the photo', () => { + // zoom 1 draws the source exactly on the window width: today's X is pinned, + // while a face box at x = -100 would anchor entirely to its right + const clampOptions: CardPhotoClamp = { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: { x: -100, y: 0, width: 100, height: 100 }, + } + const today = clampCardPhotoTransform( + { zoom: 1, offsetX: 9999, offsetY: 9999 }, + SOURCE, + TEAM_WINDOW, + { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + }, + ) + + expect( + clampCardPhotoTransform( + { zoom: 1, offsetX: 9999, offsetY: 9999 }, + SOURCE, + TEAM_WINDOW, + clampOptions, + ), + ).toEqual(today) + }) + + it('falls back to the S18 rule when the drawn anchor is wider than the window', () => { + const wideAnchor: CardAlphaBbox = { x: 0, y: 0, width: 600, height: 600 } + // zoom 2: the 600px anchor draws 710px wide, wider than the 592px window + const transform = { zoom: 2, offsetX: 9999, offsetY: 9999 } + const clampOptions: CardPhotoClamp = { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: wideAnchor, + } + + expect(clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, clampOptions)).toEqual( + clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + }), + ) + }) + + it('is idempotent with an anchor in both regimes', () => { + const transforms = [ + { zoom: 0.5, offsetX: -9999, offsetY: -9999 }, + { zoom: 0.87, offsetX: 9999, offsetY: 9999 }, + { zoom: 2.5, offsetX: -400, offsetY: 120 }, + ] + + for (const transform of transforms) { + const once = clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: anchor, + }) + const twice = clampCardPhotoTransform(once, SOURCE, TEAM_WINDOW, { + minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, + anchorBox: anchor, + }) + + expect(twice).toEqual(once) + } + }) +}) + describe('zoomCardPhotoTransform', () => { it('keeps the anchor point stable while zooming', () => { const start = centerCardPhotoTransform(LANDSCAPE, WINDOW) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts index 803f9c14..29a0c4ad 100644 --- a/tests/unit/cardRender.unit.spec.ts +++ b/tests/unit/cardRender.unit.spec.ts @@ -12,6 +12,7 @@ import { fitCardName } from '@/lib/cardNameFit' import { centerCardPhotoTransform, frameCardPhotoOnBbox, + type CardAlphaBbox, type CardPhotoSize, } from '@/lib/cardPhotoTransform' import { @@ -244,6 +245,37 @@ describe('renderTeamCard', () => { expect(rectCalls).toHaveLength(1) expect(textCalls.map((call) => call.text)).toEqual(['TIME DE']) }) + + it('S20 — draws and returns the anchored reach instead of the S18 bound', () => { + const face: CardAlphaBbox = { x: 80, y: 100, width: 60, height: 60 } + const pushed = { ...transform, offsetX: 9999 } + const draw = (anchorBox?: CardAlphaBbox | null) => { + const fake = createFakeContext() + const result = renderTeamCard(fake.ctx, teamModel, { + base: { id: 'base' } as unknown as CanvasImageSource, + overlay: { id: 'overlay' } as unknown as CanvasImageSource, + photo: { id: 'photo' } as unknown as CanvasImageSource, + photoSize, + transform: pushed, + window, + anchorBox, + name: 'Maria', + fontFamily: 'Brexter', + measure: createCardMeasure(fake.ctx, 'Brexter'), + }) + + return { dx: fake.drawCalls[1]!.dx, result } + } + + const s18 = draw() + const anchored = draw(face) + + // the face box reaches further right than the S18 cover bound, in the draw + // and in the write-back contract, with Y untouched + expect(anchored.dx).toBeGreaterThan(s18.dx) + expect(anchored.result.transform.offsetX).toBeGreaterThan(s18.result.transform.offsetX) + expect(anchored.result.transform.offsetY).toBe(s18.result.transform.offsetY) + }) }) describe('resolveCardBannerWidth (S16)', () => {