feat(S20): ajuste lateral da foto do card time sem limite artificial (Closes #1216)

This commit is contained in:
Francisco Solla 2026-09-19 16:13:49 -03:00
parent 8b74aa705a
commit e652f0ae45
8 changed files with 524 additions and 37 deletions

View file

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

View file

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

View file

@ -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<CardPhotoClamp>(
() => ({
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),
)
}

View file

@ -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],

View file

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

View file

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

View file

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

View file

@ -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)', () => {