feat(S33): ajuste da foto do card do time sem limite (Closes #1297)

This commit is contained in:
Francisco Solla 2026-09-24 01:15:31 -03:00
parent 133136dda3
commit 9069d23d88
10 changed files with 383 additions and 19 deletions

View 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).

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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