feat(S17): controle "Harmonizar cores" no ready do card Time de você

Ajuste local de brilho/contraste/saturação (clamps fixos ≤12%, alpha byte-a-byte) para aproximar a selfie do tom da arte-mestre; variante gerada 1× no ready do useCardCutout e escolhida pelo toggle no mesmo canvas da prévia e do PNG. Sem migration/Consent/rede/persistência; os 3 modelos e o recorte intocados. Crítica do designer certificada no tier primário (artefato revalidado sem DEGRADED).
This commit is contained in:
Francisco Solla 2026-09-19 10:30:55 -03:00
parent 12698ba4fc
commit 3c8a1f87b4
13 changed files with 850 additions and 28 deletions

View file

@ -20,9 +20,9 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
- `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`.
- `src/utilities/<domínio>/` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`).
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`. Geometria/catálogo puros em `src/lib/card{Models,NameFit,PhotoTransform,Render}.ts`.
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`/`cardCutout.ts`/`cardPhotoHarmonyCanvas.ts` (S17). Geometria/catálogo/tom puros em `src/lib/card{Models,NameFit,PhotoTransform,Render,Harmony}.ts`.
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`).
- `src/migrations/` — história congelada; nunca editar retroativamente.
- `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`).

View file

@ -0,0 +1 @@
**S17 — Card "Time de você": harmonizar as cores da foto do visitante (2026-09-19):** o estado pronto do modelo `time-de-voce` ganha o controle binário "Harmonizar cores" (LIGADO por padrão, `role="switch"` + `aria-checked`, alvo ≥44px, label/ajuda literais, `aria-describedby`), entre "Ajuste fino" e "Trocar foto" — só no ready do time (os 3 modelos existentes e o ramo de nome longo não o mostram). O ajuste é 100% no aparelho e sem persistência: math pura em `src/lib/cardPhotoHarmony.ts` (luma Rec.709 média/desvio + saturação HSV média, só alphas acima de `CARD_CUTOUT_ALPHA_THRESHOLD`; fatores de brilho/contraste/saturação com clamps fixos ≤12%; identidade 1±0,02; passada única preservando o alpha byte-a-byte) aplicada pelo adapter `cardPhotoHarmonyCanvas.ts`, que mede a referência na arte-mestre `team-card-base.png` (cache por imagem) e nunca muta o recorte; `useCardCutout.ready` passa a carregar `canvas` + `harmonized` (computada antes do primeiro paint — o default LIGADO não pisca a original) e o `CardComposer` só escolhe a variante que entra no `renderTeamCard` intocado, então prévia e PNG (mesmo canvas) levam a escolha; desligar devolve a original na hora, "Trocar foto" recomputa, falha de leitura esconde o controle (fail-closed, sem mentir um switch ligado). Sem migration/collection/Consent, sem rede/dependência nova; artes-mestre e recorte intocados e o alpha floor do bbox/amostra unificado em `CARD_CUTOUT_ALPHA_THRESHOLD` (`cardPhotoTransform`). Testes: 22 unit do módulo puro (alpha, clamps, identidade, monotonicidade, determinismo) + e2e novo (default LIGADO com `aria-checked`, alvo ≥44px, troca de bitmap da prévia ao desligar e volta idêntica ao religar por teclado, nota de privacidade visível, ausência no modelo de foto e no nome longo). Design: crítica final do `designer` certificada no tier primário `openai/gpt-5.6-sol` (artefato revalidado sem `DEGRADED`), com os ajustes de fidelidade aplicados — prévia `ready` a 180px no mobile (240px no desktop), superfície do switch sem hover persistente e "Trocar foto" em `mt-3`. Harness: `tests/unit/codebaseConventions.unit.spec.ts` passa a ler `git ls-files -z` — o path não-ASCII `public/campaign-kit/coração.png` (C204) vinha citado e derrubava o `verify` da main com ENOENT (commit separado). Verificação: `pnpm gate:fast` verde (376 arquivos/4066 testes), e2e `frontend` verde (30), screenshots 390/1280 dos estados ON/OFF e download do PNG no caminho harmonizado.

View file

@ -0,0 +1,203 @@
# Impl: Card "Time de você": harmonizar as cores da foto do visitante com o time
Status: aprovado
Atualizado em: 2026-09-19
Issue: #1196
Intenção: docs/plans/cards-time-de-voce-harmonizar-cores.md
Appetite restante: herdado — ~0,5–1 dia eng (3 fases somam ~0,9 dia; sem migration)
## Leitura da intenção
- **Outcome:** no estado pronto do modelo `time-de-voce`, um controle binário único ("Harmonizar cores", LIGADO por padrão) aplica um ajuste suave de brilho/contraste/saturação na foto recortada do visitante para aproximá-la do tom da arte-mestre; desligar devolve a original na hora, e a escolha vale para a prévia e para o PNG baixado — tudo no aparelho, sem persistência.
- **O que NÃO negociar:**
- 100% no aparelho: sem upload/serviço/rede, sem analytics, sem nova dependência; sem migration/collection/CMS/Consent.
- Um único pipeline: a variante harmonizada alimenta o MESMO `renderTeamCard` e o MESMO canvas que a prévia e o download já usam.
- Recorte intocável: nada de pele/forma/imperfeições, nada de matiz, nunca alterar a máscara/alpha nem o framing; os 3 modelos existentes e `renderTeamCard`/`CardDrawContext` ficam intocados.
- Só no `time-de-voce` (gate `isTeamModel`); nota de privacidade existente visível e verdadeira; literais: rótulo `Harmonizar cores`, ajuda `Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.`
- **O que reavaliar (hipóteses da intenção):**
- "Processar no recorte/hook ou no render": confirmado que não é no renderer; a variante nasce no ready do `useCardCutout` (decisão 4), o renderer fica intocado.
- "Medir a base via `baseImage` do composer": o composer não passa a imagem ao hook — o adapter resolve a referência pelo `loadCardImage` já cacheado (`cardCanvas.ts:8-27`), com cache de tom por imagem; evita sincronizar dois ciclos e acoplar o hook a estado de UI.
- "Botão binário estilizado": confirmado que não existe `ui/Switch` e que `ui/Toggle` é radix `aria-pressed`; o port usa `<button role="switch" aria-checked>` nativo (decisão 5).
- **Correções ao achado do explorador (verificadas nos arquivos):**
- O e2e JÁ tem guard automático de `console.error`/`pageerror`/5xx no fixture `tests/e2e/fixtures/e2eTest.ts` (fixture `auto: true`, :100-133) — não é preciso listener novo; o achado "sem assert de console-error" vale só como ausência de assert explícito.
- O `filter: saturate()/brightness()/contrast()` do artefato (:109-113) é o mockup do resultado sobre um placeholder `<svg>`, não o mecanismo do port (o mecanismo é passada de pixels; ver decisão 1).
- `#cfcac7` do trilho desligado não é token do tema (só existe no CSS do artefato) — é literal de port.
- Demais pontos 1–13 e 15–16 conferidos e batem (incluindo :216-229 do unit de render, que continua intocado).
## Abordagem recomendada
```mermaid
flowchart LR
BASE["team-card-base.png"] --> REF["adapter: mede tom de referência (WeakMap por imagem)"]
CUT["cutout canvas (ready)"] --> ADP["adapter cardPhotoHarmonyCanvas"]
REF --> ADP
ADP --> LIB["lib pura cardPhotoHarmony: compute + apply"]
LIB --> VAR["variante harmonizada (canvas novo; original intacto)"]
VAR --> READY["useCardCutout ready: canvas + harmonized"]
READY --> SW["CardComposer: harmonyEnabled (default ON) seleciona a variante"]
SW --> RT["renderTeamCard (intocado)"]
RT --> PREV["prévia"]
RT --> PNG["PNG baixado"]
```
**Opções consideradas:** A) uma passada de pixels pura em `src/lib/` sobre o bitmap do recorte, com adapter DOM que gera a variante harmonizada; o ready do `useCardCutout` carrega original + harmonizada e o toggle só escolhe qual entra no `renderTeamCard` | B) `ctx.filter = 'brightness() contrast() saturate()'` no `drawImage` da foto (adicionando `filter` ao `CardDrawContext` e alterando `renderTeamCard`) | C) `filter` CSS na prévia + refazer o ajuste no export/PNG.
**Recomendação:** A — `ctx.filter` tem suporte irregular no WebKit/iOS (o baseline do produto é mobile/iPhone) e tocaria o renderer; a passada de pixels é determinística, testável como math pura com dados sintéticos, preserva o alpha do recorte e serve prévia e PNG sem segundo pipeline.
**Rejeitadas:** B porque dependeria de recurso instável no Safari e violaria a invariante "não alterar `renderTeamCard`/`CardDrawContext`"; C porque o PNG refaria o filtro por outro caminho (risco de divergir da prévia) — exatamente o "twin" de pipeline que a doutrina proíbe.
### Decisões de engenharia
1. **Plataforma do ajuste (Safari baseline).**
**Opções:** A) passada de pixel pura + canvas variante | B) `ctx.filter` no draw | C) CSS filter na prévia + filtro próprio no export.
**Recomendação:** A — ver acima; a variante é criada UMA vez e o toggle apenas troca a referência.
**Rejeitadas:** B e C pelos motivos acima; nenhuma usa `ctx.filter` (não há precedente no repo).
2. **Fronteira lib pura × adapter DOM.**
**Opções:** A) novo `src/lib/cardPhotoHarmony.ts` (medidas + compute + apply, puro) + novo `src/components/cards/cardPhotoHarmonyCanvas.ts` (canvas offscreen/getImageData/putImageData) | B) tudo em `cardCutout.ts` | C) tudo em `cardCanvas.ts` | D) importar `utilities/`.
**Recomendação:** A — `src/lib` é puro e client-safe (nunca importa `utilities/`), então a math de pixels (ImageData-like) é unit-testável com dados sintéticos. O adapter novo é o dono de "canvas offscreen + leitura/escrita de pixels do tom", preocupação que hoje não tem dono: `cardCanvas.ts` é I/O de assets/PNG (não faz `getImageData`) e `cardCutout.ts` é segmentação/máscara — acoplar tom ali arriscaria o recorte. Sem twin: o adapter reusa `loadCardImage` do dono existente e a lib pura.
**Rejeitadas:** B porque misturaria motor MediaPipe com tone mapping na mesma vizinhança e convidaria a mutar o bitmap do recorte; C porque colocaria duas preocupações no mesmo arquivo e todo modelo pagaria o import; D por invariante do repo.
3. **Fórmula e limites (números fixos).**
**Opções:** A) medir a arte-mestre em runtime e a foto, com fator = `clamp(ref/src)` e limites fixos | B) perfil fixo calibrado uma vez | C) correção com matiz/temperatura/OKLab/white balance.
**Recomendação:** A — segue o literal ("referência: arte-mestre"), com clamps que impedem "filtro da casa"; B foi a alternativa já registrada na intenção e fica só como plano B de contingência; C está fora do literal (só brilho, contraste, saturação).
| fator | cálculo | limites (clamp) |
| --------- | ----------------------------------------- | --------------- |
| brilho | `ref.meanLuma / src.meanLuma` | `[0,94, 1,08]` |
| contraste | `ref.sdLuma / src.sdLuma` | `[0,94, 1,08]` |
| saturação | `ref.meanSaturation / src.meanSaturation` | `[0,90, 1,12]` |
- Medidas (puras): por pixel, luma Rec.709 (`0.2126r + 0.7152g + 0.0722b`, sRGB bytes, sem linearizar); `meanLuma`; `sdLuma` (desvio populacional); `meanSaturation` = média de `(max-min)/max` (HSV S; 0 quando max=0). Pixels com `alpha ≤ 32` ficam fora da amostra (mesmo limiar do recorte; sem amostras → `samples: 0`).
- Guards: stat da fonte ≤ 0 ou não-finita → fator 1; referência com `samples: 0` → identidade.
- Aplicação (ordem fixa): brilho (multiplica RGB) → contraste (`v' = (v-0.5)*c + 0.5`) → saturação (`v' = luma + (v-luma)*s`, luma do pixel já corrigido); arredonda e clampa `[0,255]`; **alpha copiado byte-a-byte**.
- **Identidade:** se os três fatores ficam em `1 ± 0,02`, a variante É o original (nenhum canvas novo); é o comportamento esperado para foto já próxima do tom.
- Baseline medido agora no repo (sanidade; o código mede em runtime): `team-card-base.png` 1080×1440 → `meanLuma 0,652`, `sdLuma 0,280`, `meanSaturation 0,441` (HSV, alpha>32). Os caps ficam iguais/logo acima do mockup do design (`saturate 1.12`, `brightness 1.06`, `contrast 1.06`) — delta máximo ≤ 12%.
- Amostra e aplicação varrem a imagem inteira (cutout ≤ 1600px de aresta por `CUTOUT_MAX_EDGE`); é uma medida + uma aplicação por foto.
4. **Ciclo de vida e cache da variante.**
**Opções:** A) computar dentro do `useCardCutout.start` (antes do `setState(ready)`), guardando `canvas` + `harmonized` no estado ready | B) `useMemo` no composer | C) `useEffect` separado com estado próprio | D) computar no clique do toggle.
**Recomendação:** A — o hook já é o dono do ciclo do recorte (run-id, retry, desmontagem); o ready passa a carregar as duas variantes e o default LIGADO já vale no primeiro commit (sem frame original → "a prévia abre harmonizada"). "Trocar foto" chama `start` de novo e gera nova variante naturalmente; o toggle só escolhe qual variante entra em `renderTeamCard` (zero reprocessamento). Cache: o tom da referência fica num `WeakMap` por imagem no adapter (a imagem do asset já é cacheada por `loadCardImage`); a variante é cacheada no próprio estado ready — nenhuma estrutura global segura canvas grande. Falha da harmonização (referência indisponível/contexto 2D ausente) → `harmonized: null` e o composer esconde o controle (não mente um switch ligado sem efeito); é caso praticamente impossível (mesma origem, canvas 2D padrão).
**Rejeitadas:** B porque compute em render é efeito colateral e bloqueia sem dono explícito; C porque espelharia os run-ids do hook (twin de ciclo) e deixaria um frame original visível; D porque o default LIGADO não estaria pronto no primeiro frame e religar poderia reprocessar.
5. **Controle.**
**Opções:** A) `<button type="button" role="switch" aria-checked>` nativo com trilho/thumb, componente local `TeamHarmonySwitch` no `CardComposer.tsx` (padrão do `TeamNameField`) | B) reusar `ui/Toggle` (radix, `aria-pressed`) | C) criar `ui/Switch` shadcn/radix | D) arquivo novo `components/cards/TeamHarmonySwitch.tsx`.
**Recomendação:** A — o literal exige `role=switch` + `aria-checked` e o artefato diz explicitamente "sem shadcn Switch"; o markup do design (:353-377, CSS :161-206) é botão nativo com trilho/thumb. Port classe-a-classe: linha inteira é o botão com `min-h-11`, trilho 44×26 com thumb 20px (`--pt-red` ligado / `#cfcac7` desligado, literal), label/ajuda sempre visíveis, foco visível com tokens do app. Uso único → componente local (extrair arquivo seria pass-through raso). As pílulas "LIGADO · alvo ≥44px"/"DESLIGADO" do artefato são anotação de crítica, não UI — não são portadas.
**Rejeitadas:** B porque `aria-pressed` contradiz a semântica literal; C porque adiciona primitivo vetado pelo design e sem segundo uso; D porque cria módulo raso de uso único.
6. **Ramo `nameError` e "Trocar foto".**
**Opções:** A) esconder o controle no `nameError` (junto do zoom, que já some) e manter a escolha do usuário ao trocar de foto | B) mostrar também no `nameError` | C) resetar o toggle para LIGADO em "Trocar foto".
**Recomendação:** A — precedente do próprio composer (em `nameError` só o campo do nome aparece); o design só cobre o ready normal; ao corrigir o nome o controle reaparece com o estado que o usuário deixou. O reset para LIGADO acontece só ao desmontar/reabrir o composer (sem persistência), que é o "sair e voltar" do aceite.
**Rejeitadas:** B porque o artefato não cobre e o ramo existe para focar a correção do nome; C porque reaplicaria um efeito recém-recusado na foto anterior (surpresa ruim) e custaria código extra.
7. **Testes.**
**Opções:** A) unit do lib puro com dados sintéticos + estender o describe e2e do time | B) só e2e | C) int/e2e com engine real.
**Recomendação:** A — unit: alpha preservado byte-a-byte, clamps exatos, guards de stat zero/não-finita, identidade (mesma referência, sem cópia), monotonicidade (brilho sobe luma; contraste afasta do meio; saturação abre canais; cinza neutro fica cinza), clamp `[0,255]` sem wrap, determinismo. E2E (no describe `Time de você`): novo teste com stub `ok` — switch visível com `aria-checked="true"` (default), alvo ≥ 44px, amostra de 1 pixel no centro da janela (`(582,727)`, dentro do recorte e fora do overlay) muda ao desligar e volta idêntica ao religar (prova de troca de bitmap, não só de ARIA), nota de privacidade visível; ausência do switch no modelo de foto e no `nameError` (um assert em cada teste existente). O guard de console/pageerror do fixture já cobre "sem console error"; o teste de download existente passa a exercitar o caminho harmonizado por padrão (fixture RGB sólido `30,120,200` cai longe da identidade: luma 0,418 vs 0,652; sat 0,849 vs 0,441). Sem int (nenhuma superfície de DB).
**Rejeitadas:** B porque clamps/alpha merecem prova determinística fora do browser; C porque o engine real é craft/UAT (S15) e o stub existe para isso.
8. **A11y/teclado.**
**Opções:** A) botão nativo `role=switch` (Tab + Space/Enter nativos), `aria-checked` booleano, linha inteira ≥44px, foco visível com tokens | B) `<input type=checkbox role=switch>` | C) teclado customizado em setas.
**Recomendação:** A — semântica correta sem JS de teclado; `aria-checked` reflete o estado; toque e teclado no alvo da linha toda; foco com `focus-visible:ring-2` (`--pt-red`), como os demais controles do composer.
**Rejeitadas:** B porque o design pede trilho/thumb e o input exigiria styling complexo; C porque inventaria interação sem necessidade.
### Componentes / mudanças
- **`src/lib/cardPhotoHarmony.ts`** (novo, puro, client-safe): `CardPhotoTone`, `CardHarmonyAdjustment`, `CARD_HARMONY_LIMITS`, `CARD_HARMONY_IDENTITY_EPSILON`; `measureCardPhotoTone(data)`, `computeCardHarmonyAdjustment`, `isCardHarmonyIdentity`, `applyCardPhotoHarmony(data, adjustment)`. O alpha floor da amostra é o mesmo do bbox: `CARD_CUTOUT_ALPHA_THRESHOLD` exportado em `cardPhotoTransform.ts` (dono do `CardAlphaBbox`) e importado por `cardCutout.ts` e `cardPhotoHarmony.ts` — fonte única, sem twin.
- **`src/lib/cardPhotoTransform.ts`** (editar, 1 constante): `CARD_CUTOUT_ALPHA_THRESHOLD` — alpha floor compartilhado pelo bbox (`cardCutout`) e pela amostra de tom (S17).
- **`src/components/cards/cardPhotoHarmonyCanvas.ts`** (novo, adapter DOM): `harmonizeCardCutout(cutout, referenceSrc): Promise<HTMLCanvasElement | null>` — `loadCardImage` (dono) + WeakMap de tom por imagem; `ctx.createImageData` + `set` + `putImageData`; nunca muta o canvas do recorte; devolve o próprio cutout quando identidade.
- **`src/components/cards/useCardCutout.ts`** (editar): novo param `harmonyReferenceSrc?: string`; ready ganha `harmonized: HTMLCanvasElement | null`; harmoniza antes do `setState(ready)` com guard de run-id.
- **`src/components/cards/CardComposer.tsx`** (editar): estado `harmonyEnabled` default `true` (junto de :112-123); draw effect (:214-230) passa a variante selecionada (`harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas`, com `harmonyEnabled` no array de deps); `TeamHarmonySwitch` local no ramo ready não-error, entre `{zoomAndPanControls}` e "Trocar foto"; esconde o controle quando `harmonized === null`.
- **`src/components/cards/cardCopy.ts`** (editar): `CARD_HARMONY_LABEL = 'Harmonizar cores'` e `CARD_HARMONY_HELP = 'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.'` (dono da copy do estúdio).
- **`tests/unit/cardPhotoHarmony.unit.spec.ts`** (novo): casos da decisão 7, com `Uint8ClampedArray` sintético — sem canvas/jsdom.
- **`tests/e2e/frontend.e2e.spec.ts`** (editar): novo teste no describe `Cards personalizados (S15 — Time de você)` + asserts de ausência nos testes do modelo de foto (:1796) e do nome longo (:1930). O guard de console já é automático pelo fixture.
- **`docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md`** (novo, uma entrada curta).
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
- **Access / Consent:** n/a — nenhuma superfície Payload; a foto e a escolha nunca saem do aparelho; sem chave nova de Consent e sem persistência.
- **UI:** Impeccable C — port do artefato (controle no ready, depois de "Ajuste fino" e antes de "Trocar foto"; trilho `--pt-red`/cinza, thumb, label+ajuda sempre visíveis, cenas desktop 1280 e mobile 390); sem cor nova; alvo ≥44px; foco visível; `role=switch`/`aria-checked`.
### Dados → forma (não se aplica)
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única "forma" é o pixel da própria foto (insumo local) e o tom da arte-mestre. Nenhum dado é coletado, persistido ou apresentado; a escolha do toggle não vira métrica.
## Fases verificáveis
1. **Tracer do núcleo puro** — quota ~0,25 dia. `src/lib/cardPhotoHarmony.ts` + `tests/unit/cardPhotoHarmony.unit.spec.ts`: medidas, fórmula, clamps, guards, identidade, alpha preservado, monotonicidade, determinismo.
Prova: `pnpm test:unit tests/unit/cardPhotoHarmony.unit.spec.ts` verde; nenhuma UI alterada ainda.
2. **Pipeline + UI** — quota ~0,4 dia. Adapter `cardPhotoHarmonyCanvas.ts`; `useCardCutout` com `harmonized`; draw do composer escolhendo a variante; `TeamHarmonySwitch` + copy no ramo ready (não no `nameError`).
Prova: `NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm dev` em `/cards?model=time-de-voce` — prévia abre harmonizada, desligar/religar troca na hora, "Trocar foto" reaplica, `nameError` e os 3 modelos não mostram o switch; `pnpm gate:fast` verde.
3. **Gates** — quota ~0,25 dia. E2E novo + asserts de ausência; `pnpm gate:fast`; `pnpm test:e2e tests/e2e/frontend.e2e.spec.ts` (o manifesto `scripts/lib/e2e-affected-manifest.mjs` já aciona `frontend` para `src/components/cards` e `src/lib/card` — sem mudança de manifesto); `pnpm push` (roda `gate:ci`).
**Design tier:** crítica final do `designer` **certificada no tier primário** (`openai/gpt-5.6-sol`) — o artefato foi revalidado e perdeu o selo `DEGRADED`; registrar `Design tier:` no body do PR. Ajustes pedidos na crítica e aplicados: prévia `ready` a 180px no mobile (240px no desktop), superfície do switch sem hover persistente, "Trocar foto" em `mt-3`.
## Rabbit holes / Não escopo (engenharia)
- **`ctx.filter` no Safari.** Suporte irregular no WebKit; o baseline é iPhone — rejeitado (decisão 1).
- **CSS filter na prévia + filtro separado no export.** Dois pipelines e PNG divergente — rejeitado.
- **Editor de imagem** (slider/curvas/presets/ajuste manual) — anti-goal de produto.
- **Matiz/temperatura/OKLab/white balance** — fora do literal (só brilho, contraste, saturação).
- **Worker/OffscreenCanvas para a passada de pixels** — especulativo; só com jank medido no UAT (gatilho explícito).
- **Persistência da preferência** (localStorage/CMS/Consent) — anti-goal.
- **Retoque de beleza** (pele/forma/imperfeições) — anti-goal.
- **Mudar recorte/máscara/framing, `renderTeamCard`/`CardDrawContext` ou os 3 modelos** — invariante.
- **Nova dependência, upload, serviço ou analytics** — anti-goal.
- **Novo primitivo `ui/Switch` shadcn/radix** — vetado pelo design (decisão 5).
- **Medir a referência no servidor / pré-processar o asset** — anti-goal (100% no aparelho) e perde a verdade do asset servido.
## Revisão da sessão (simplify) — resolvido, adiado com gatilho, fora
**Resolvido no simplify (não reabrir):** alpha floor do bbox e da amostra de tom unificados em
`CARD_CUTOUT_ALPHA_THRESHOLD` (`cardPhotoTransform`); `CardPhotoPixels` removido (as funções puras
recebem `Uint8ClampedArray`); `IDENTITY_ADJUSTMENT` congelado (`Object.freeze`); `aria-describedby`
- `useId` no switch (nome acessível limpo, ajuda anunciada como descrição); primeira amostra de
pixel do e2e pollada (fim do flake potencial); assert da nota de privacidade no teste novo;
`?? 0` redundante removido do loop quente; codebase-map atualizado no mesmo PR.
**Adiados com gatilho (engenharia):**
- **Alpha-skip na passada de aplicação** (pixels com alpha ≤ 32 hoje fazem a conta invisível) —
gatilho: jank medido no UAT; alpha-skip antes de subsample/worker.
- **Testes do adapter `harmonizeCardCutout`** (identidade devolve o mesmo canvas; falha → `null`
esconde o controle) — gatilho: 2º consumidor do adapter OU identidade virar aceite explícito de
produto (hoje o e2e prova a troca de bitmap e prévia = PNG no mesmo canvas).
- **`transition-[left]` do thumb** (anima layout) — trocar por `translate-x` se o switch for tocado
por outro motivo; sem jank medido.
**Explicitamente fora (não reabrir):** catch fail-closed do adapter (esconde o controle em vez de
mentir um switch ligado sem efeito; a math pura é coberta por unit); dedup do `clamp` (one-liner
estável já repetido em vários módulos de `lib`; extrair módulo raso piora).
**Harness (main vermelha):** `tests/unit/codebaseConventions.unit.spec.ts` passa a usar
`git ls-files -z` — sem o `-z`, o path não-ASCII `public/campaign-kit/coração.png` (C204) vinha
citado e o `readFileSync` estourava ENOENT no `verify` da main; vai em commit separado neste PR.
## Riscos e mitigação
| Risco | Mitigação / gatilho |
| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Flash de foto original → harmonizada ao abrir a prévia | Computar no `start` antes do `setState(ready)` (um único commit); se algum dia entrar latência > ~300ms, aí sim worker + estado transitório explícito (nomeado em rabbit holes). |
| Passada de pixels pesada em celular fraco (≤1600², 1 medida + 1 aplicação) | Uma única vez por foto; se o UAT acusar jank, subsample da medida (stride 2) antes de qualquer mudança estrutural. |
| Referência "alta demais" (base inclui céu/candidato coberto: luma 0,652) empurrar quase toda selfie para o cap | Caps fixos (≤12%) tornam o pior caso aceitável; se o UAT achar claro demais, remedir excluindo a janela da foto ou usar mediana — decisão de produto registrada, não silenciosa. |
| Identidade (1±0,02) fazer o toggle parecer inerte numa foto já no tom | Comportamento intencional ("foto já próxima do tom = identidade"); a fixture e2e está longe da identidade (luma 0,418 vs 0,652), então a troca de bitmap é observável. |
| Memória +1 canvas RGBA por cutout (≤ ~10MB) | Limitado por `CUTOUT_MAX_EDGE`; liberado ao trocar de foto/desmontar o composer; o original é preservado para o toggle off. |
| Amostra e2e em (582,727) ficar sob o overlay em mudança futura da arte | Ponto hoje transparente no front (front só cobre y≥971); se a arte mudar, ajustar o ponto de amostra (trigger barato). |
| `getImageData` taint | Asset same-origin em `/cards` (sem CDN); sem mudança prevista. |
| Falso "no-op" no e2e por console | Guard automático do fixture `e2eTest` já falha o teste em `console.error`/`pageerror`/5xx — nenhum listener novo. |
## Aceite de engenharia (checkboxes)
- [ ] Controle só no ready normal do `time-de-voce` (não no `nameError`, não nos outros 3 modelos); `role=switch` + `aria-checked`; alvo da linha ≥44px; toque e teclado (Space/Enter) com foco visível; label e ajuda nos literais exatos.
- [ ] Default LIGADO: a prévia abre harmonizada sem frame original visível.
- [ ] Desligar devolve a original instantaneamente; religar reaplica; nenhum reprocessamento de pixels na troca (cache no ready).
- [ ] A escolha vale para a prévia E para o PNG (mesmo canvas único, `canvasRef` → `canvasToPngBlob`).
- [ ] Nada persistido: fechar/reabrir volta ao padrão LIGADO (estado de mount).
- [ ] Recorte/máscara intactos: alpha byte-a-byte, nenhuma mutação do canvas original; enquadramento/pan/zoom inalterados.
- [ ] `renderTeamCard`, `CardDrawContext`, `cardCutout.ts` (compose/máscara/bbox), `cardModels.ts` e os 3 modelos sem mudança; pins do `cardRender.unit.spec.ts` verdes.
- [ ] Só brilho, contraste e saturação via passada de pixels local; sem `ctx.filter`, sem CSS filter, sem rede/dependência/analytics.
- [ ] Sem migration/collection/CMS/Consent; nota de privacidade visível e inalterada.
- [ ] Unit do lib puro com alpha/clamps/identidade/determinismo; e2e com default, toggle (ARIA + amostra de bitmap), alvo ≥44px e ausências; `pnpm gate:fast` verde; e2e `frontend` acionado.
- [ ] Changelog `docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md`; crítica visual do `designer` certificada no tier primário (`openai/gpt-5.6-sol`) antes do `pnpm push`.
## Self-score decision-quality: 5/5
1. **Decisões caras com rejeitadas:** pixel pass × `ctx.filter` × CSS filter (Safari/semântica de pixels); fronteira lib pura × adapter (novo dono de pixel pass, sem twin); contrato da variante no ready do hook; controle nativo × `ui/Toggle` × shadcn Switch.
2. **Cabe no appetite:** ~0,9 dia em 3 fases, sem migration, sem dependência; tracer do núcleo puro primeiro.
3. **Rabbit holes nomeados:** worker, editor, hue/Lab/white balance, persistência, CSS filter, shadcn Switch, retoque, servidor.
4. **Depth check:** o `useCardCutout` (dono do ciclo) passa a carregar as duas variantes em vez de um segundo hook/estado espelhado; o switch fica local (uso único, sem módulo raso); o adapter novo existe porque nenhum dono cobre tone mapping e `cardCutout` não pode ser acoplado.
5. **Intenção preservada:** default LIGADO, sem persistência, prévia+PNG, só `time-de-voce`, 100% local, copy literal, privacidade intacta, recorte e arte-mestre intocados.

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>DEGRADED — Design S17 · Card Time de você: harmonizar cores</title>
<title>Design S17 · Card Time de você: harmonizar cores</title>
<style type="text/tailwindcss">
:root {
--background: #ffffff;
@ -209,16 +209,10 @@
</style>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<div
class="border-b border-amber-500/60 bg-amber-500/10 px-4 py-2 text-center text-[11px] font-black tracking-[0.14em] text-amber-300 uppercase"
>
DEGRADED — não certificado; exige sign-off humano · tier opencode-go/deepseek-v4.1-flash
</div>
<main class="mx-auto max-w-[1440px] px-4 py-10 sm:px-6 lg:px-10">
<header class="max-w-4xl">
<p class="m-0 text-xs font-black tracking-[0.12em] text-red-400 uppercase">
Gate visual · S17 · DEGRADED
Gate visual · S17
</p>
<h1 class="mt-2 text-2xl font-black tracking-[-0.02em] sm:text-3xl">
Card “Time de você”: harmonizar as cores da foto
@ -229,10 +223,6 @@
desligar devolve a original na hora; vale para a prévia e para o PNG; nada persiste e não
há editor.
</p>
<p class="mt-3 max-w-3xl text-xs leading-5 text-amber-300/90">
<strong>DEGRADED:</strong> este design não está certificado. É insumo para decisão humana
no gate/PR — o tier degradado nunca aprova.
</p>
<div class="mt-5 flex flex-wrap gap-2 text-[11px] font-bold text-zinc-300">
<span class="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2"
>1280px · desktop</span
@ -903,10 +893,8 @@
</section>
<aside class="mt-14 border-t border-zinc-800 pt-8 text-xs leading-5 text-zinc-500">
<p
class="mb-3 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 font-bold text-amber-300"
>
DEGRADED — não certificado; exige sign-off humano. Tier: opencode-go/deepseek-v4.1-flash.
<p class="mb-3 font-bold text-zinc-300">
Design certificado no tier primário · openai/gpt-5.6-sol.
</p>
<p>
<strong class="text-zinc-300">Copys literais do plano:</strong> rótulo “Harmonizar cores”;

View file

@ -19,7 +19,12 @@ import {
loadCardImage,
loadCardPhoto,
} from '@/components/cards/cardCanvas'
import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import {
CARD_HARMONY_HELP,
CARD_HARMONY_LABEL,
CARD_PHOTO_PRIVACY_NOTE,
CARD_PRIVACY_NOTE,
} from '@/components/cards/cardCopy'
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
import { useCardCutout } from '@/components/cards/useCardCutout'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@ -53,12 +58,12 @@ const controlButtonClassName =
const NAME_TOO_LONG_MESSAGE =
'Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.'
/** S15 design gate: the team preview width per state. */
/** S15 design gate: the team preview width per state (S17 ready: 180px mobile / 240px desktop). */
type TeamPreviewStage = 'idle' | 'processing' | 'ready' | 'result'
const TEAM_PREVIEW_WIDTH: Record<TeamPreviewStage, string> = {
idle: 'max-w-[10.75rem]',
processing: 'max-w-[9rem]',
ready: 'max-w-[15rem]',
ready: 'max-w-[11.25rem] sm:max-w-[15rem]',
result: 'max-w-[11.875rem]',
}
@ -101,6 +106,45 @@ const TeamNameField = ({
</div>
)
/**
* S17 — the tone-harmony switch of the ready state. Native `role="switch"` (the
* design gate's semantics; the studio has no shadcn Switch) with the whole row
* as the target and the label/help always visible.
*/
const TeamHarmonySwitch = ({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) => {
const helpId = useId()
return (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-describedby={helpId}
onClick={onToggle}
className="mt-4 flex min-h-11 w-full items-start gap-3 rounded-lg border border-(--campaign-line) bg-white p-3 text-left focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span
aria-hidden="true"
className={`relative mt-1.5 inline-block h-[26px] w-11 flex-none rounded-full transition-colors ${
enabled ? 'bg-(--pt-red)' : 'bg-[#cfcac7]'
}`}
>
<span
className={`absolute top-[3px] size-5 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/25%)] transition-[left] ${
enabled ? 'left-[21px]' : 'left-[3px]'
}`}
/>
</span>
<span className="min-w-0">
<span className="block text-sm font-bold text-(--campaign-ink)">{CARD_HARMONY_LABEL}</span>
<span id={helpId} className="mt-0.5 block text-xs leading-5 text-(--campaign-muted)">
{CARD_HARMONY_HELP}
</span>
</span>
</button>
)
}
type CardComposerProps = {
model: CardModel
shell: 'dialog' | 'drawer'
@ -121,6 +165,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
/** S17 — LIGADO by default; resets only when the composer unmounts (no persistence). */
const [harmonyEnabled, setHarmonyEnabled] = useState(true)
const canvasRef = useRef<HTMLCanvasElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@ -131,7 +177,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const photoWindow = model.photoWindow
const isNameModel = model.kind === 'name'
const isTeamModel = model.kind === 'team'
const cutout = useCardCutout(photoWindow)
const cutout = useCardCutout(photoWindow, isTeamModel ? model.assetSrc : undefined)
const cutoutState = cutout.state
const teamProcessing = cutoutState.status === 'processing' ? cutoutState : null
@ -215,7 +261,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const result = renderTeamCard(ctx, model, {
base: baseImage,
overlay: overlayImage,
photo: teamReady.canvas,
photo: harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas,
photoSize: { width: teamReady.width, height: teamReady.height },
transform: effectiveTransform,
window: photoWindow,
@ -264,6 +310,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
isNameModel,
isTeamModel,
teamReady,
harmonyEnabled,
cutoutState.status,
model,
name,
@ -733,10 +780,16 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
) : (
<>
{zoomAndPanControls}
{teamReady.harmonized ? (
<TeamHarmonySwitch
enabled={harmonyEnabled}
onToggle={() => setHarmonyEnabled((enabled) => !enabled)}
/>
) : null}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={`${secondaryButtonClassName} mt-4 w-full`}
className={`${secondaryButtonClassName} mt-3 w-full`}
>
Trocar foto
</button>

View file

@ -7,6 +7,14 @@ export const CARD_PRIVACY_NOTE =
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
export const CARD_PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
/**
* S17 — the tone-harmony control of the team model. Literals of the intent and
* the design gate; keep both identical between the plan and the composer.
*/
export const CARD_HARMONY_LABEL = 'Harmonizar cores'
export const CARD_HARMONY_HELP =
'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.'
/**
* S14 — example name of the name-model tile. Drawn by the real composer
* pipeline over the base image (same fit and left border as the generated card).

View file

@ -14,6 +14,7 @@
import type { ImageSegmenter, MPMask } from '@mediapipe/tasks-vision'
import type { CardAlphaBbox } from '@/lib/cardPhotoTransform'
import { CARD_CUTOUT_ALPHA_THRESHOLD } from '@/lib/cardPhotoTransform'
declare global {
interface Window {
@ -25,7 +26,6 @@ declare global {
const WASM_DIR = '/cards/mediapipe/wasm'
const MODEL_URL = '/cards/selfie_segmenter.tflite'
const CUTOUT_MAX_EDGE = 1600
const ALPHA_THRESHOLD = 32
const STUB_ENABLED = process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB === '1'
const STUB_DELAY_MS = 1500
const STUB_PROGRESS = 0.36
@ -243,7 +243,7 @@ const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => {
for (let y = 0; y < height; y += 1) {
const row = y * width * 4
for (let x = 0; x < width; x += 1) {
if (pixels[row + x * 4 + 3]! > ALPHA_THRESHOLD) {
if (pixels[row + x * 4 + 3] > CARD_CUTOUT_ALPHA_THRESHOLD) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y

View file

@ -0,0 +1,78 @@
/**
* S17 — browser adapter for the on-device tone harmony of the `Time de você`
* cutout. Reads the reference tone from the official master (already cached by
* `loadCardImage`) and the photo's tone through an offscreen canvas — the photo
* still never leaves the device. The pixel math is pure (`lib/cardPhotoHarmony`);
* this module only touches canvas/DOM and never mutates the cutout canvas, so
* toggling the control can always bring the original back.
*/
import { loadCardImage } from '@/components/cards/cardCanvas'
import {
applyCardPhotoHarmony,
computeCardHarmonyAdjustment,
isCardHarmonyIdentity,
measureCardPhotoTone,
type CardPhotoTone,
} from '@/lib/cardPhotoHarmony'
const REFERENCE_TONE_CACHE = new WeakMap<HTMLImageElement, CardPhotoTone>()
const measureImageTone = (image: HTMLImageElement): CardPhotoTone | null => {
const cached = REFERENCE_TONE_CACHE.get(image)
if (cached) return cached
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return null
ctx.drawImage(image, 0, 0)
const tone = measureCardPhotoTone(ctx.getImageData(0, 0, canvas.width, canvas.height).data)
REFERENCE_TONE_CACHE.set(image, tone)
return tone
}
/**
* Returns the harmonized variant of `cutout` (same size, same alpha) or `null`
* when the reference cannot be read. A photo already on the reference tone
* returns the `cutout` itself: the identity band means "nothing to adjust".
*/
export const harmonizeCardCutout = async (
cutout: HTMLCanvasElement,
referenceSrc: string,
): Promise<HTMLCanvasElement | null> => {
try {
const referenceTone = measureImageTone(await loadCardImage(referenceSrc))
if (!referenceTone) return null
const ctx = cutout.getContext('2d')
if (!ctx) return null
const source = ctx.getImageData(0, 0, cutout.width, cutout.height)
const adjustment = computeCardHarmonyAdjustment(
measureCardPhotoTone(source.data),
referenceTone,
)
if (isCardHarmonyIdentity(adjustment)) return cutout
const harmonized = applyCardPhotoHarmony(source.data, adjustment)
const canvas = document.createElement('canvas')
canvas.width = cutout.width
canvas.height = cutout.height
const outputCtx = canvas.getContext('2d')
if (!outputCtx) return null
const image = outputCtx.createImageData(cutout.width, cutout.height)
image.data.set(harmonized)
outputCtx.putImageData(image, 0, 0)
return canvas
} catch {
// Fail closed: the composer hides the control instead of showing a switch
// with no effect. The pixel math is pure and covered by unit tests.
return null
}
}

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { removeCardPhotoBackground } from '@/components/cards/cardCutout'
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
import type { CardRect } from '@/lib/cardModels'
import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
@ -12,6 +13,12 @@ export type CardCutoutState =
| {
status: 'ready'
canvas: HTMLCanvasElement
/**
* S17 — tone-harmonized variant of `canvas` (same size/alpha). Equals
* `canvas` when the photo is already on tone; `null` when the reference
* could not be read (the composer then hides the control).
*/
harmonized: HTMLCanvasElement | null
width: number
height: number
/** Initial framing: cutout top on the slot top, centered, covering it. */
@ -25,8 +32,11 @@ export type CardCutoutState =
* so a stale inference or an unmount never writes state. The engine itself is a
* page-lifetime singleton in the adapter (re-creating the wasm session per open
* would cost seconds); each result's masks/bitmap are released there.
*
* S17 — the ready state also carries the harmonized variant, computed once
* before the first ready paint (default ON must not flash the raw photo).
*/
export const useCardCutout = (photoWindow: CardRect | undefined) => {
export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenceSrc?: string) => {
const [state, setState] = useState<CardCutoutState>({ status: 'idle' })
const fileRef = useRef<File | null>(null)
const runRef = useRef(0)
@ -68,15 +78,21 @@ export const useCardCutout = (photoWindow: CardRect | undefined) => {
return
}
const harmonized = harmonyReferenceSrc
? await harmonizeCardCutout(result.canvas, harmonyReferenceSrc)
: null
if (runRef.current !== run) return
setState({
status: 'ready',
canvas: result.canvas,
harmonized,
width: result.width,
height: result.height,
transform,
})
},
[photoWindow],
[photoWindow, harmonyReferenceSrc],
)
const retry = useCallback(() => {

163
src/lib/cardPhotoHarmony.ts Normal file
View file

@ -0,0 +1,163 @@
/**
* S17 — pure tone math for the `Time de você` card: brings the visitor's cutout
* photo closer to the official candidate photos in brightness, contrast and
* saturation. No DOM here (the browser adapter lives in
* `components/cards/cardPhotoHarmonyCanvas`), no hue/white balance and no skin
* retouch — only the three measured channels, clamped to a mild range so the
* result never reads as a preset filter. The alpha channel is always copied
* byte-for-byte: the cutout mask is untouchable.
*/
import { CARD_CUTOUT_ALPHA_THRESHOLD } from './cardPhotoTransform'
export type CardPhotoTone = {
/** Rec.709 luma mean (0..1) over the pixels above the alpha threshold. */
meanLuma: number
/** Population standard deviation of the luma (0..1). */
sdLuma: number
/** Mean HSV saturation `(max-min)/max` (0..1); 0 when a pixel is black. */
meanSaturation: number
/** Sampled pixel count; 0 when every pixel is below the alpha threshold. */
samples: number
}
export type CardHarmonyAdjustment = {
brightness: number
contrast: number
saturation: number
}
/** Fixed mild deltas (≤ 12%): the adjustment harmonizes, it does not filter. */
export const CARD_HARMONY_LIMITS = {
brightness: { min: 0.94, max: 1.08 },
contrast: { min: 0.94, max: 1.08 },
saturation: { min: 0.9, max: 1.12 },
} as const
/** Inside this band the adjustment is a no-op: the photo is already on tone. */
export const CARD_HARMONY_IDENTITY_EPSILON = 0.02
const IDENTITY_ADJUSTMENT: CardHarmonyAdjustment = Object.freeze({
brightness: 1,
contrast: 1,
saturation: 1,
})
const LUMA_RED = 0.2126
const LUMA_GREEN = 0.7152
const LUMA_BLUE = 0.0722
const MID_RANGE = 127.5
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max)
const lumaOf = (red: number, green: number, blue: number): number =>
LUMA_RED * red + LUMA_GREEN * green + LUMA_BLUE * blue
/**
* S17 — measures the tone of an RGBA buffer. Fully transparent pixels are not
* evidence of tone, so they stay out of the sample (same alpha floor as the
* cutout bbox): a blank segmentation measures `samples: 0` instead of black.
*/
export const measureCardPhotoTone = (data: Uint8ClampedArray): CardPhotoTone => {
let samples = 0
let lumaSum = 0
let lumaSquaredSum = 0
let saturationSum = 0
for (let index = 0; index < data.length; index += 4) {
if (data[index + 3] <= CARD_CUTOUT_ALPHA_THRESHOLD) continue
const red = data[index]
const green = data[index + 1]
const blue = data[index + 2]
const luma = lumaOf(red, green, blue) / 255
const max = Math.max(red, green, blue)
samples += 1
lumaSum += luma
lumaSquaredSum += luma * luma
saturationSum += max === 0 ? 0 : (max - Math.min(red, green, blue)) / max
}
if (samples === 0) return { meanLuma: 0, sdLuma: 0, meanSaturation: 0, samples: 0 }
const meanLuma = lumaSum / samples
return {
meanLuma,
sdLuma: Math.sqrt(Math.max(0, lumaSquaredSum / samples - meanLuma * meanLuma)),
meanSaturation: saturationSum / samples,
samples,
}
}
const factorFrom = (
reference: number,
source: number,
limit: { min: number; max: number },
): number => {
if (!Number.isFinite(reference) || !Number.isFinite(source) || source <= 0) return 1
const factor = reference / source
return Number.isFinite(factor) ? clamp(factor, limit.min, limit.max) : 1
}
/**
* S17 — the three factors that bring `source` toward `reference`, each clamped.
* A degenerate source stat (flat tone, zero saturation, empty sample) keeps its
* factor at 1 instead of amplifying noise; an empty reference is identity.
*/
export const computeCardHarmonyAdjustment = (
source: CardPhotoTone,
reference: CardPhotoTone,
): CardHarmonyAdjustment => {
if (source.samples === 0 || reference.samples === 0) return IDENTITY_ADJUSTMENT
return {
brightness: factorFrom(reference.meanLuma, source.meanLuma, CARD_HARMONY_LIMITS.brightness),
contrast: factorFrom(reference.sdLuma, source.sdLuma, CARD_HARMONY_LIMITS.contrast),
saturation: factorFrom(
reference.meanSaturation,
source.meanSaturation,
CARD_HARMONY_LIMITS.saturation,
),
}
}
export const isCardHarmonyIdentity = (adjustment: CardHarmonyAdjustment): boolean =>
Math.abs(adjustment.brightness - 1) <= CARD_HARMONY_IDENTITY_EPSILON &&
Math.abs(adjustment.contrast - 1) <= CARD_HARMONY_IDENTITY_EPSILON &&
Math.abs(adjustment.saturation - 1) <= CARD_HARMONY_IDENTITY_EPSILON
/**
* S17 — applies the adjustment in one pass, in a fixed order (brightness →
* contrast → saturation) and with the pixel luma recomputed after the first two
* so saturation pivots on the corrected color. Returns a new buffer; `alpha`
* bytes are copied untouched.
*/
export const applyCardPhotoHarmony = (
data: Uint8ClampedArray,
adjustment: CardHarmonyAdjustment,
): Uint8ClampedArray => {
const output = new Uint8ClampedArray(data.length)
const { brightness, contrast, saturation } = adjustment
for (let index = 0; index < data.length; index += 4) {
output[index + 3] = data[index + 3]
const brightRed = data[index] * brightness
const brightGreen = data[index + 1] * brightness
const brightBlue = data[index + 2] * brightness
const correctedRed = (brightRed - MID_RANGE) * contrast + MID_RANGE
const correctedGreen = (brightGreen - MID_RANGE) * contrast + MID_RANGE
const correctedBlue = (brightBlue - MID_RANGE) * contrast + MID_RANGE
const luma = lumaOf(correctedRed, correctedGreen, correctedBlue)
output[index] = Math.round(luma + (correctedRed - luma) * saturation)
output[index + 1] = Math.round(luma + (correctedGreen - luma) * saturation)
output[index + 2] = Math.round(luma + (correctedBlue - luma) * saturation)
}
return output
}

View file

@ -20,6 +20,12 @@ export const CARD_PHOTO_MAX_ZOOM = 4
*/
const CARD_CUTOUT_MIN_BBOX = 24
/**
* Alpha noise floor of a cutout (matches the mask's soft edge): the bbox read
* ignores it and the S17 tone sample ignores it too, so both see the same photo.
*/
export const CARD_CUTOUT_ALPHA_THRESHOLD = 32
export type CardPhotoSize = {
width: number
height: number

View file

@ -1787,6 +1787,8 @@ test.describe('Cards personalizados (S14)', () => {
const canvas = dialog.locator('canvas')
await expect(canvas).toHaveAttribute('width', '1000')
await expect(canvas).toHaveAttribute('height', '1000')
// S17 — the harmony control is exclusive of the team model.
await expect(dialog.getByRole('switch')).toHaveCount(0)
await dialog.locator('input[type="file"]').setInputFiles({
name: 'foto-e2e.png',
@ -1915,6 +1917,53 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled()
})
test('harmonizes the cutout by default and toggles back to the original photo', 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()
const toggle = dialog.getByRole('switch', { name: 'Harmonizar cores' })
await expect(toggle).toBeVisible()
await expect(toggle).toHaveAttribute('aria-checked', 'true')
await expect(
dialog.getByText(
'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.',
),
).toBeVisible()
const box = await toggle.boundingBox()
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44)
const canvas = dialog.locator('canvas')
const readSample = () =>
canvas.evaluate((element) => {
const node = element as HTMLCanvasElement
return [...node.getContext('2d')!.getImageData(582, 727, 1, 1).data]
})
// Default ON: the preview pixel is the harmonized variant, not the raw photo.
await expect.poll(readSample).not.toEqual([30, 120, 200, 255])
const harmonized = await readSample()
await toggle.click()
await expect(toggle).toHaveAttribute('aria-checked', 'false')
await expect.poll(readSample).toEqual([30, 120, 200, 255])
// Re-enabling with the keyboard proves the native switch semantics.
await toggle.focus()
await page.keyboard.press('Space')
await expect(toggle).toHaveAttribute('aria-checked', 'true')
await expect.poll(readSample).toEqual(harmonized)
await expect(
dialog.getByText('Sua foto fica neste aparelho e não é enviada para nós.'),
).toBeVisible()
})
test('a name that cannot fit fails closed and keeps the cutout', async ({ page }) => {
await setCutoutStub(page, 'ok')
await page.goto('/cards?model=time-de-voce')
@ -1925,6 +1974,8 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible()
await expect(dialog.getByRole('alert')).toContainText('nome mais curto')
// S17 — the harmony control lives in the regular ready state only.
await expect(dialog.getByRole('switch')).toHaveCount(0)
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
await expect(primary).toBeDisabled()
await expect(dialog.getByText('permanecem salvos neste aparelho')).toBeVisible()

View file

@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest'
import {
CARD_HARMONY_IDENTITY_EPSILON,
CARD_HARMONY_LIMITS,
applyCardPhotoHarmony,
computeCardHarmonyAdjustment,
isCardHarmonyIdentity,
measureCardPhotoTone,
type CardHarmonyAdjustment,
type CardPhotoTone,
} from '@/lib/cardPhotoHarmony'
import { CARD_CUTOUT_ALPHA_THRESHOLD } from '@/lib/cardPhotoTransform'
type Rgba = readonly [number, number, number, number?]
const pixelsOf = (colors: ReadonlyArray<Rgba>) =>
new Uint8ClampedArray(
colors.flatMap(([red, green, blue, alpha = 255]) => [red, green, blue, alpha]),
)
const toneOf = (overrides: Partial<CardPhotoTone> = {}): CardPhotoTone => ({
meanLuma: 0.5,
sdLuma: 0.2,
meanSaturation: 0.4,
samples: 1,
...overrides,
})
const IDENTITY: CardHarmonyAdjustment = { brightness: 1, contrast: 1, saturation: 1 }
describe('measureCardPhotoTone', () => {
it('measures Rec.709 luma and HSV saturation of an opaque color', () => {
const tone = measureCardPhotoTone(pixelsOf([[30, 120, 200]]))
expect(tone.samples).toBe(1)
expect(tone.meanLuma).toBeCloseTo(0.4182, 4)
expect(tone.sdLuma).toBe(0)
expect(tone.meanSaturation).toBeCloseTo(0.85, 5)
})
it('ignores pixels at or below the alpha threshold (matches the cutout bbox)', () => {
const tone = measureCardPhotoTone(
pixelsOf([
[255, 255, 255, 0],
[255, 255, 255, CARD_CUTOUT_ALPHA_THRESHOLD],
[0, 0, 0, CARD_CUTOUT_ALPHA_THRESHOLD + 1],
]),
)
expect(tone.samples).toBe(1)
expect(tone.meanLuma).toBe(0)
})
it('reports an empty sample instead of black when nothing is opaque', () => {
const tone = measureCardPhotoTone(pixelsOf([[10, 20, 30, 0]]))
expect(tone).toEqual({ meanLuma: 0, sdLuma: 0, meanSaturation: 0, samples: 0 })
})
it('measures the population spread of a two-tone sample', () => {
const tone = measureCardPhotoTone(
pixelsOf([
[0, 0, 0],
[255, 255, 255],
]),
)
expect(tone.samples).toBe(2)
expect(tone.meanLuma).toBeCloseTo(0.5, 5)
expect(tone.sdLuma).toBeCloseTo(0.5, 5)
expect(tone.meanSaturation).toBe(0)
})
})
describe('computeCardHarmonyAdjustment', () => {
it('is identity when the photo already matches the reference tone', () => {
const tone = toneOf()
const adjustment = computeCardHarmonyAdjustment(tone, tone)
expect(adjustment).toEqual(IDENTITY)
expect(isCardHarmonyIdentity(adjustment)).toBe(true)
})
it('raises brightness toward a brighter reference, capped by the fixed limit', () => {
const adjustment = computeCardHarmonyAdjustment(
toneOf({ meanLuma: 0.4 }),
toneOf({ meanLuma: 0.8 }),
)
expect(adjustment.brightness).toBe(CARD_HARMONY_LIMITS.brightness.max)
expect(adjustment.contrast).toBe(1)
expect(adjustment.saturation).toBe(1)
})
it('moves saturation toward a desaturated reference, floored by the fixed limit', () => {
const adjustment = computeCardHarmonyAdjustment(
toneOf({ meanSaturation: 0.5 }),
toneOf({ meanSaturation: 0.1 }),
)
expect(adjustment.saturation).toBe(CARD_HARMONY_LIMITS.saturation.min)
})
it('moves contrast toward a flatter reference, floored by the fixed limit', () => {
const adjustment = computeCardHarmonyAdjustment(
toneOf({ sdLuma: 0.3 }),
toneOf({ sdLuma: 0.1 }),
)
expect(adjustment.contrast).toBe(CARD_HARMONY_LIMITS.contrast.min)
})
it('keeps the factor at 1 for a degenerate source stat instead of amplifying noise', () => {
const adjustment = computeCardHarmonyAdjustment(
toneOf({ sdLuma: 0, meanSaturation: 0 }),
toneOf({ sdLuma: 0.3, meanSaturation: 0.5 }),
)
expect(adjustment.contrast).toBe(1)
expect(adjustment.saturation).toBe(1)
})
it('is identity when either sample is empty', () => {
expect(computeCardHarmonyAdjustment(toneOf({ samples: 0 }), toneOf())).toEqual(IDENTITY)
expect(computeCardHarmonyAdjustment(toneOf(), toneOf({ samples: 0 }))).toEqual(IDENTITY)
})
it('falls back to 1 for non-finite stats', () => {
const adjustment = computeCardHarmonyAdjustment(
toneOf({ meanLuma: Number.NaN }),
toneOf({ meanLuma: Number.NaN, sdLuma: Number.POSITIVE_INFINITY }),
)
expect(adjustment).toEqual(IDENTITY)
})
})
describe('isCardHarmonyIdentity', () => {
it('accepts the epsilon band on both sides', () => {
const inside = CARD_HARMONY_IDENTITY_EPSILON - 0.001
expect(
isCardHarmonyIdentity({
brightness: 1 + inside,
contrast: 1 - inside,
saturation: 1,
}),
).toBe(true)
})
it('rejects factors outside the epsilon band', () => {
const outside = CARD_HARMONY_IDENTITY_EPSILON + 0.001
expect(isCardHarmonyIdentity({ ...IDENTITY, brightness: 1 + outside })).toBe(false)
expect(isCardHarmonyIdentity({ ...IDENTITY, saturation: 1 - outside })).toBe(false)
})
})
describe('applyCardPhotoHarmony', () => {
it('copies alpha byte-for-byte and never mutates the input buffer', () => {
const source = pixelsOf([
[10, 20, 30, 7],
[40, 50, 60, 200],
])
const before = Uint8ClampedArray.from(source)
const result = applyCardPhotoHarmony(source, {
brightness: 1.08,
contrast: 1,
saturation: 1.1,
})
expect(source).toEqual(before)
expect(result).not.toBe(source)
expect(result[3]).toBe(7)
expect(result[7]).toBe(200)
})
it('returns the same color for an identity adjustment', () => {
const source = pixelsOf([
[11, 22, 33],
[200, 100, 50],
])
expect([...applyCardPhotoHarmony(source, IDENTITY)]).toEqual([...source])
})
it('multiplies every channel by the brightness factor', () => {
const result = applyCardPhotoHarmony(pixelsOf([[100, 100, 100]]), {
...IDENTITY,
brightness: 1.08,
})
expect([...result]).toEqual([108, 108, 108, 255])
})
it('pushes values away from the mid range with the contrast factor', () => {
const result = applyCardPhotoHarmony(
pixelsOf([
[0, 0, 0],
[255, 255, 255],
]),
{ ...IDENTITY, contrast: 1.08 },
)
expect([...result]).toEqual([0, 0, 0, 255, 255, 255, 255, 255])
})
it('collapses color into luma when saturation goes to zero', () => {
const result = applyCardPhotoHarmony(pixelsOf([[200, 100, 50]]), {
...IDENTITY,
saturation: 0,
})
expect([...result]).toEqual([118, 118, 118, 255])
})
it('spreads the channels apart when saturation goes up', () => {
const source = pixelsOf([[200, 100, 50]])
const saturated = applyCardPhotoHarmony(source, { ...IDENTITY, saturation: 1.2 })
expect(saturated[0] - saturated[2]).toBeGreaterThan(source[0] - source[2])
expect(saturated[1]).toBeLessThan(source[1])
})
it('keeps a neutral gray neutral whatever the saturation', () => {
const result = applyCardPhotoHarmony(pixelsOf([[128, 128, 128]]), {
...IDENTITY,
saturation: 1.12,
})
expect(result[0]).toBe(result[1])
expect(result[1]).toBe(result[2])
})
it('clamps at both ends without wrapping', () => {
const result = applyCardPhotoHarmony(
pixelsOf([
[250, 250, 250],
[2, 2, 2],
]),
{ ...IDENTITY, brightness: 1.08, contrast: 1.08 },
)
expect([...result].slice(0, 7)).toEqual([255, 255, 255, 255, 0, 0, 0])
})
it('is deterministic across runs', () => {
const source = pixelsOf([[123, 45, 210, 128]])
const adjustment = { brightness: 1.04, contrast: 0.96, saturation: 1.08 }
expect([...applyCardPhotoHarmony(source, adjustment)]).toEqual([
...applyCardPhotoHarmony(source, adjustment),
])
})
})