mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
feat(S33): ajuste da foto do card do time sem limite (Closes #1297)
This commit is contained in:
parent
133136dda3
commit
9069d23d88
10 changed files with 383 additions and 19 deletions
1
docs/changelog/2026-09-24-s33-card-time-ajuste-livre.md
Normal file
1
docs/changelog/2026-09-24-s33-card-time-ajuste-livre.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S33 — Card do time: ajuste da foto livre, sem limite (2026-09-24):** nos dois modelos de time do estúdio de cards (`time-de-voce` e `time-do-estadual`), o ajuste do visitante — arrasto no quadro, botões de ajuste fino e setas/teclado — deixa de ser barrado pelo espaço transparente do recorte: a foto vai a qualquer posição nos dois eixos, inclusive para fora do quadro, revelando a arte de trás, e volta arrastando na mesma área ativa. A decisão é do gate humano de 2026-09-24 (o atrito reencontrado no teste do card com estadual é o mesmo que a S20 tentou resolver com âncora de silhueta — e cuja remoção no revert do S15/`48e08bf7` devolveu o limite): **sem limite nenhum**, sem âncora, caixa medida ou união de alcances. O enquadramento automático S15 (`frameCardPhotoOnBbox`) continua sendo o ponto de partida, aplicado uma vez ao carregar; os limites do zoom seguem `[1,4]`; prévia e PNG baixado mostram a mesma posição (sem "voltar sozinho"). A política é do **modelo**, não da chamada: o catálogo ganhou `photoPosition?: 'bounded' | 'free'` (`src/lib/cardModels.ts`; `'free'` só nos dois modelos do time — os 3 anteriores, a colinha e o nome ficam com o default omitido = bounded de hoje) e o dono da geometria aceita `CardPhotoClampOptions.position` (`clampCardPhotoTransform`/`cardPhotoDrawRect`/`panCardPhotoTransform` no último parâmetro, `zoomCardPhotoTransform` como 6º — o `anchor` segue posicional para não editar os pins S13/S14/S15); com `free`, só o zoom é normalizado e os offsets voltam intactos — inclusive no early-return do caminho "zoom inalterado", que era o ponto silencioso de snap-back. O `renderTeamCard` lê a MESMA política do modelo no desenho e no retorno (o retorno é o contrato do write-back do composer; re-clampar ali reintroduziria o snap-back) e o `renderPhotoCard`/`centerCardPhotoTransform`/`handlePhotoFile` ficam intocados — a superfície S13/S14 não muda em nada. Sem controle, copy, estado ou telemetria novos; sem migration/Consent/access; 100% no aparelho. Verificação: unit (novo describe do free com offsets preservados nos dois eixos, zoom ainda `[1,4]`, anti-snap-back do zoom inalterado e idempotência; pin de catálogo `'free'` só nos dois times; pin do retorno do `renderTeamCard` "zoom clampado, posição preservada" e do `renderPhotoCard` ainda bounded; os descritos default de clamp/pan/zoom/frame passam **sem edição**), e2e novo no `frontend` que discrimina (com o clamp antigo ele falha: 8× `Shift+→` = +384px contra o teto antigo de 88,67px da geometria do stub) e pina as duas falhas reais — o limite que persistia e o snap-back do write-back — sobre a sonda da arte de trás (`[246,246,248,255]`) com o slider ainda em 1, e2e S14/S15/S30/S31 verdes **sem edição** (enquadramento, harmonização, troca de estadual e download intactos), craft no browser com o stub nos dois modelos (foto totalmente fora no desktop 1280 e no drawer 390, arte de trás aparecendo, PNG gerado) e `pnpm gate:fast`. Sem trigger do `designer` (o artefato aprovado registra explicitamente "sem novidade de interface"; o diff não muda estrutura visual — só a régua pura e a fiação dos gestos no markup aprovado).
|
||||
155
docs/plans/cards-time-ajuste-sem-limite-impl.md
Normal file
155
docs/plans/cards-time-ajuste-sem-limite-impl.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Impl: S33 — Card do time (você e estadual): ajuste da foto livre, sem limite
|
||||
|
||||
Status: aprovado (modo --auto)
|
||||
Atualizado em: 2026-09-24
|
||||
Issue: #1297
|
||||
Intenção: docs/plans/cards-time-ajuste-sem-limite.md
|
||||
Appetite restante: herdado (~0,5 dia) — sem migration; 3 fases: régua pura ~0,25 + render/composer ~0,2 + gates/changelog ~0,05
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** nos dois modelos de time (`time-de-voce`, `time-do-estadual`), depois do enquadramento automático S15, arrasto/setas/teclado movem a foto livremente nos dois eixos — inclusive para fora do quadro, revelando a arte de trás; a área de arrasto segue ativa para voltar; prévia e PNG mostram a mesma posição (sem "voltar sozinho"); o zoom segue `[1,4]`; S13/S14, colinha e o resto do funil ficam intocados; sem controle, copy ou telemetria novos.
|
||||
- **O que NÃO negociar:**
|
||||
- Sem limite de posição (X e Y) nos dois modelos do time — decisão do gate (2026-09-24); nada de âncora de silhueta, caixa medida, união de alcances, margem de composição ou colisão com candidatos (o S20/revert `48e08bf7` é história, não base).
|
||||
- Enquadramento inicial S15 (`frameCardPhotoOnBbox`) intocado, aplicado uma vez ao carregar a foto; zoom `[1,4]` intocado; preview = PNG (`toBlob` do mesmo canvas, sem re-render).
|
||||
- S13/S14 (`eu-sou-solla`, `perfil-quadrado`, `perfil-retangular`) e `renderPhotoCard` sem mudança de superfície; colinha (S31) não tem transform de foto.
|
||||
- Sem controle/copy/estado de erro/telemetria novos; 100% no aparelho; sem migration/Consent/access/int.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- "o limite vive só em `cardPhotoTransform.ts`": há re-clamp no desenho **e** no retorno de `renderTeamCard` (`src/lib/cardRender.ts:312` e `:314`), e o retorno é o contrato do write-back (`CardComposer.tsx:413-415`); se o render não souber da política, o snap-back volta.
|
||||
- "os controles do composer bastam": o pan é um funil (`withTransform` + `panBy` + `zoomTo`, `CardComposer.tsx:527-575`) e o zoom re-clampa a posição também no caminho "zoom inalterado" (`cardPhotoTransform.ts:181`) — ponto silencioso de snap-back.
|
||||
- "não há nada a preservar no S13/S14": há — o default omitido das funções do dono e o `renderPhotoCard` (que não recebe modelo). A garantia mais forte é: **specs S13/S14 passam sem edição**.
|
||||
- "`#1277` só conflita por arquivo": o diff no composer deve ser mínimo (1 constante + 3 closures) para o rebase do S30-FOLLOWUP-DRY ser barato.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CAT["catálogo: photoPosition 'free' nos 2 modelos do time"] --> POS{"position do modelo"}
|
||||
POS -->|free| COMP["CardComposer: arrasto/setas/teclado/zoom sem clamp de posição"]
|
||||
POS -->|free| REN["renderTeamCard: drawRect + retorno sem clamp de posição"]
|
||||
COMP --> WB["write-back idempotente (cardPhotoTransformsEqual) — sem snap-back"]
|
||||
REN --> WB
|
||||
REN --> PNG["PNG = canvas já pintado (toBlob, sem re-render)"]
|
||||
POS -->|"omitido = bounded"| S13["renderPhotoCard + centerCardPhotoTransform — S13/S14 de hoje"]
|
||||
INIT["frameCardPhotoOnBbox (S15) — clamp bounded"] -.->|"ponto de partida"| COMP
|
||||
ZOOM["zoom sempre clampado [1,4]"] --> COMP
|
||||
ZOOM --> REN
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) política por modelo no catálogo (`photoPosition`) + opção `position` no dono, default omitido = bounded, lida pelo composer e pelo `renderTeamCard` | B) `kind === 'team'` derivado em cada call site | C) threading do composer → `TeamCardRenderArgs` (forma S20) | D) remover o clamp de posição para todos os modelos | E) branch por `model.id` dentro do render.
|
||||
**Recomendação:** A — a regra é do modelo, não da chamada; o catálogo já carrega as decisões por modelo (`photoWindow`, `overlaySrc`, `stateDeputyPicker`) e passa a ser a **fonte única** consumida pelos dois pontos (composer e render), o que elimina por construção a divergência que a S20 tinha de resolver com threading de um dado dinâmico. O default omitido preserva literalmente S13/S14 e o S15 inicial.
|
||||
**Rejeitadas:** B porque a regra ficaria duplicada em dois call sites e o catálogo não a registraria (o gate diz "os dois modelos do time"); C porque threadar um valor **estático** por toda a cadeia é cerimônia sem volatilidade (na S20 era dado dinâmico: a âncora); D porque reabre o aceite ("os 3 modelos anteriores intocados"); E porque acopla o renderizador a ids e o catálogo já é o dono do comportamento por modelo.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Onde vive a política: campo `photoPosition` no catálogo, tipo `CardPhotoPosition` exportado de `cardModels.ts`.**
|
||||
Opções: A) `CardPhotoPosition = 'bounded' | 'free'` em `src/lib/cardModels.ts`; `CardModel.photoPosition?: CardPhotoPosition` setado `'free'` só em `time-de-voce` (`cardModels.ts:93-102`) e `time-do-estadual` (`:104-118`); omitido nos demais | B) booleano `freePhoto?: boolean` no catálogo | C) derivar de `kind === 'team'` sem campo | D) `photoPosition` em `cardPhotoTransform.ts` (importado por `cardModels`).
|
||||
Recomendação: A — uma definição só, no vocabulário do catálogo; `cardPhotoTransform.ts` importa o tipo **type-only** na direção que já existe (`cardPhotoTransform` → `cardModels`, como `CardRect`), sem ciclo; `cardModels.unit.spec.ts` pina que só os dois modelos do time são `'free'`.
|
||||
Rejeitadas: B — flag assimétrica ("free quando setado") esconde o valor default; C — regra duplicada em composer/render e invisível no catálogo; D — inverte a direção de dependência e cria ciclo type-only desnecessário com madge/knip.
|
||||
|
||||
2. **API do clamp: opção `position` no dono; `free` clampa só o zoom.**
|
||||
Opções: A) `CardPhotoClampOptions = { position?: CardPhotoPosition }` como último parâmetro de `clampCardPhotoTransform` (`cardPhotoTransform.ts:76-91`), `cardPhotoDrawRect` (`:60-74`) e `panCardPhotoTransform` (`:153-164`), e como 6º parâmetro de `zoomCardPhotoTransform` (`:170-194`; o `anchor` segue posicional no 5º — o objeto de options do S18 não existe mais desde o revert `48e08bf7`, e manter a posição evita editar os pins default); `position: 'free'` devolve `offsetX/offsetY` intactos e clampa `zoom` em `[1,4]` | B) booleano posicional `free?: boolean` | C) gêmeas `panCardPhotoTransformFree`/`cardPhotoDrawRectFree` | D) flag dentro do `CardPhotoTransform`.
|
||||
Recomendação: A — a política fica nomeada no call site (`{ position: 'free' }`), o default omitido é o comportamento de hoje byte a byte, e o clamp continua idempotente (`clamp(clamp(t)) = clamp(t)`) porque `free` só normaliza o zoom. `frameCardPhotoOnBbox` (`:115-150`) fica intocado e bounded.
|
||||
Rejeitadas: B — `pan` chegaria a 6º posicional e o call site não nomeia a política; C — gêmeas rasas que duplicam a régua de zoom e criam superfície morta no knip; D — o transform é o dado que o composer guarda e compara (`cardPhotoTransformsEqual`), embutir política nele polui o contrato do write-back.
|
||||
|
||||
3. **Zoom com posição livre: clamp de zoom sempre, re-clamp de posição nunca — inclusive no caminho "zoom inalterado".**
|
||||
Opções: A) em `zoomCardPhotoTransform`, clampa `nextZoom` a `[1,4]`, preserva a âncora e passa `position` adiante; o early-return `zoom === transform.zoom` (`:181`) também respeita `position` (free = posição intacta) | B) manter o re-clamp no early-return | C) liberar o zoom também.
|
||||
Recomendação: A — o early-return é um ponto silencioso de snap-back: tocar o slider sem mudar o zoom desfaria a posição livre; com free ele devolve a posição intacta (só o zoom normalizado). O zoom continua `[1,4]` em todos os modelos, como o aceite manda.
|
||||
Rejeitadas: B — é exatamente o "voltar sozinho" que o aceite proíbe; C — viola o aceite ("os limites do zoom seguem iguais").
|
||||
|
||||
4. **Render: `renderTeamCard` desenha e devolve com a mesma política lida do `model`; `renderPhotoCard` intocado.**
|
||||
Opções: A) `renderTeamCard` (`cardRender.ts:303-336`) deriva `position = model.photoPosition ?? 'bounded'` e usa no `cardPhotoDrawRect` (`:312`) e no retorno (`:314`); `renderPhotoCard` (`:224-233`) segue chamando as funções sem options (bounded) | B) `renderTeamCard` devolve o transform cru, sem normalizar o zoom | C) o composer passa o transform já livre e o render não clampa.
|
||||
Recomendação: A — o retorno continua normalizando o zoom (contrato do write-back) mas nunca re-limita a posição; desenho e retorno com o **mesmo** contexto derivado do mesmo `model`, então o bug de divergência não volta; `renderPhotoCard` não recebe modelo e permanece bounded por default.
|
||||
Rejeitadas: B — perde a normalização de zoom que o contrato do render sempre teve; C — o render deixa de ser auto-suficiente (um futuro chamador desenharia diferente do que devolve).
|
||||
|
||||
5. **Composer: uma constante de política, usada nos três funis de gesto.**
|
||||
Opções: A) `const photoPosition = model.photoPosition ?? 'bounded'` no `CardComposer` e uso nos closures de `handlePointerMove` (`:556-558`), `panBy` (`:565-569`) e `zoomTo` (`:571-575`); nenhum estado/UI/copy novos | B) cada call site decide | C) mudar `withTransform` para injetar a opção.
|
||||
Recomendação: A — `panBy` já serve setas e teclado (`:832-848`), então os três funis fecham sobre a mesma constante; `withTransform` fica intocado exceto os closures; diff mínimo para o rebase do `#1277`.
|
||||
Rejeitadas: B — divergência silenciosa entre gestos (um gesto limitado e outro livre); C — muda a assinatura de um helper interno sem ganho.
|
||||
|
||||
6. **Divergência registrada — "os 3 modelos anteriores ficam intocados": a superfície S13/S14 não muda em nada.**
|
||||
Opções: A) garantia por construção: defaults omitidos nas funções do dono, `renderPhotoCard` sem options, `centerCardPhotoTransform` intocado, `handlePhotoFile` intocado, catálogo sem `photoPosition` nos modelos de foto; **nenhum spec S13/S14 editado** (se algum precisar de edição, a entrega parou) | B) atualizar os testes S13/S14 "se necessário" | C) unificar os dois renderizadores com a opção nos dois.
|
||||
Recomendação: A — o aceite "intocados" vira uma checagem objetiva: os describes de `clampCardPhotoTransform`/`pan`/`zoom` default, o `renderPhotoCard` e o e2e S14 (`tests/e2e/frontend.e2e.spec.ts:1767`) passam **sem edição**. Só os pins do time mudam (eles pinam o comportamento antigo do time, não o dos 3 anteriores).
|
||||
Rejeitadas: B — editar asserção de modelo anterior é o próprio sinal de regressão; C — "simetria" sem caso de uso que mudaria o desenho do S13/S14 (foto sempre cobrindo a janela).
|
||||
|
||||
7. **Testes de domínio: atualizar os pins do time, adicionar o caminho livre nos dois eixos e manter os pins dos anteriores.**
|
||||
Opções: A) `tests/unit/cardPhotoTransform.unit.spec.ts`: manter `clampCardPhotoTransform` (`:116-142`), `pan` (`:144-155`), `zoom` (`:157-184`) e `frameCardPhotoOnBbox` (`:58-114`) **sem edição**; novo describe do free: offsets preservados nos dois eixos, zoom ainda `[1,4]`, `zoom` inalterado preserva posição (anti-snap-back), idempotência; `tests/unit/cardRender.unit.spec.ts`: atualizar o pin do retorno do `renderTeamCard` (`:290-310`) para "zoom clampado, posição preservada" e adicionar um pin bounded do `renderPhotoCard` (transform fora da janela volta clampado e o retângulo cobre a janela); `tests/unit/cardModels.unit.spec.ts`: pin do `photoPosition` (`'free'` nos dois do time, `undefined` nos demais) | B) apagar os pins antigos do clamp e reescrever tudo | C) só unit, sem catálogo.
|
||||
Recomendação: A — os pins default continuam sendo a cobertura dos 3 anteriores; os novos cobrem X **e** Y livres, a faixa de zoom e o anti-snap-back do caminho de zoom; o pin de catálogo torna a política visível e testável.
|
||||
Rejeitadas: B — apagaria a cobertura dos modelos anteriores; C — a política ficaria sem pin de dados.
|
||||
|
||||
8. **e2e: um teste novo, no seam que já existe — decidido adicionar.**
|
||||
Opções: A) novo describe `Cards personalizados (S33 — ajuste livre do time)` com 1 teste no `frontend.e2e.spec.ts`, usando o stub existente + teclado + sonda de pixel: com stub `ok` e harmonização desligada, a sonda `(500,727)` é azul `[30,120,200,255]` no enquadramento S15; focar o grupo `Ajuste da foto`, 8× `Shift+ArrowRight` (48px de card cada → +384, contra o teto antigo de X do stub: `offsetX` inicial ≈197,33, faixa antiga `[108,67, 286]`), poll da sonda ≠ azul (a foto saiu da janela e a arte de trás aparece), esperar 250ms e conferir que **continua** ≠ azul (sem snap-back do write-back), slider de zoom ainda 1 | B) nenhum e2e novo | C) medir o curso em pixels de card com um seam novo.
|
||||
Recomendação: A — o outcome é comportamento de browser e a fiação composer↔render não tem unit (o `CardComposer` é um client component grande, com `#1277` pendente); o teste usa só seams existentes (stub, handler de teclado, sonda) e pina exatamente as duas falhas reais do histórico: o limite que persiste e o snap-back. O manifest `scripts/lib/e2e-affected-manifest.mjs:189-196` já mapeia `src/components/cards`/`src/lib/card` → `frontend`, sem edição.
|
||||
Rejeitadas: B — deixaria a fiação (3 funis de gesto + write-back) sem cobertura de integração; C — frágil, mediria a geometria do stub e exigiria seam novo (e o unit já pina a régua).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CardPhotoPosition` + `CardModel.photoPosition`** (`src/lib/cardModels.ts`): união `'bounded' | 'free'` e campo opcional documentado; `'free'` só em `time-de-voce` e `time-do-estadual`; omitido = bounded (S13/S14, nome, colinha).
|
||||
- **`CardPhotoClampOptions` + `position`** (`src/lib/cardPhotoTransform.ts`): `clampCardPhotoTransform`, `cardPhotoDrawRect` e `panCardPhotoTransform` ganham o último parâmetro opcional; `zoomCardPhotoTransform` ganha o `options` no 6º parâmetro (o `anchor` permanece posicional no 5º — o objeto de options do S18 não voltou no revert); free = offsets intactos, zoom sempre `[1,4]`; early-return do zoom respeita `position`. `frameCardPhotoOnBbox` e `centerCardPhotoTransform` intocados.
|
||||
- **`renderTeamCard`** (`src/lib/cardRender.ts:303-336`): deriva `position` do `model` e usa no draw (`:312`) e no retorno (`:314`); `renderPhotoCard` (`:224-233`) intocado.
|
||||
- **`CardComposer`** (`src/components/cards/CardComposer.tsx`): `photoPosition` derivado do `model` e passado nos closures de arrasto (`:556-558`), `panBy` (`:565-569`) e `zoomTo` (`:571-575`). Nenhuma mudança de UI, estado, copy ou erro; `handleDownload` (`:584`) segue `toBlob` do canvas pintado.
|
||||
- **Testes:** `tests/unit/cardPhotoTransform.unit.spec.ts` (novo describe do free; pins default sem edição), `tests/unit/cardRender.unit.spec.ts` (pin do retorno do time atualizado + pin bounded do `renderPhotoCard`), `tests/unit/cardModels.unit.spec.ts` (pin do `photoPosition`), `tests/e2e/frontend.e2e.spec.ts` (novo describe S33; S14/S15/S30/S31 sem edição).
|
||||
- **`docs/changelog/2026-09-24-s33-card-time-ajuste-livre.md`** (novo, additions-only).
|
||||
- **Migration:** sem migration — nenhuma collection/global/field; catálogo estático; `push:false` intocado (100% client-side).
|
||||
- **Access / Consent:** não se aplica — nenhuma superfície Payload; a posição é geometria efêmera no aparelho; sem chave de Consent.
|
||||
- **UI:** Impeccable C — comportamento, sem controle/copy/telemetria novos. O artefato `docs/plans/cards-time-ajuste-sem-limite-ui-design.html` é a referência do gate (Cenas 1/2 e a anotação "Posição: sem clamp em X ou Y"); craft no browser com fotos reais (arrasto até fora do quadro nos dois modelos, voltar, zoom antes/depois, preview×PNG). 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 (janela `{286,439,592,577}`) e a posição efêmera no aparelho, como a intenção fixou.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — régua pura do dono** — quota ~0,25 dia. `CardPhotoPosition`/`photoPosition` no catálogo; `CardPhotoClampOptions.position` em `clampCardPhotoTransform`/`cardPhotoDrawRect`/`panCardPhotoTransform`/`zoomCardPhotoTransform`; units novos (free X/Y, zoom `[1,4]`, anti-snap-back do zoom inalterado, idempotência) e pin do catálogo.
|
||||
Prova: `pnpm gate:fast` verde; os describes default de clamp/pan/zoom/frame e o `cardModels.unit.spec.ts` pré-existente seguem valendo sem edição; unit pina que `free` preserva offsets fora da janela nos dois eixos e que `bounded` (omitido) é byte a byte o de hoje.
|
||||
2. **Render + composer + e2e** — quota ~0,2 dia. `renderTeamCard` lendo o `model` no desenho e no retorno; `photoPosition` nos 3 funis do composer; pin do retorno do time atualizado; pin bounded do `renderPhotoCard`; teste e2e S33.
|
||||
Prova: `pnpm gate:fast` verde; unit do render (time devolve zoom clampado com posição livre; `renderPhotoCard` clampa e cobre a janela); e2e local do `frontend` verde, com S14/S15/S30/S31 sem edição.
|
||||
3. **Gates + changelog** — quota ~0,05 dia. e2e da superfície (`pnpm test:e2e:affected` ou `pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts`), changelog, `pnpm gate:fast`, `pnpm push`.
|
||||
Prova: e2e S15 (`:1998`/`:2177`) e S30 (`:2227`) verdes sem edição (enquadramento inicial, harmonização, troca de estadual e download intactos); novo S33 verde; fixture de console/5xx intacta; PNG baixável.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Ressuscitar âncora/rosto/união do S20 (`resolveCardPhotoAnchor`, `anchorBox`, `frameCardPhotoOnFace`, detector): o gate decidiu sem limite e o revert `48e08bf7` removeu por inércia sob o S15; não voltam nem como "proteção".
|
||||
- Limite novo por composição: margem medida, colisão com caixas de candidatos, "não deixar cobrir rostos" — a barreira aprovada é **nenhuma**.
|
||||
- Clipar a foto na janela, manter cover mínimo ou impedir a foto de sair do quadro — contraria o aceite e a Cena 2 do gate.
|
||||
- Controles novos ("centralizar", "voltar ao enquadramento"), copy nova, estado de erro novo, telemetria de foto/posição, persistência.
|
||||
- Mexer no zoom (`[1,4]`), no enquadramento S15/`frameCardPhotoOnBbox`, na janela `{286,439,592,577}`, na arte-mestre ou no `centerCardPhotoTransform`.
|
||||
- Aplicar free a S13/S14 "por simetria", tornar a opção obrigatória, criar funções gêmeas ou mudar `renderPhotoCard`.
|
||||
- e2e medindo o curso em pixels de card (seam novo) ou novos modos de stub; unit de `CardComposer` (sem harness de React no repo para ele).
|
||||
- Fatiar o `CardComposer` (`#1277`) nesta entrega; migration/int/Consent/access.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Snap-back do write-back** → política única lida do catálogo nos dois pontos (composer e `renderTeamCard`), desenho e retorno com o mesmo contexto; unit do retorno do render; e2e S33 pina a persistência após o pan.
|
||||
- **Zoom inalterado re-clampa a posição** → branch explícito no early-return de `zoomCardPhotoTransform`; unit "zoom inalterado preserva a posição livre".
|
||||
- **Deriva de S13/S14** → defaults omitidos; `renderPhotoCard`/`centerCardPhotoTransform`/`handlePhotoFile` sem edição; specs S13/S14 e e2e S14 **sem edição** (se algum pedir edição, parar e reavaliar).
|
||||
- **Foto totalmente fora do quadro** → `drawImage` aceita coordenadas fora do canvas; o canvas tem tamanho fixo e o PNG é o mesmo canvas; craft nos extremos (foto fora à direita/esquerda/cima/baixo) com fotos reais; sem estado de erro novo.
|
||||
- **Conflito com `#1277`** (CardComposer DRY, OPEN/blocked) → diff mínimo (1 constante + 3 closures); rebase antes de abrir o PR; sem tocar em `withTransform`/estado.
|
||||
- **Knip/cycles** → tipo novo importado type-only na direção existente `cardPhotoTransform` → `cardModels`; opção consumida em composer/render/testes; sem export órfão.
|
||||
- **e2e instável** → sonda e contagem de passos calculadas sobre o stub determinístico (4×3 → 400×300; S15: `offsetX≈197,33`, teto antigo 286; 8× `Shift+→` = +384 → 581,33; base em `(500,727)` = `[246,246,248]`); poll + espera de 250ms; sem rede nova.
|
||||
- **A11y/UX** → nenhum controle, copy ou área de toque mudam; foco/teclado/touch seguem iguais; a área de arrasto continua ativa mesmo sem a pessoa visível.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: posição livre X+Y nos dois modelos do time (inclusive fora do quadro), S15 como ponto de partida, zoom `[1,4]`, preview = PNG sem snap-back, S13/S14 e colinha intocados, sem controle/copy/telemetria, 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; `pnpm gate:fast` + e2e da superfície + `pnpm push`.
|
||||
- [ ] Testes de domínio previstos: unit do free nos dois eixos + zoom `[1,4]` + anti-snap-back + idempotência; pin do `photoPosition` no catálogo; pin do retorno do `renderTeamCard` e do `renderPhotoCard` bounded; e2e S33 novo; **specs S13/S14 e e2e S14 sem edição**; **int não se aplica** (client-side, sem DB/access/escrita multi-collection).
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- JSDoc do `panCardPhotoTransform`/`zoomCardPhotoTransform` e o doc de `photoWindow`/cabeçalho do catálogo atualizados para o mundo com `free` (achados convergentes dos 2 revisores).
|
||||
- Comentário do pin de catálogo corrigido ("S13/S14 e a colinha" — 4 asserts) e as decisões 1/2 realinhadas ao código (o options object do S18 não existe mais desde o revert `48e08bf7`; o `anchor` segue posicional).
|
||||
- e2e da sonda endurecido: a asserção é a cor exata da arte de trás (`[246,246,248,255]`), não um "≠ azul" que um canvas em branco também passaria.
|
||||
|
||||
## Explicitamente fora / deferido com gatilho (triage)
|
||||
|
||||
- **S1 — `undefined` explícito no 5º parâmetro de `zoomCardPhotoTransform` no composer**: score 2, pureza; mover o `anchor` para um bag de options quebraria os pins default S13/S15 (a prova "sem edição") por ganho cosmético. **Descartado**.
|
||||
- **S2 — `windowPixel` (S33) duplica `windowCenterPixel` (S30) no e2e**: 2 call sites; DRY <3 é rabbit hole do repo e içar o helper editaria o describe S30 (a prova "sem edição"). **Descartado**.
|
||||
- **S3 — e2e cobre só o teclado e só `time-de-voce`**: **defer com gatilho** — o próximo item que mexer no funil de cards acrescenta o e2e de arrasto (pointer) e/ou do `time-do-estadual`; os três gestos compartilham `panCardPhotoTransform` e a política do modelo tem pins de unit/catálogo.
|
||||
- **S4 — renames `CardPhotoClampOptions`/`CardPhotoPosition`**: score 1, cosmético; **descartado**.
|
||||
- **S5 — trocar o `waitForTimeout(250)` do e2e por estabilidade de rAF**: score 1; o teste discrimina e passa (a espera pega a variante draw-free/write-back-bounded). **Descartado**.
|
||||
- **Fora do lote — flake unit do `municipalityRelationEditor.unit.spec.tsx` ("Salvando relações.")**: falhou 2× em 3 runs full sob carga e passa isolado; pré-existente, sem Issue específica aberta (as de flake abertas são e2e/int) e fora da superfície desta entrega. Registrado no resumo da sessão para o humano decidir virar bug à parte.
|
||||
|
||||
## Self-score decision-quality: 5/5
|
||||
|
||||
Decisões caras (onde vive a política, API do clamp, semântica do zoom, render/write-back, garantia S13/S14, testes e e2e) todas com Opções/Recomendação/Rejeitadas; cabe no appetite (~0,5 dia, sem migration); rabbit holes nomeados (S20, limites novos, controles, zoom, seam); depth check reusa o dono (`cardPhotoTransform`), o catálogo e o `renderTeamCard` sem abstração nova; o outcome da intenção permanece intacto — a engenharia só escolheu a forma.
|
||||
|
|
@ -219,6 +219,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const zoomInputId = useId()
|
||||
|
||||
const photoWindow = model.photoWindow
|
||||
/** S33 — the model's adjustment policy: the two team models are position-free. */
|
||||
const photoPosition = model.photoPosition ?? 'bounded'
|
||||
const isNameModel = model.kind === 'name'
|
||||
const isTeamModel = model.kind === 'team'
|
||||
const isColinhaModel = model.kind === 'colinha'
|
||||
|
|
@ -554,7 +556,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),
|
||||
panCardPhotoTransform(transform, size, window, dx, dy, { position: photoPosition }),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -564,13 +566,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),
|
||||
panCardPhotoTransform(transform, size, window, dx, dy, { position: photoPosition }),
|
||||
)
|
||||
}
|
||||
|
||||
const zoomTo = (zoom: number) => {
|
||||
withTransform((transform, size, window) =>
|
||||
zoomCardPhotoTransform(transform, size, window, zoom),
|
||||
zoomCardPhotoTransform(transform, size, window, zoom, undefined, { position: photoPosition }),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* the home section, the `/cards` gallery and the canvas renderer share one
|
||||
* catalog (no second editor, no CMS): each entry carries the master asset, the
|
||||
* real output size in pixels and, for photo/team models, the transparent window
|
||||
* the visitor's photo must cover.
|
||||
* the photo is framed and zoomed over (fully covered under the bounded default).
|
||||
*
|
||||
* Geometry comes from the master files (pixel-measured, keep in sync with
|
||||
* `docs/plans/cards-personalizados-campanha-impl.md` and
|
||||
|
|
@ -30,6 +30,15 @@ export type CardRect = {
|
|||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* S33 — the visitor-adjustment policy of a photo/team model. `bounded` (the
|
||||
* omitted default) pins the photo offsets so the transparent window is always
|
||||
* covered (S13/S14); `free` leaves the offsets untouched in both axes — the
|
||||
* photo may leave the frame and reveal the art behind it — while the zoom keeps
|
||||
* its `[1, 4]` range (human gate of 2026-09-24).
|
||||
*/
|
||||
export type CardPhotoPosition = 'bounded' | 'free'
|
||||
|
||||
export type CardModel = {
|
||||
id: CardModelId
|
||||
kind: CardModelKind
|
||||
|
|
@ -37,8 +46,10 @@ export type CardModel = {
|
|||
assetSrc: string
|
||||
width: number
|
||||
height: number
|
||||
/** Photo and team models: the transparent window the photo must cover. */
|
||||
/** Photo and team models: the transparent window the photo is framed over. */
|
||||
photoWindow?: CardRect
|
||||
/** Photo/team models: `free` unlocks the position clamp during the adjustment. */
|
||||
photoPosition?: CardPhotoPosition
|
||||
/** Team model: the master front overlay drawn above the cutout photo. */
|
||||
overlaySrc?: string
|
||||
/**
|
||||
|
|
@ -99,6 +110,7 @@ export const CARD_MODELS: readonly CardModel[] = [
|
|||
width: 1080,
|
||||
height: 1440,
|
||||
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
|
||||
photoPosition: 'free',
|
||||
},
|
||||
{
|
||||
id: 'time-do-estadual',
|
||||
|
|
@ -115,6 +127,7 @@ export const CARD_MODELS: readonly CardModel[] = [
|
|||
width: 1080,
|
||||
height: 1440,
|
||||
photoWindow: { x: 286, y: 439, width: 592, height: 577 },
|
||||
photoPosition: 'free',
|
||||
},
|
||||
{
|
||||
id: 'minha-colinha',
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
/**
|
||||
* S13 — pure cover/pan/zoom math for the photo card models. The visitor photo
|
||||
* is drawn full-bleed under the master overlay and must always cover the
|
||||
* transparent window: `clampCardPhotoTransform` keeps the zoom inside
|
||||
* is drawn full-bleed under the master overlay and, by default, must always
|
||||
* cover the transparent window: `clampCardPhotoTransform` keeps the zoom inside
|
||||
* [1, 4] and pins the offsets so no gap can open at the window edges.
|
||||
*
|
||||
* S33 — the two team models carry `photoPosition: 'free'` (human gate of
|
||||
* 2026-09-24): their adjustment is not clamped in any axis — the photo may
|
||||
* leave the frame — while the zoom keeps its range. The policy arrives through
|
||||
* `CardPhotoClampOptions`; the omitted default is the S13 bounded behavior.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
|
||||
import type { CardRect } from './cardModels'
|
||||
import type { CardPhotoPosition, CardRect } from './cardModels'
|
||||
|
||||
export const CARD_PHOTO_MIN_ZOOM = 1
|
||||
export const CARD_PHOTO_MAX_ZOOM = 4
|
||||
|
|
@ -37,6 +42,15 @@ export type CardPhotoTransform = {
|
|||
offsetY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* S33 — the adjustment policy of the call site: `bounded` (omitted default)
|
||||
* pins the offsets so the photo covers the window; `free` keeps the offsets
|
||||
* untouched in both axes, normalizing only the zoom.
|
||||
*/
|
||||
export type CardPhotoClampOptions = {
|
||||
position?: CardPhotoPosition
|
||||
}
|
||||
|
||||
/** Alpha bounding box of a cutout photo, in source pixels. */
|
||||
export type CardAlphaBbox = {
|
||||
x: number
|
||||
|
|
@ -61,8 +75,9 @@ export const cardPhotoDrawRect = (
|
|||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
options: CardPhotoClampOptions = {},
|
||||
): CardRect => {
|
||||
const clamped = clampCardPhotoTransform(transform, source, window)
|
||||
const clamped = clampCardPhotoTransform(transform, source, window, options)
|
||||
const scale = coverScale(source, window) * clamped.zoom
|
||||
|
||||
return {
|
||||
|
|
@ -77,8 +92,13 @@ export const clampCardPhotoTransform = (
|
|||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
options: CardPhotoClampOptions = {},
|
||||
): CardPhotoTransform => {
|
||||
const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
|
||||
if (options.position === 'free') {
|
||||
return { zoom, offsetX: transform.offsetX, offsetY: transform.offsetY }
|
||||
}
|
||||
|
||||
const scale = coverScale(source, window) * zoom
|
||||
const width = source.width * scale
|
||||
const height = source.height * scale
|
||||
|
|
@ -149,23 +169,26 @@ export const frameCardPhotoOnBbox = (
|
|||
)
|
||||
}
|
||||
|
||||
/** Nudges the offset by a step in card pixels, clamped to the window. */
|
||||
/** Nudges the offset by a step in card pixels; clamped to the window by default. */
|
||||
export const panCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
dx: number,
|
||||
dy: number,
|
||||
options: CardPhotoClampOptions = {},
|
||||
): CardPhotoTransform =>
|
||||
clampCardPhotoTransform(
|
||||
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
|
||||
source,
|
||||
window,
|
||||
options,
|
||||
)
|
||||
|
||||
/**
|
||||
* Rescales around a window anchor (used by the zoom control): keeps the point
|
||||
* under `anchor` stable from old to new zoom, then clamps.
|
||||
* under `anchor` stable from old to new zoom, then clamps — under the free
|
||||
* policy only the zoom is normalized.
|
||||
*/
|
||||
export const zoomCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
|
|
@ -176,9 +199,10 @@ export const zoomCardPhotoTransform = (
|
|||
x: window.x + window.width / 2,
|
||||
y: window.y + window.height / 2,
|
||||
},
|
||||
options: CardPhotoClampOptions = {},
|
||||
): CardPhotoTransform => {
|
||||
const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
|
||||
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window)
|
||||
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window, options)
|
||||
|
||||
const ratio = zoom / transform.zoom
|
||||
|
||||
|
|
@ -190,5 +214,6 @@ export const zoomCardPhotoTransform = (
|
|||
},
|
||||
source,
|
||||
window,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,7 +289,11 @@ export type TeamCardRenderArgs = {
|
|||
|
||||
export type TeamCardRenderResult = {
|
||||
fit: CardNameFit
|
||||
/** The clamped photo transform; `null` for the silhouette subject. */
|
||||
/**
|
||||
* The normalized photo transform (zoom always in range; S33 — the offsets
|
||||
* follow the model's `photoPosition`, so a `free` model keeps them as sent);
|
||||
* `null` for the silhouette subject.
|
||||
*/
|
||||
transform: CardPhotoTransform | null
|
||||
}
|
||||
|
||||
|
|
@ -309,9 +313,23 @@ export const renderTeamCard = (
|
|||
|
||||
let transform: CardPhotoTransform | null = null
|
||||
if (args.subject.kind === 'photo') {
|
||||
const rect = cardPhotoDrawRect(args.subject.transform, args.subject.photoSize, args.window)
|
||||
// S33 — the drawing and the returned transform follow the same policy read
|
||||
// from the model: a `free` team model never re-clamps the visitor position
|
||||
// (that would snap the photo back on the next paint).
|
||||
const options = { position: model.photoPosition ?? 'bounded' }
|
||||
const rect = cardPhotoDrawRect(
|
||||
args.subject.transform,
|
||||
args.subject.photoSize,
|
||||
args.window,
|
||||
options,
|
||||
)
|
||||
ctx.drawImage(args.subject.photo, rect.x, rect.y, rect.width, rect.height)
|
||||
transform = clampCardPhotoTransform(args.subject.transform, args.subject.photoSize, args.window)
|
||||
transform = clampCardPhotoTransform(
|
||||
args.subject.transform,
|
||||
args.subject.photoSize,
|
||||
args.window,
|
||||
options,
|
||||
)
|
||||
} else {
|
||||
drawCardVisitorSilhouette(ctx, args.window)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2467,3 +2467,59 @@ test.describe('Cards personalizados (S31 — Minha colinha)', () => {
|
|||
await expect(dialog.getByRole('button', { name: 'Baixar minha colinha' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* S33 — the two team models are position-free (human gate of 2026-09-24): the
|
||||
* visitor adjustment is not clamped in any axis, so the photo may leave the
|
||||
* window and reveal the art behind it. The S15 auto-framing stays the starting
|
||||
* point and the zoom keeps its [1, 4] range. With the stub cutout (400×300,
|
||||
* full-canvas bbox) the S15 transform lands at offsetX ≈ 197.33, whose old X
|
||||
* ceiling was 286 — eight Shift+→ steps (+48 each = +384) only pass the window
|
||||
* with the clamp gone, and the write-back must never snap it back.
|
||||
*/
|
||||
test.describe('Cards personalizados (S33 — ajuste livre do time)', () => {
|
||||
const windowPixel = (canvas: Locator, x: number, y: number) =>
|
||||
canvas.evaluate(
|
||||
(element, point) => {
|
||||
const node = element as HTMLCanvasElement
|
||||
return [...node.getContext('2d')!.getImageData(point.x, point.y, 1, 1).data]
|
||||
},
|
||||
{ x, y },
|
||||
)
|
||||
|
||||
test('the keyboard moves the photo past the window and it never snaps back', async ({ page }) => {
|
||||
await setCutoutStub(page, 'ok')
|
||||
await page.goto('/cards?model=time-de-voce')
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria')
|
||||
await uploadBustPhoto(dialog)
|
||||
await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible()
|
||||
|
||||
// Harmony OFF reads the raw stub fixture: under the S15 framing the photo
|
||||
// covers x=500, so the probe sees the fixture blue.
|
||||
await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click()
|
||||
const canvas = dialog.locator('canvas')
|
||||
const probe = () => windowPixel(canvas, 500, 727)
|
||||
await expect.poll(probe).toEqual([30, 120, 200, 255])
|
||||
|
||||
// Eight Shift+→ steps = +384 card pixels — past the old ceiling (197.33 +
|
||||
// 88.67): the photo leaves the window and the art behind it shows there.
|
||||
const group = dialog.getByRole('group', { name: 'Ajuste da foto' })
|
||||
await group.focus()
|
||||
for (let step = 0; step < 8; step += 1) await page.keyboard.press('Shift+ArrowRight')
|
||||
|
||||
// The exact base-art pixel at the probe: stronger than "not the fixture
|
||||
// blue" (a blank or broken canvas would not pass it).
|
||||
const baseArtPixel = [246, 246, 248, 255]
|
||||
await expect.poll(probe).toEqual(baseArtPixel)
|
||||
|
||||
// No snap-back: the render write-back keeps the free position, so the moved
|
||||
// frame stays painted after the effect settles.
|
||||
await page.waitForTimeout(250)
|
||||
expect(await probe()).toEqual(baseArtPixel)
|
||||
|
||||
// The freedom is of the position only: the zoom stays at the cover floor.
|
||||
expect(Number(await dialog.locator('input[type="range"]').inputValue())).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ describe('card model catalog (S13/S15)', () => {
|
|||
expect(getCardModel('time-de-voce')?.stateDeputyPicker).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks only the two team models as free position (S33)', () => {
|
||||
expect(getCardModel('time-de-voce')?.photoPosition).toBe('free')
|
||||
expect(getCardModel('time-do-estadual')?.photoPosition).toBe('free')
|
||||
// S13/S14 and the colinha keep the bounded default (omitted).
|
||||
expect(getCardModel('eu-sou-solla')?.photoPosition).toBeUndefined()
|
||||
expect(getCardModel('perfil-quadrado')?.photoPosition).toBeUndefined()
|
||||
expect(getCardModel('perfil-retangular')?.photoPosition).toBeUndefined()
|
||||
expect(getCardModel('minha-colinha')?.photoPosition).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pins the S31 colinha model: new kind, official top assets, picker flag and the approved tile', () => {
|
||||
expect(getCardModel('minha-colinha')).toMatchObject({
|
||||
kind: 'colinha',
|
||||
|
|
|
|||
|
|
@ -183,3 +183,64 @@ describe('zoomCardPhotoTransform', () => {
|
|||
expect(same).toEqual(start)
|
||||
})
|
||||
})
|
||||
|
||||
describe('free position (S33 — position: free)', () => {
|
||||
const outside = { zoom: 1, offsetX: 9999, offsetY: -9999 }
|
||||
|
||||
it('keeps the offsets outside the window and still clamps the zoom to [1, 4]', () => {
|
||||
expect(
|
||||
clampCardPhotoTransform({ ...outside, zoom: 9 }, LANDSCAPE, WINDOW, { position: 'free' }),
|
||||
).toEqual({ ...outside, zoom: CARD_PHOTO_MAX_ZOOM })
|
||||
expect(
|
||||
clampCardPhotoTransform({ ...outside, zoom: 0.2 }, LANDSCAPE, WINDOW, { position: 'free' }),
|
||||
).toEqual({ ...outside, zoom: CARD_PHOTO_MIN_ZOOM })
|
||||
// Idempotent: a settled free transform never drifts on a second pass.
|
||||
const free = clampCardPhotoTransform(outside, LANDSCAPE, WINDOW, { position: 'free' })
|
||||
expect(clampCardPhotoTransform(free, LANDSCAPE, WINDOW, { position: 'free' })).toEqual(free)
|
||||
})
|
||||
|
||||
it('draws the photo at the sent offsets, only the cover scale applied', () => {
|
||||
expect(cardPhotoDrawRect(outside, LANDSCAPE, WINDOW, { position: 'free' })).toEqual({
|
||||
x: 9999,
|
||||
y: -9999,
|
||||
width: 1480,
|
||||
height: 740,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets the pan leave the window on both axes', () => {
|
||||
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
||||
const panned = panCardPhotoTransform(start, LANDSCAPE, WINDOW, 9999, 9999, {
|
||||
position: 'free',
|
||||
})
|
||||
|
||||
expect(panned).toEqual({ zoom: 1, offsetX: 9759, offsetY: 9999 })
|
||||
})
|
||||
|
||||
it('keeps the free position when the zoom does not change (anti snap-back)', () => {
|
||||
// The bounded default pulls the photo back to the window (S13/S14)…
|
||||
expect(zoomCardPhotoTransform(outside, LANDSCAPE, WINDOW, outside.zoom)).toEqual({
|
||||
zoom: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
})
|
||||
// …while the free team policy normalizes only the zoom.
|
||||
expect(
|
||||
zoomCardPhotoTransform(outside, LANDSCAPE, WINDOW, outside.zoom, undefined, {
|
||||
position: 'free',
|
||||
}),
|
||||
).toEqual(outside)
|
||||
})
|
||||
|
||||
it('clamps the zoom but never pulls the free position back', () => {
|
||||
const zoomed = zoomCardPhotoTransform(outside, LANDSCAPE, WINDOW, 99, undefined, {
|
||||
position: 'free',
|
||||
})
|
||||
|
||||
expect(zoomed).toEqual({
|
||||
zoom: CARD_PHOTO_MAX_ZOOM,
|
||||
offsetX: 500 - (500 - 9999) * CARD_PHOTO_MAX_ZOOM,
|
||||
offsetY: 370 - (370 + 9999) * CARD_PHOTO_MAX_ZOOM,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -287,12 +287,14 @@ describe('renderTeamCard', () => {
|
|||
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE'])
|
||||
})
|
||||
|
||||
it('returns the clamped photo transform for the photo subject', () => {
|
||||
it('returns the photo transform with the zoom clamped and the free position kept (S33)', () => {
|
||||
const { result, photo } = render('Maria')
|
||||
|
||||
expect(result.transform).toEqual(transform)
|
||||
|
||||
// A transform outside the cover floor comes back clamped, never as sent.
|
||||
// S33 — `time-de-voce` is `free`: the zoom still clamps to [1, 4] but the
|
||||
// position comes back exactly as sent; a re-clamp here would snap the photo
|
||||
// back on the next paint (drawing and return share the model's policy).
|
||||
const fake = createFakeContext()
|
||||
const outOfBounds = { zoom: 0.5, offsetX: 10_000, offsetY: -10_000 }
|
||||
const clamped = renderTeamCard(fake.ctx, teamModel, {
|
||||
|
|
@ -305,8 +307,9 @@ describe('renderTeamCard', () => {
|
|||
measure: createCardMeasure(fake.ctx, 'Brexter'),
|
||||
})
|
||||
|
||||
expect(clamped.transform).not.toEqual(outOfBounds)
|
||||
expect(clamped.transform?.zoom).toBe(1)
|
||||
expect(clamped.transform).toEqual({ zoom: 1, offsetX: 10_000, offsetY: -10_000 })
|
||||
// The drawing follows the same free policy (second drawImage is the photo).
|
||||
expect(fake.drawCalls[1]).toMatchObject({ dx: 10_000, dy: -10_000 })
|
||||
})
|
||||
|
||||
it('draws the visitor silhouette instead of the photo when no cutout is ready (S30)', () => {
|
||||
|
|
@ -452,6 +455,26 @@ describe('renderPhotoCard', () => {
|
|||
expect(drawCalls[0]!.dx + drawCalls[0]!.dw).toBeGreaterThanOrEqual(window.x + window.width)
|
||||
expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height)
|
||||
})
|
||||
|
||||
it('still clamps the visitor adjustment on the bounded photo models (S13/S14)', () => {
|
||||
const { ctx, drawCalls } = createFakeContext()
|
||||
const photo = { id: 'photo' } as unknown as CanvasImageSource
|
||||
const frame = { id: 'frame' } as unknown as CanvasImageSource
|
||||
const size: CardPhotoSize = { width: 2000, height: 1000 }
|
||||
const window: CardRect = squareModel.photoWindow!
|
||||
|
||||
const clamped = renderPhotoCard(ctx, {
|
||||
photo,
|
||||
frame,
|
||||
photoSize: size,
|
||||
frameSize: { width: squareModel.width, height: squareModel.height },
|
||||
window,
|
||||
transform: { zoom: 1, offsetX: 9999, offsetY: 9999 },
|
||||
})
|
||||
|
||||
expect(clamped).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
|
||||
expect(drawCalls[0]).toEqual({ image: photo, dx: 0, dy: 0, dw: 1480, dh: 740 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderColinhaCard (S31)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue