diff --git a/docs/changelog/2026-09-19-s16-card-time-de-voce-nomes-longos.md b/docs/changelog/2026-09-19-s16-card-time-de-voce-nomes-longos.md new file mode 100644 index 00000000..0ce663c2 --- /dev/null +++ b/docs/changelog/2026-09-19-s16-card-time-de-voce-nomes-longos.md @@ -0,0 +1 @@ +**S16 — Card "Time de você": nomes longos cabendo no banner (2026-09-19):** o banner azul do nome deixa de ter largura fixa — mantém a referência 509 do S15 quando o nome cabe e alarga só o necessário até o teto 1000 (≈40px de margem de cada lado; pode ultrapassar o banner vermelho 693), com o canto mais baixo em ≈436,5 sem cruzar o topo da janela da foto (y=439). A tinta máxima sobe de 470 para 961 e o cap mínimo legível desce de 56 para 40 (decisão do humano no gate, 2026-09-19): MARIA DA CONCEIÇÃO passa a caber e só os absurdos abaixo do piso (BARTOLOMEUCOSTAJUNIOR cap 38, ANTICONSTITUCIONALISSIMAMENTE cap 29, medidos com a Brexter real) seguem `too-long` com a mesma mensagem e CTA desabilitado — nunca corte silencioso. Núcleo puro ganhou `CardNameFit.ok.inkWidth` (medido no fontSize final), `CardBanner.maxWidth` (azul 1000; vermelho fixo 693) e `resolveCardBannerWidth(slot, inkWidth) = max(width, min(maxWidth, inkWidth + (maxWidth − maxInkWidth)))` (padding 39 = 1000−961 = 509−470); `drawCardBanner` recebe a largura resolvida e só o `fillRect` do banner azul muda — centro/y/altura/rotação, o banner vermelho, o modelo de nome e os outros 3 modelos ficam intocados. Unit dos 3 specs puros reescrito (fake 0,84em espelha a Brexter; 6 pares do design pinados no resolver; guard do canto/margens) e e2e do `frontend` com nome composto (`Maria Eduarda`) + sonda que mede o alargamento real (>700px sólidos de `#0061A5` numa linha) e o download de PNG. Sem migration/Consent/access; 100% client-side. Design gate DEGRADED (artefato do tier 3) com crítica final do `designer` no tier 1 (paridade certificada, sem divergências) e sign-off humano registrado no PR. Verificação: `pnpm gate:fast` (4049 unit verdes), e2e da superfície 9/9 e spec `frontend` completo executado; fronteira real-font não pinada no e2e por flakiness pré-existente da prontidão da fonte (evidência no impl plan, fora do escopo). diff --git a/docs/plans/cards-time-de-voce-nomes-longos-impl.md b/docs/plans/cards-time-de-voce-nomes-longos-impl.md new file mode 100644 index 00000000..af8d45d8 --- /dev/null +++ b/docs/plans/cards-time-de-voce-nomes-longos-impl.md @@ -0,0 +1,142 @@ +# Impl: Card "Time de você": nomes longos cabendo no banner + +Status: aprovado +Atualizado em: 2026-09-19 +Issue: #1195 +Intenção: docs/plans/cards-time-de-voce-nomes-longos.md +Appetite restante: herdado — ~0,5–1 dia eng (3 fases ≤0,75 dia; sem migration) + +## Leitura da intenção + +- **Outcome:** o banner azul do "Time de você" deixa de ter largura fixa — mantém a referência 509 quando o nome cabe nela e alarga só o necessário até o teto 1000 — e nomes compostos (MARIA EDUARDA, PEDRO HENRIQUE, MARIA DA CONCEIÇÃO…) cabem em uma linha com a fonte real; fora do teto (cap mínimo 40) a pessoa recebe a mesma mensagem de nome curto. Centro (545), y (313), altura (176), rotação (−4,1°), banner vermelho e demais modelos ficam intocados. +- **O que NÃO negociar:** + - Uma linha, sem quebra e sem corte silencioso; fora do teto → mensagem existente, CTA desabilitado. + - Geometria medida do S15 literal: azul centro (545,313) 176 de altura −4,1° `#0061A5` texto `#FFFFFF`; vermelho 693×118 centro (545,162) −3,5° `#E50E2F`/`#FFEC01`; janela da foto `{286,439,592,577}`. + - O banner alargado não cruza o topo da janela da foto (y=439) nem as margens do card. + - `NAME_CARD_SLOT` (714/48, 2 linhas) e o modelo de nome intocados; os outros 3 modelos do funil intocados; `team-card-example.jpg` congelado, fora do escopo. + - 100% client-side: nome/foto nunca saem do aparelho; sem persistência nem analytics. +- **O que reavaliar (hipóteses):** + - **"Basta afrouxar `maxInkWidth` e baixar `minCapHeight`"** — insuficiente: `drawCardBanner` desenha o retângulo com a largura fixa do modelo (`cardRender.ts:101`) e o fit descarta a tinta medida (`cardNameFit.ts:61-66`). Sem expor a tinta e resolver a largura, o nome "cabe no fit" e transborda o banner (Decisão 1). + - **"Os pins de unit existentes continuam"** — não: com o teto novo `Maria` deixa de encolher (`cardNameFit.unit.spec.ts:133-146`) e `Maria da Conceição` passa a caber (`:148-157`); além disso o fake de 0,6em/char passaria a classificar os dois absurdos pinados como ok (Decisão 2). + - **"O e2e não muda"** — o teste feliz usa `Maria` (curto, não exercita o alargamento); a aceitação com a Brexter só fica provada com um nome composto (Decisão 3). + +## Abordagem recomendada + +```mermaid +flowchart LR + M["cardModels
banner: width 509 (piso) + maxWidth 1000
slot: maxInkWidth 961 · minCapHeight 40"] --> F["cardNameFit.fitCardName
ok.inkWidth no fontSize final"] + F --> R["cardRender.drawCardName (center)
resolveCardBannerWidth: min(1000, max(509, ink + 39))"] + X["resolveCardBannerWidth — pura e exportada
(pin sem canvas)"] --> R + R --> B["drawCardBanner (width override)
só o fillRect do banner azul muda"] + L["TEAM_CARD_LABEL 693×118"] -.-> B + C["CardComposer — nameError (intocado)"] -.-> F +``` + +**Opções consideradas:** A) aditivo no núcleo puro (2 campos + 1 função pura; fit e render nos donos atuais) | B) re-medir a tinta dentro do render (`drawCardName` passa a receber `measure`) | C) guardar tinta/fontSize no `CardComposer` e passar a largura ao render. +**Recomendação:** A — o fit já mede a tinta e o render já é o dono da geometria do banner; expor `inkWidth` e resolver a largura com os literais do modelo mantém tudo testável sem DOM, sem plumbing novo e sem tocar no slot left (`CardNameTileCanvas`). +**Rejeitadas:** B porque duplica a medição, muda a assinatura de `drawCardName` (2 call sites) e o mesmo `renderTeamCard` roda no preview e no download — o `measure` teria de viajar por todo o caminho; C porque leva uma fórmula de geometria para o componente, com 2 call sites e sem pin puro. + +### Decisões de engenharia + +1. **A tinta entra no fit (`CardNameFit.ok.inkWidth`) e a largura é resolvida no render (`CardBanner.maxWidth` + `resolveCardBannerWidth`).** + Opções: A) `inkWidth` no shape ok (medido no fontSize final; 2 linhas = a linha mais larga; recomputado em `fitCardName` via `widthAt`, com `fitSingleLine`/`fitTwoLines` intactos) + `maxWidth` novo em `CardBanner` (`TEAM_CARD_NAME_BANNER.maxWidth: 1000`, label 693 fixo) + `resolveCardBannerWidth(banner, maxInkWidth, inkWidth) = min(maxWidth, max(width, inkWidth + (maxWidth − maxInkWidth)))` pura/exportada em `cardRender.ts` + `CardBannerDrawArgs.width?` como override do `fillRect` | B) `drawCardName` recebe `measure` e re-mede a linha no fontSize do fit | C) composer guarda tamanho/tinta e passa `width` ao render. + Recomendação: A — o par do teto dá o padding exato (`1000 − 961 = 39`, coincidente com o par de referência do S15 `509 − 470`), então a fórmula fica sem literal: os 6 exemplos medidos do design viram pin exato (470→509, 527→566, 807→846, 925→964, 951→990, 952→991, 961→1000); nome curto (tinta ≤470) continua 509 e nada passa de 1000; o resto do render não muda (centro, y, altura, rotação e a ordem das ops continuam). Nenhum componente muda. + Rejeitadas: B porque a medição já é do fit e a assinatura pioraria para todos os call sites (tile, composer, download); C porque joga a geometria para fora do `lib` puro e não cobre o download sem duplicar a fórmula. + +2. **Harness dos units: fake determinístico de 0,84em/char (não manter 0,6em com nome fictício).** + Opções: A) subir o fake para 0,84em/char (cap 0,72em mantido) nos dois specs (`cardNameFit.unit.spec.ts:12-16`, `cardRender.unit.spec.ts:56-63`), mantendo os dois absurdos pinados como `too-long` | B) manter 0,6em e pinar um nome fictício ≥29 chars como `too-long`. + Recomendação: A — com o teto 961/cap 40, o fake de 0,6em faz `BARTOLOMEUCOSTAJUNIOR` (cap ~54,7) e `ANTICONSTITUCIONALISSIMAMENTE` (cap ~41) passarem; o unit afirmaria o oposto do comportamento real justo na fronteira que este item muda e o pin de render (`cardRender.unit.spec.ts:236-241`) quebraria. 0,84em é ≥ a Brexter nos nomes do aceite (0,81–0,85 medidos) e mantém os dois absurdos too-long (caps ~39 e ~29), espelhando o produto; a fronteira real (cap 40) fica coberta pelo fake em unit (medição direta do TTF confirma cap 38/29 para os dois absurdos) e o e2e prova o caminho real com a Brexter no nome composto (Decisão 3) — a tentativa de pinar a fronteira exata no e2e foi revertida: a medição com a fonte real é intermitente no composer (ver Riscos). Os dois pins do slot do time que mudam de resultado (`Maria` deixa de encolher; `Maria da Conceição` passa a caber) são reescritos: nome do aceite que encolhe (`Guilherme`) no teste de shrink e `Maria da Conceição` virando caso positivo. + Rejeitadas: B porque o fake continuaria mentindo na faixa 21–28 chars e só um nome inventado de 29+ chars ficaria fora — o absurdo real do e2e não teria eco no unit; C (medir a fonte real no unit) porque o lib é DOM-free por decisão do S13 e fidelidade de fonte se prova no e2e/craft. + +3. **E2E: o teste feliz passa a usar nome composto + sonda do banner alargado.** + Opções: A) trocar o nome do teste feliz para `Maria Eduarda` (aceite medido: cap 62) e, depois do `Confira seu card`, varrer o band do banner (y∈[250,400]) contando pixels `#0061A5` por linha — exigir uma linha com >700 matches (a referência 509 dá ~505 e o fundo tem ≤1/linha; medido no master) | B) manter `Maria` e confiar no unit/resolver | C) teste dedicado novo só para o nome composto (fluxo de upload/processing duplicado). + Recomendação: A — o e2e é a única prova com a Brexter no CI; `Maria Eduarda` prova a mensagem ausente + CTA habilitado + download real, e a varredura por linha prova que o retângulo cresceu além de 509 (a cor exata evita o falso positivo do azul do próprio master, ~(0,131,173) vs banner (0,97,165)). O teste de too-long (`Anticonstitucionalissimamente`, cap real 30 < 40) fica como está — ele é too-long em qualquer uma das fontes (Brexter ou fallback). Pin de fronteira (BARTOLOMEUCOSTAJUNIOR, cap 38) foi tentado e revertido: a medição real é intermitente no composer (2/8 execuções mediram com fallback → cap 46; ver Riscos); a fronteira fica no unit fake. Retry/mobile/console-error não mudam. Sem entrada nova no manifest de e2e: `src/lib/card*` e `src/components/cards` já acordam o spec `frontend` (`scripts/lib/e2e-affected-manifest.mjs:75-78`). + Rejeitadas: B porque o CI não provaria a aceitação de nomes compostos que o produto pediu; C porque duplica o custo do fluxo (stub de recorte + upload) sem aumentar a prova. + +4. **`maxWidth` obrigatório no `CardBanner`; vermelho fixo por construção.** + Opções: A) `CardBanner.maxWidth` requerido — `TEAM_CARD_LABEL.maxWidth: 693` (= width, fixo) e `TEAM_CARD_NAME_BANNER.maxWidth: 1000`; o label continua desenhado direto por `drawCardBanner` sem override e, mesmo se roteado pelo resolver, o `min(693, …)` o mantém 693 | B) `maxWidth?` opcional só no banner do nome | C) tipo separado `CardDynamicBanner` só para o nome. + Recomendação: A — o invariante "o vermelho nunca herda largura dinâmica" fica no tipo e na matemática, não só num comentário; o pin existente do render (`rectCalls[0].width === TEAM_CARD_LABEL.width`) segue como guarda de regressão. + Rejeitadas: B porque esquecer o campo no banner do nome regride para 509 em silêncio (sem erro de tipo) enquanto o fit aceita 961 de tinta — transbordo visual; C porque é o mesmo shape com um tipo a mais e sem call site que o exija. + +5. **Ordem/geometria: a largura não muda centro, y, altura nem rotação; o canto ≈436 é consequência do teto.** + Opções: A) só o `fillRect` do banner azul muda (`x = -w/2`, `w` resolvido); texto segue centrado em `(0, capHeight/2)` e a sequência `save→translate→rotate→fillRect→fillText→restore` não muda; a garantia do canto (no teto, o canto mais baixo fica em `313 + 500·sen 4,1° + 88·cos 4,1° ≈ 436,5 < 439`) fica documentada no modelo e guardada por unit derivado do modelo | B) clampar largura/margem também no render | C) escalar altura/posição com a largura. + Recomendação: A — é a geometria medida do S15 intocada; não desenhar além do teto (o `min()` do resolver) é a garantia, e o comentário de manutenção + o guard unit impedem que uma edição futura do modelo cruze a janela da foto. + Rejeitadas: B porque clamp duplo mascara a fonte do valor (se o modelo estiver errado, o teste deve falhar — não o render consertar); C porque viola a geometria medida. + +6. **Processo/verificação: sem migration/Consent/access; fechamento visual com crítica (c) + sign-off humano obrigatório.** + Opções: A) nada de schema/Payload (edição em `src/lib` + testes); no fechamento, crítica visual do `designer` (trigger c) contra as cenas 1/2/7 do artefato com screenshots 390/1280 (curto, composto, fora do teto) e sign-off humano antes do `pnpm push` — o artefato é `DEGRADED` (tier 3, `opencode-go/deepseek-v4.1-flash` via `designer-degraded`) e tier degradado nunca certifica; o PR Ready registra `Design tier: DEGRADED (cards-time-de-voce-nomes-longos)` + o sign-off; `--auto` **para** na certificação visual (a flag não cobre) | B) fechar sem crítica por ser "só um número" | C) regerar o artefato no frontier antes de fechar. + Recomendação: A — Impeccable C muda UI; o pipeline exige a crítica (c) e o sign-off; a parada do modo autônomo na certificação visual `DEGRADED` está explícita na skill. + Rejeitadas: B (pularia o contrato de design fail-closed), C (a quota do frontier bateu — o próprio artefato registra a queda ao tier 3; re-tentar não é o caminho do ladder). + +### Componentes / mudanças + +- **`src/lib/cardModels.ts`** (editar): `CardBanner` ganha `maxWidth: number`; `TEAM_CARD_LABEL.maxWidth: 693`; `TEAM_CARD_NAME_BANNER.maxWidth: 1000`; `TEAM_CARD_NAME_SLOT.maxInkWidth` 470→961 e `minCapHeight` 56→40; comentário de manutenção no banner/slot (canto ≈436 < y=439; padding 39 = `maxWidth − maxInkWidth`). `NAME_CARD_SLOT` intocado. +- **`src/lib/cardNameFit.ts`** (editar): `CardNameFit.ok` ganha `inkWidth` (tinta no fontSize final; 2 linhas = a maior), computado em `fitCardName` (`:114-149`) via `widthAt`; `fitSingleLine` (`:55-69`) e `fitTwoLines` intactos. +- **`src/lib/cardRender.ts`** (editar): `resolveCardBannerWidth` pura exportada; `CardBannerDrawArgs.width?: number` e `drawCardBanner` usando `args.width ?? banner.width` (`:101`); ramo center do `drawCardName` (`:120-130`) resolve a largura com `fit.inkWidth`; `renderTeamCard` (`:211-240`) e o label intocados. +- **`src/components/cards/CardComposer.tsx` / `CardNameTileCanvas.tsx`**: sem mudança — o composer já desenha pelo fit/render e usa `nameFit.ok` só para a mensagem (`:374-407`); o tile usa o slot de nome (left). +- **Testes** (editar): `tests/unit/cardModels.unit.spec.ts` (maxWidth 693/1000; slot 961/40; guard do canto `< 439` e das margens no teto); `tests/unit/cardNameFit.unit.spec.ts` (fake 0,84; `Maria`→`Guilherme` encolhendo; `Maria da Conceição` ok; too-long com `Bartolomeucostajunior`+`Anticonstitucionalissimamente`; `inkWidth` ≤ 961); `tests/unit/cardRender.unit.spec.ts` (fake 0,84; `resolveCardBannerWidth` com os 6 pares do design + piso 509/teto 1000 + label fixo 693; retângulo azul com a largura resolvida em `:212-215` e `:253-260`; `Bartolomeucostajunior` too-long em `:236-241`); `tests/e2e/frontend.e2e.spec.ts` (describe S15: feliz com `Maria Eduarda` + sonda; demais sem mudança). +- **`docs/changelog/2026-09-19-s16-card-time-de-voce-nomes-longos.md`** (novo). +- **Migration:** sem migration — nenhum schema/collection/global; `push:false` intocado. +- **Access / Consent:** não se aplica — nenhuma superfície Payload; nome/foto continuam insumos locais. +- **UI:** Impeccable C — a estrutura visual é a do artefato `docs/plans/cards-time-de-voce-nomes-longos-ui-design.html` (cenas 1/2/7); o "port" aqui é a fórmula/limites no canvas (sem CSS novo); crítica (c) + sign-off humano no fechamento (`DEGRADED` tier 3). + +### Dados → forma (se aplicável) + +Não se aplica (data-presentation Q3): nenhum KPI, série ou mapa; a única "forma" é a geometria do próprio card e ela permanece a medida do S15 (só a largura do banner azul varia). + +## Fases verificáveis + +1. **Núcleo puro (modelo + fit + resolver)** — quota ~0,25 dia. `cardModels` (maxWidth/cap 40/tinta 961 + comentário de manutenção), `cardNameFit` (`inkWidth`), `cardRender` (`resolveCardBannerWidth` + override no `drawCardBanner`); units atualizados. + Prova: `pnpm gate:fast` verde; `resolveCardBannerWidth` pina os seis pares do design (470→509 … 961→1000), o piso/teto e o label fixo; fake 0,84 mantém os dois absurdos too-long e faz `Maria da Conceição` caber; `cardModels` pina o canto < 439; pin do vermelho (693) verde. +2. **E2E da aceitação com a fonte real** — quota ~0,25 dia. Teste feliz com `Maria Eduarda` + varredura do banner; too-long intacto; nenhuma outra mudança no describe. + Prova: `pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts` verde (29 testes; resultado + download PNG com o nome composto; linha do banner com >700 pixels `#0061A5`; retry/mobile/console-error intactos). Pin de fronteira real-font tentado e revertido por flakiness pré-existente da fonte (Riscos); fronteira coberta pelo unit fake. +3. **Fechamento visual + gates** — quota ~0,25 dia. Crítica do `designer` (trigger c) contra as cenas 1/2/7 com screenshots 390/1280 dos três estados (curto/composto/fora do teto) + sign-off humano (DEGRADED); changelog; `pnpm gate:fast`; `pnpm push`. + Prova: crítica + sign-off registrados no PR (`Design tier: DEGRADED (cards-time-de-voce-nomes-longos)`); CI (PR) verde. + +## Rabbit holes / Não escopo (engenharia) + +- Duas linhas/auto-quebra no banner do time; cortar/elidir; encolher abaixo de 40 (a saída é a mensagem). +- Fazer o fit (ou o render) dono da largura do banner: o fit mede a tinta, o render resolve a largura. +- Tornar o `TEAM_CARD_LABEL` dinâmico ou generalizar os dois banners num "banner parametrizado". +- Clamp duplo de largura no render; heurísticas de "banner grande demais" sem evidência. +- Medir a fonte real no unit/jsdom; snapshot visual de canvas. +- Tocar no modelo de nome (S13), nos outros 3 modelos, nas artes-mestre ou em `team-card-example.jpg`. +- Migration/CMS/flag/analytics para nome ou largura. +- Refatorar `drawCardBanner`/`drawCardName` para um pipeline genérico. + +## Riscos e mitigação + +- **O fake de 0,84em não é a Brexter:** a fronteira real (cap ≥ 40 para `Maria Eduarda`; cap 30 para o absurdo) é provada no e2e com a fonte real; o fake é conservador (≥ Brexter nos nomes medidos) e o unit pina a fórmula, não o valor da fonte. +- **O fit "caber" e o banner não crescer (override esquecido):** unit de render pina `fillRect.width` com o valor do resolver e o e2e exige uma linha do banner com >700 pixels `#0061A5`. +- **Herdar largura dinâmica no banner vermelho:** `maxWidth = width = 693` no label + resolver com `min(693, …)` + pin do retângulo vermelho no render. +- **Regressão no modelo de nome (compartilha `fitCardName`):** `NAME_CARD_SLOT` intocado (714/48, 2 linhas); o shape ganha só `inkWidth`; os pins do S13/S14 continuam. +- **Sonda de pixel instável (rotação/antialias):** cor exata `#0061A5` com tolerância curta e contagem por linha (o fundo do master tem ≤1 match/linha; a referência 509 dá ~505), sem depender de glifo nem de posição única. +- **Futuro: teto maior cruzar a janela da foto:** guard unit do canto mais baixo derivado do modelo (`< 439`) + comentário de manutenção; o teto é o `min()` do resolver. +- **Fechamento visual:** artefato `DEGRADED` não certifica → crítica (c) + sign-off humano explícito antes do push; sem sign-off, a sessão para (nunca "segue sem"). +- **Fonte no canvas:** o banner só desenha depois de `ensureCardFont` (como no S15); a varredura espera o render estabilizar (`expect.poll`). +- **Prontidão da fonte é intermitente no composer (pré-existente, S15):** sonda no e2e real mostrou que em 6/8 execuções o fit mediu com o fallback (`ctx.font` 66px = métricas de sans-serif) e em 2/8 com a Brexter (54px), apesar de `ensureCardFont` aguardar `document.fonts.load` (que resolveu `loaded` nas execuções instrumentadas); o pin de fronteira BARTOLOMEUCOSTAJUNIOR ficou flaky (2/6 falhas, reproduzido 2/8 com e sem `document.fonts.ready`). Fora do escopo do S16 — a entrega usa o unit fake para a fronteira e mantém o e2e com nomes que são too-long em qualquer fonte. Pela triage do capture-review-debts (pré-existente em `main`, fora do escopo) fica **descartado deste lote**: a evidência e o smell de `ensureCardFont` checar `faces.length > 0` sem o `status` vivem neste plano e no body do PR; Issue própria só a pedido do humano. + +## Revisão da sessão (simplify) — resolvido, adiado com gatilho, fora + +**Resolvido no simplify (não reabrir):** `CardBannerDrawArgs.width` obrigatório (sem fallback silencioso para a largura do modelo); `resolveCardBannerWidth` com `max(width, min(maxWidth, …))` (nunca abaixo da referência) e assinatura por slot (`Pick`, sem par de widths trocável); guard de margem x do unit com a rotação (`halfSpanX`); comentários precisos (padding 39 = 1000 − 961 = 509 − 470; "model-pixel"; canto como checagem conservadora); JSDoc de proveniência do cap 40/tinta 961; sonda e2e renomeada (`widestNameBannerRun`) com banda derivada do modelo e cor do `background`; nomes de teste e do resolver alinhados ao comportamento real. + +**Adiados com gatilho (engenharia):** + +- **Devolver a tinta medida pelo `fitSingleLine`/`fitTwoLines`** (hoje o caller re-mede 1–2 `measureText` por tecla) — risco de jank no canvas não medido e churn nos helpers compartilhados. Gatilho: jank medido ao digitar OU 5º modelo. +- **Calibrar o fake de unit a partir do TTF real** (0,84em/cap 0,72 vs medido 0,70–0,85 por nome) — o unit pina a fórmula, a fonte real é do e2e. Gatilho: nova mudança da fronteira de cap/tinta → medir o TTF e ajustar o fake. + +**Explicitamente fora (não reabrir):** pin e2e da fronteira cap 40 com a fonte real (flaky pelo comportamento pré-existente do S15 — ver Riscos; descartado deste lote pela triage); `ensureCardFont` checando só `faces.length > 0` (pré-existente, fora do escopo; Issue própria só a pedido); banda/cor da sonda em constantes do próprio modelo (derivadas — decisão da sessão); `width` como parte do `CardBanner` (o override fica nos args de desenho). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: nomes compostos do aceite cabem (e2e com Brexter para `MARIA EDUARDA`; unit pinando os limites/fórmula dos demais), banner dinâmico 509→1000 com a referência preservada para nome curto, fora do teto a mensagem existente e CTA desabilitado, geometria/vermelho/modelo de nome/outros modelos/exemplo congelado intocados. +- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent/access; `src/lib` puro e sem DOM (tinta medida pelo `CardMeasureText` injetado); copy pt-BR/identificadores em inglês; sem `as never`; knip sem órfãos; `pnpm gate:fast` + e2e da superfície + `pnpm push` verdes. +- [ ] Testes de domínio previstos: unit dos módulos puros (catálogo/slot/canto; fit com `inkWidth` e pins reescritos; resolver com os 6 pares; largura desenhada no render) obrigatório; e2e no spec `frontend` (feliz com nome composto + varredura; too-long; retry/mobile) obrigatório; **int não se aplica** — não há Payload/DB/access nem escrita multi-collection (100% client-side). + +## Self-score decision-quality: 5/5 + +1. **Decisões caras com rejeitadas:** onde a tinta entra/o dono da largura, harness do fake, cobertura e2e, `maxWidth`/vermelho fixo, geometria/ordem e processo de fechamento — todas com Opções/Recomendação/Rejeitadas. +2. **Cabe no appetite:** 3 fases ≤0,75 dia, sem migration/infra/plumbing; tracer no núcleo puro antes do e2e. +3. **Rabbit holes nomeados:** duas linhas, corte/elipse, fit dono da largura, label dinâmico, clamp duplo, fonte real no unit, refactor de pipeline, CMS/analytics. +4. **Depth check:** reusa `fitCardName`/`CardMeasureText`, `drawCardName`/`drawCardBanner` e o fallback de fonte existentes; 2 campos + 1 função pura; nenhuma abstração nova para <3 call sites; componentes intocados. +5. **Intenção preservada:** a engenharia resolve só o "como" (tinta/largura/limites), sem reescrever o outcome — geometria medida, mensagem, escopo e privacidade intactos. diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index 9012efd7..f89aa251 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -109,6 +109,8 @@ export type CardBanner = { centerY: number width: number height: number + /** Model-pixel ceiling the width may grow to (S16); equals `width` when fixed. */ + maxWidth: number rotationDeg: number background: string } @@ -154,6 +156,7 @@ export const TEAM_CARD_LABEL = { centerX: 545, centerY: 162, width: 693, + maxWidth: 693, height: 118, rotationDeg: -3.5, background: '#e50e2f', @@ -161,22 +164,38 @@ export const TEAM_CARD_LABEL = { capHeight: 93, } as const +/** + * S16 — the blue name banner is dynamic: `width` is the S15 reference kept while + * the fitted ink fits the reference band (`width - padding` = 470) and + * `maxWidth` (1000) is the ceiling the banner grows to only as needed. The + * ceiling keeps the lowest rotated corner (`313 + 500·sin 4.1° + 88·cos 4.1°` + * ≈ 436.5 — conservative corner check) above the photo window top (y=439); it is + * also the pair that fixes the ink padding: `maxWidth - slot.maxInkWidth = 39` + * (= the S15 pair `509 - 470`). + */ export const TEAM_CARD_NAME_BANNER = { centerX: 545, centerY: 313, width: 509, + maxWidth: 1000, height: 176, rotationDeg: -4.1, background: '#0061a5', fill: '#ffffff', } as const +/** + * S16 — the readable floor drops to a 40px cap (the human gate decision of + * 2026-09-19: long compound names fit and only clearly absurd names are asked + * to shorten) and the ink ceiling becomes the banner ceiling pair: 961 = 1000 + * (banner) − 39 (padding), the same padding measured in the S15 pair 509 − 470. + */ export const TEAM_CARD_NAME_SLOT = { align: 'center', banner: TEAM_CARD_NAME_BANNER, capHeight: 130, - minCapHeight: 56, - maxInkWidth: 470, + minCapHeight: 40, + maxInkWidth: 961, fill: TEAM_CARD_NAME_BANNER.fill, maxLines: 1, } as const satisfies CardNameBannerSlot diff --git a/src/lib/cardNameFit.ts b/src/lib/cardNameFit.ts index 53d9a27e..5fa7bda6 100644 --- a/src/lib/cardNameFit.ts +++ b/src/lib/cardNameFit.ts @@ -26,6 +26,11 @@ export type CardNameFit = fontSize: number lineHeight: number capHeight: number + /** + * Measured ink at `fontSize`: the widest line (S16 — the renderer uses it + * to grow the banner only as much as the text needs). + */ + inkWidth: number } | { ok: false; reason: 'empty' | 'too-long' } @@ -128,6 +133,7 @@ export const fitCardName = ( fontSize: singleSize, lineHeight: singleSize * LINE_HEIGHT_RATIO, capHeight: capAt(measure, singleSize), + inkWidth: widthAt(measure, normalized, singleSize), } } @@ -141,6 +147,10 @@ export const fitCardName = ( fontSize: twoLines.fontSize, lineHeight: twoLines.fontSize * LINE_HEIGHT_RATIO, capHeight: capAt(measure, twoLines.fontSize), + inkWidth: Math.max( + widthAt(measure, twoLines.lines[0], twoLines.fontSize), + widthAt(measure, twoLines.lines[1], twoLines.fontSize), + ), } } } diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index d802a4dc..9fe7e9a4 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -79,6 +79,8 @@ export type CardNameDrawArgs = { type CardBannerDrawArgs = { banner: CardBanner + /** S16 — the drawn width: the model width, or the resolved dynamic one. */ + width: number text: string fill: string fontSize: number @@ -86,6 +88,23 @@ type CardBannerDrawArgs = { fontFamily: string } +/** + * S16 — resolves the name banner width from the measured ink: the model's + * reference width while the ink fits `width - padding` (= 470), then growing + * with the ink so the model's side padding (`maxWidth - maxInkWidth` = 39) is + * preserved, never past the ceiling. Assumes `maxWidth >= width` (fixed + * banners use equal values and resolve to themselves). + */ +export const resolveCardBannerWidth = ( + slot: Pick, + inkWidth: number, +): number => { + const { banner } = slot + const padding = banner.maxWidth - slot.maxInkWidth + + return Math.max(banner.width, Math.min(banner.maxWidth, inkWidth + padding)) +} + /** * S15 — draws one official top banner: the rotated rectangle plus its centered * cap-height text. Both team banners (the fixed `TIME DE` label and the fitted @@ -93,12 +112,13 @@ type CardBannerDrawArgs = { */ const drawCardBanner = (ctx: CardDrawContext, args: CardBannerDrawArgs): void => { const { banner } = args + const width = args.width ?? banner.width ctx.save() ctx.translate(banner.centerX, banner.centerY) ctx.rotate((banner.rotationDeg * Math.PI) / 180) ctx.fillStyle = banner.background - ctx.fillRect(-banner.width / 2, -banner.height / 2, banner.width, banner.height) + ctx.fillRect(-width / 2, -banner.height / 2, width, banner.height) ctx.font = `700 ${args.fontSize}px ${args.fontFamily}` ctx.fillStyle = args.fill ctx.textAlign = 'center' @@ -120,6 +140,7 @@ export const drawCardName = (ctx: CardDrawContext, args: CardNameDrawArgs): void if (slot.align === 'center') { drawCardBanner(ctx, { banner: slot.banner, + width: resolveCardBannerWidth(slot, fit.inkWidth), text: fit.lines[0], fill: slot.fill, fontSize: fit.fontSize, @@ -222,6 +243,7 @@ export const renderTeamCard = ( drawCardBanner(ctx, { banner: TEAM_CARD_LABEL, + width: TEAM_CARD_LABEL.width, text: TEAM_CARD_LABEL.text, fill: TEAM_CARD_LABEL.fill, fontSize: resolveFontSizeForCapHeight(args.measure, TEAM_CARD_LABEL.capHeight), diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 62c389a1..9a13df1c 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,6 +1,8 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' import { readFileSync } from 'node:fs' +import { TEAM_CARD_NAME_BANNER } from '@/lib/cardModels' + import { adminHeaders } from '../helpers/adminApi' import { seedTestUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' @@ -1845,6 +1847,52 @@ const uploadBustPhoto = (dialog: Locator) => buffer: TEST_PHOTO_PNG, }) +/** + * S16 — the blue name banner probe: scan only the rows where the rotated + * rectangle spans its full width (|v| ≤ h/2·cos − w/2·sin) and take the widest + * run of solid banner pixels. The banner is the only source of that exact color + * there, so a long name shows a much wider run than the 509 reference (~505 + * pixels) — the fixed-width regression fails the count. + */ +const NAME_BANNER_PROBE = (() => { + const banner = TEAM_CARD_NAME_BANNER + const tilt = (Math.abs(banner.rotationDeg) * Math.PI) / 180 + const fullChordHalfSpan = + (banner.height / 2) * Math.cos(tilt) - (banner.maxWidth / 2) * Math.sin(tilt) + + return { + background: banner.background, + band: { + top: Math.ceil(banner.centerY - fullChordHalfSpan), + bottom: Math.floor(banner.centerY + fullChordHalfSpan), + }, + } +})() + +const widestNameBannerRun = (canvas: Locator) => + canvas.evaluate((element, probe) => { + const node = element as HTMLCanvasElement + const ctx = node.getContext('2d')! + const fill = Number.parseInt(probe.background.slice(1), 16) + const red = (fill >> 16) & 0xff + const green = (fill >> 8) & 0xff + const blue = fill & 0xff + let widest = 0 + + for (let y = probe.band.top; y <= probe.band.bottom; y++) { + const { data } = ctx.getImageData(0, y, node.width, 1) + let matches = 0 + for (let index = 0; index < data.length; index += 4) { + if (data[index] === red && data[index + 1] === green && data[index + 2] === blue) { + matches += 1 + } + } + if (matches > widest) widest = matches + } + + return widest + }, NAME_BANNER_PROBE) + test.describe('Cards personalizados (S15 — Time de você)', () => { test('cuts the photo on device, reaches the result and downloads a real PNG', async ({ page, @@ -1861,7 +1909,7 @@ test.describe('Cards personalizados (S15 — Time de você)', () => { const primary = dialog.getByRole('button', { name: 'Criar meu card' }) await expect(primary).toBeDisabled() - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria Eduarda') await uploadBustPhoto(dialog) // Processing is observable (first run: model download + local cutout). @@ -1877,6 +1925,10 @@ test.describe('Cards personalizados (S15 — Time de você)', () => { await expect(primary).toBeEnabled() await expect(dialog.getByText('Arraste para ajustar')).toBeVisible() + // S16 — `Maria Eduarda` only fits by widening the blue banner beyond the + // 509 reference (the real Brexter ink goes to ~954 → banner ~993). + await expect.poll(() => widestNameBannerRun(canvas)).toBeGreaterThan(700) + await primary.click() await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts index 369d8b00..458dd83d 100644 --- a/tests/unit/cardModels.unit.spec.ts +++ b/tests/unit/cardModels.unit.spec.ts @@ -88,6 +88,7 @@ describe('card model catalog (S13/S15)', () => { centerX: 545, centerY: 162, width: 693, + maxWidth: 693, height: 118, rotationDeg: -3.5, background: '#e50e2f', @@ -98,6 +99,7 @@ describe('card model catalog (S13/S15)', () => { centerX: 545, centerY: 313, width: 509, + maxWidth: 1000, height: 176, rotationDeg: -4.1, background: '#0061a5', @@ -107,13 +109,29 @@ describe('card model catalog (S13/S15)', () => { align: 'center', banner: TEAM_CARD_NAME_BANNER, capHeight: 130, - minCapHeight: 56, - maxInkWidth: 470, + minCapHeight: 40, + maxInkWidth: 961, maxLines: 1, fill: '#ffffff', }) }) + it('keeps the dynamic name banner ceiling inside the measured card bounds (S16)', () => { + const model = getCardModel('time-de-voce')! + const halfWidth = TEAM_CARD_NAME_BANNER.maxWidth / 2 + const halfHeight = TEAM_CARD_NAME_BANNER.height / 2 + const tilt = (Math.abs(TEAM_CARD_NAME_BANNER.rotationDeg) * Math.PI) / 180 + const halfSpanX = halfWidth * Math.cos(tilt) + halfHeight * Math.sin(tilt) + const lowestCorner = + TEAM_CARD_NAME_BANNER.centerY + halfHeight * Math.cos(tilt) + halfWidth * Math.sin(tilt) + + // The ceiling preserves the S15 ink padding: 1000 − 961 = 509 − 470 = 39. + expect(TEAM_CARD_NAME_BANNER.maxWidth - TEAM_CARD_NAME_SLOT.maxInkWidth).toBe(39) + expect(TEAM_CARD_NAME_BANNER.centerX - halfSpanX).toBeGreaterThanOrEqual(0) + expect(TEAM_CARD_NAME_BANNER.centerX + halfSpanX).toBeLessThanOrEqual(model.width) + expect(lowestCorner).toBeLessThan(model.photoWindow!.y) + }) + it('sanitizes query-param values through isCardModelId', () => { expect(isCardModelId('eu-sou-solla')).toBe(true) expect(isCardModelId('perfil-quadrado')).toBe(true) diff --git a/tests/unit/cardNameFit.unit.spec.ts b/tests/unit/cardNameFit.unit.spec.ts index 3e42aee0..4c1a64a3 100644 --- a/tests/unit/cardNameFit.unit.spec.ts +++ b/tests/unit/cardNameFit.unit.spec.ts @@ -8,9 +8,9 @@ import { type CardMeasureText, } from '@/lib/cardNameFit' -/** Deterministic fake: 0.6em per char, 0.72em cap, 0.2em descent. */ +/** Deterministic fake: 0.84em per char (Brexter-like), 0.72em cap, 0.2em descent. */ const measure: CardMeasureText = (text, fontSize) => ({ - width: text.length * fontSize * 0.6, + width: text.length * fontSize * 0.84, actualBoundingBoxAscent: fontSize * 0.72, actualBoundingBoxDescent: fontSize * 0.2, }) @@ -47,6 +47,7 @@ describe('fitCardName', () => { if (fit.ok) { expect(fit.capHeight).toBeCloseTo(NAME_CARD_SLOT.capHeight, 5) expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) + expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5) } }) @@ -127,13 +128,14 @@ describe('fitCardName', () => { expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual( TEAM_CARD_NAME_SLOT.maxInkWidth, ) + expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5) } }) it('shrinks a name that overflows the ink width down to the readable minimum', () => { - const fit = fitCardName('Maria', measure, TEAM_CARD_NAME_SLOT) + const fit = fitCardName('Guilherme', measure, TEAM_CARD_NAME_SLOT) - expect(fit).toMatchObject({ ok: true, lines: ['MARIA'] }) + expect(fit).toMatchObject({ ok: true, lines: ['GUILHERME'] }) if (fit.ok) { expect(fit.capHeight).toBeLessThan(TEAM_CARD_NAME_SLOT.capHeight) expect(fit.fontSize).toBeGreaterThanOrEqual( @@ -145,12 +147,23 @@ describe('fitCardName', () => { } }) + it('fits a long compound name within the new ceiling (S16)', () => { + const fit = fitCardName('Maria da Conceição', measure, TEAM_CARD_NAME_SLOT) + + expect(fit).toMatchObject({ ok: true, lines: ['MARIA DA CONCEIÇÃO'] }) + if (fit.ok) { + expect(fit.capHeight).toBeGreaterThanOrEqual(TEAM_CARD_NAME_SLOT.minCapHeight) + expect(fit.inkWidth).toBeLessThanOrEqual(TEAM_CARD_NAME_SLOT.maxInkWidth) + expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5) + } + }) + it('fails with too-long instead of wrapping below the readable minimum', () => { - expect(fitCardName('Maria da Conceição', measure, TEAM_CARD_NAME_SLOT)).toEqual({ + expect(fitCardName('Bartolomeucostajunior', measure, TEAM_CARD_NAME_SLOT)).toEqual({ ok: false, reason: 'too-long', }) - expect(fitCardName('Bartolomeucostajunior', measure, TEAM_CARD_NAME_SLOT)).toEqual({ + expect(fitCardName('Anticonstitucionalissimamente', measure, TEAM_CARD_NAME_SLOT)).toEqual({ ok: false, reason: 'too-long', }) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts index 30a28965..803f9c14 100644 --- a/tests/unit/cardRender.unit.spec.ts +++ b/tests/unit/cardRender.unit.spec.ts @@ -20,6 +20,7 @@ import { renderNameCard, renderPhotoCard, renderTeamCard, + resolveCardBannerWidth, type CardDrawContext, } from '@/lib/cardRender' @@ -56,7 +57,7 @@ const createFakeContext = () => { measureText: (text) => { const size = fontSizeFrom(ctx.font) return { - width: text.length * size * 0.6, + width: text.length * size * 0.84, actualBoundingBoxAscent: size * 0.72, actualBoundingBoxDescent: size * 0.2, } @@ -209,10 +210,14 @@ describe('renderTeamCard', () => { width: TEAM_CARD_LABEL.width, height: TEAM_CARD_LABEL.height, }) + // S16 — `MARIA` at the ideal cap (130/0.72) with the 0.84em fake measures + // ≈758.33 of ink; the banner grows by the model's 39px padding: ≈797.33. expect(rectCalls[1]).toMatchObject({ - width: TEAM_CARD_NAME_BANNER.width, + y: -TEAM_CARD_NAME_BANNER.height / 2, height: TEAM_CARD_NAME_BANNER.height, }) + expect(rectCalls[1]!.width).toBeCloseTo(797.33, 1) + expect(rectCalls[1]!.x).toBeCloseTo(-797.33 / 2, 1) expect(ops.filter((op) => op.op === 'save')).toHaveLength(2) expect(ops.filter((op) => op.op === 'restore')).toHaveLength(2) expect(ops.map((op) => op.op)).toEqual([ @@ -241,6 +246,39 @@ describe('renderTeamCard', () => { }) }) +describe('resolveCardBannerWidth (S16)', () => { + const slot = TEAM_CARD_NAME_SLOT + const maxInkWidth = slot.maxInkWidth + + it('pins the design table pairs (ink = width − 39: reference 509 → ceiling 1000)', () => { + const designPairs: [ink: number, width: number][] = [ + [470, 509], + [527, 566], + [807, 846], + [925, 964], + [951, 990], + [952, 991], + [961, 1000], + ] + + for (const [ink, width] of designPairs) { + expect(resolveCardBannerWidth(slot, ink)).toBe(width) + } + }) + + it('keeps the reference width for short names and clamps past the ceiling', () => { + expect(resolveCardBannerWidth(slot, 0)).toBe(slot.banner.width) + expect(resolveCardBannerWidth(slot, 200)).toBe(slot.banner.width) + expect(resolveCardBannerWidth(slot, 2000)).toBe(slot.banner.maxWidth) + }) + + it('never grows a fixed banner (the red TIME DE label)', () => { + const labelSlot = { banner: TEAM_CARD_LABEL, maxInkWidth } + expect(resolveCardBannerWidth(labelSlot, 0)).toBe(TEAM_CARD_LABEL.width) + expect(resolveCardBannerWidth(labelSlot, 5000)).toBe(TEAM_CARD_LABEL.width) + }) +}) + describe('drawCardName (centered team slot)', () => { it('draws the blue banner with the centered name through the same banner helper', () => { const { ctx, textCalls, rectCalls, ops } = createFakeContext() @@ -250,14 +288,14 @@ describe('drawCardName (centered team slot)', () => { drawCardName(ctx, { fit, fontFamily: 'Brexter', slot: TEAM_CARD_NAME_SLOT }) - expect(rectCalls).toEqual([ - { - x: -TEAM_CARD_NAME_BANNER.width / 2, - y: -TEAM_CARD_NAME_BANNER.height / 2, - width: TEAM_CARD_NAME_BANNER.width, - height: TEAM_CARD_NAME_BANNER.height, - }, - ]) + // S16 — dynamic width resolved from the fitted ink (same ≈797.33 as above). + expect(rectCalls).toHaveLength(1) + expect(rectCalls[0]).toMatchObject({ + y: -TEAM_CARD_NAME_BANNER.height / 2, + height: TEAM_CARD_NAME_BANNER.height, + }) + expect(rectCalls[0]!.width).toBeCloseTo(797.33, 1) + expect(rectCalls[0]!.x).toBeCloseTo(-797.33 / 2, 1) expect(ops).toEqual([ { op: 'save', args: [] }, { op: 'translate', args: [TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY] },