feat(S16): banner azul dinâmico no card "Time de você" — nomes longos cabendo (Closes #1195)

This commit is contained in:
Francisco Solla 2026-09-19 12:39:55 -03:00
parent 88d6ff3f1c
commit dbd9ecdfd4
9 changed files with 337 additions and 22 deletions

View file

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

View file

@ -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<br/>banner: width 509 (piso) + maxWidth 1000<br/>slot: maxInkWidth 961 · minCapHeight 40"] --> F["cardNameFit.fitCardName<br/>ok.inkWidth no fontSize final"]
F --> R["cardRender.drawCardName (center)<br/>resolveCardBannerWidth: min(1000, max(509, ink + 39))"]
X["resolveCardBannerWidth — pura e exportada<br/>(pin sem canvas)"] --> R
R --> B["drawCardBanner (width override)<br/>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<CardNameBannerSlot, 'banner' | 'maxInkWidth'>`, 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.

View file

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

View file

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

View file

@ -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<CardNameBannerSlot, 'banner' | 'maxInkWidth'>,
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),

View file

@ -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'
@ -1847,6 +1849,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,
@ -1863,7 +1911,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).
@ -1879,6 +1927,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()

View file

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

View file

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

View file

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