From d7d4258cd7482ac871297fdf7cee33cb47ce7f9e Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Sat, 19 Sep 2026 10:30:55 -0300
Subject: [PATCH] =?UTF-8?q?feat(S17):=20controle=20"Harmonizar=20cores"=20?=
=?UTF-8?q?no=20ready=20do=20card=20Time=20de=20voc=C3=AA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
.agents/rules/codebase-map.mdc | 4 +-
...-s17-card-time-de-voce-harmonizar-cores.md | 1 +
...ards-time-de-voce-harmonizar-cores-impl.md | 203 ++++++++++++++
...me-de-voce-harmonizar-cores-ui-design.html | 20 +-
src/components/cards/CardComposer.tsx | 65 ++++-
src/components/cards/cardCopy.ts | 8 +
src/components/cards/cardCutout.ts | 4 +-
.../cards/cardPhotoHarmonyCanvas.ts | 78 ++++++
src/components/cards/useCardCutout.ts | 20 +-
src/lib/cardPhotoHarmony.ts | 163 +++++++++++
src/lib/cardPhotoTransform.ts | 6 +
tests/e2e/frontend.e2e.spec.ts | 51 ++++
tests/unit/cardPhotoHarmony.unit.spec.ts | 255 ++++++++++++++++++
13 files changed, 850 insertions(+), 28 deletions(-)
create mode 100644 docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md
create mode 100644 docs/plans/cards-time-de-voce-harmonizar-cores-impl.md
create mode 100644 src/components/cards/cardPhotoHarmonyCanvas.ts
create mode 100644 src/lib/cardPhotoHarmony.ts
create mode 100644 tests/unit/cardPhotoHarmony.unit.spec.ts
diff --git a/.agents/rules/codebase-map.mdc b/.agents/rules/codebase-map.mdc
index cb4e82a3..2fbc4007 100644
--- a/.agents/rules/codebase-map.mdc
+++ b/.agents/rules/codebase-map.mdc
@@ -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//` — 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//` — 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`).
diff --git a/docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md b/docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md
new file mode 100644
index 00000000..c91fdd99
--- /dev/null
+++ b/docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md
@@ -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.
diff --git a/docs/plans/cards-time-de-voce-harmonizar-cores-impl.md b/docs/plans/cards-time-de-voce-harmonizar-cores-impl.md
new file mode 100644
index 00000000..88d8557c
--- /dev/null
+++ b/docs/plans/cards-time-de-voce-harmonizar-cores-impl.md
@@ -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 `
-
- DEGRADED: este design não está certificado. É insumo para decisão humana
- no gate/PR — o tier degradado nunca aprova.
-
1280px · desktop
)
+/**
+ * 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 (
+
+ )
+}
+
type CardComposerProps = {
model: CardModel
shell: 'dialog' | 'drawer'
@@ -121,6 +165,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const [previewImage, setPreviewImage] = useState(null)
const [downloadError, setDownloadError] = useState(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(null)
const fileInputRef = useRef(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 ? (
+ setHarmonyEnabled((enabled) => !enabled)}
+ />
+ ) : null}
diff --git a/src/components/cards/cardCopy.ts b/src/components/cards/cardCopy.ts
index 419880b3..f71cef9b 100644
--- a/src/components/cards/cardCopy.ts
+++ b/src/components/cards/cardCopy.ts
@@ -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).
diff --git a/src/components/cards/cardCutout.ts b/src/components/cards/cardCutout.ts
index 44ed988c..cfa5d842 100644
--- a/src/components/cards/cardCutout.ts
+++ b/src/components/cards/cardCutout.ts
@@ -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
diff --git a/src/components/cards/cardPhotoHarmonyCanvas.ts b/src/components/cards/cardPhotoHarmonyCanvas.ts
new file mode 100644
index 00000000..2eac4f1b
--- /dev/null
+++ b/src/components/cards/cardPhotoHarmonyCanvas.ts
@@ -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()
+
+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 => {
+ 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
+ }
+}
diff --git a/src/components/cards/useCardCutout.ts b/src/components/cards/useCardCutout.ts
index f0b52352..46a4e430 100644
--- a/src/components/cards/useCardCutout.ts
+++ b/src/components/cards/useCardCutout.ts
@@ -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({ status: 'idle' })
const fileRef = useRef(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(() => {
diff --git a/src/lib/cardPhotoHarmony.ts b/src/lib/cardPhotoHarmony.ts
new file mode 100644
index 00000000..0e1c8ef0
--- /dev/null
+++ b/src/lib/cardPhotoHarmony.ts
@@ -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
+}
diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts
index 3e0c7ebd..7c9c50f8 100644
--- a/src/lib/cardPhotoTransform.ts
+++ b/src/lib/cardPhotoTransform.ts
@@ -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
diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts
index 62c389a1..710dc122 100644
--- a/tests/e2e/frontend.e2e.spec.ts
+++ b/tests/e2e/frontend.e2e.spec.ts
@@ -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()
diff --git a/tests/unit/cardPhotoHarmony.unit.spec.ts b/tests/unit/cardPhotoHarmony.unit.spec.ts
new file mode 100644
index 00000000..9413d521
--- /dev/null
+++ b/tests/unit/cardPhotoHarmony.unit.spec.ts
@@ -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) =>
+ new Uint8ClampedArray(
+ colors.flatMap(([red, green, blue, alpha = 255]) => [red, green, blue, alpha]),
+ )
+
+const toneOf = (overrides: Partial = {}): 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),
+ ])
+ })
+})