From 02ca466469f656a242e80307e149dc5f3d379e7e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 15:51:44 -0300 Subject: [PATCH] =?UTF-8?q?feat(S40):=20arte=20gen=C3=A9rica=20do=20Time?= =?UTF-8?q?=20do=20estadual=20e=20janela=20vazia=20sem=20silhueta=20(Close?= =?UTF-8?q?s=20#1316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...9-24-s40-cards-estadual-modelo-generico.md | 1 + .../cards-estadual-modelo-generico-impl.md | 141 ++++++++++++++++++ src/components/cards/CardComposer.tsx | 6 +- src/lib/cardRender.ts | 66 ++------ tests/e2e/frontend.e2e.spec.ts | 22 +-- tests/unit/cardRender.unit.spec.ts | 49 +----- tests/unit/stateDeputyCatalog.unit.spec.ts | 14 +- 7 files changed, 188 insertions(+), 111 deletions(-) create mode 100644 docs/changelog/2026-09-24-s40-cards-estadual-modelo-generico.md create mode 100644 docs/plans/cards-estadual-modelo-generico-impl.md diff --git a/docs/changelog/2026-09-24-s40-cards-estadual-modelo-generico.md b/docs/changelog/2026-09-24-s40-cards-estadual-modelo-generico.md new file mode 100644 index 00000000..9bade55d --- /dev/null +++ b/docs/changelog/2026-09-24-s40-cards-estadual-modelo-generico.md @@ -0,0 +1 @@ +**S40 — Time do estadual: a arte de modelo genérica entregue pelo humano (2026-09-24):** o tile e o placeholder de pré-escolha do modelo `time-do-estadual` deixam de ilustrar o JULIO PINHEIRO 13999 do S30 e passam a ser a arte genérica entregue pela campanha (`SEU ESTADUAL` + duas silhuetas), **byte-a-byte no MESMO caminho público** `/cards/modelo-time-de-voce-com-estadual.jpeg` (1080×1440 jpeg sRGB, sha256 `1f5dc8212662697149f0acac5701e8de58c53a86f5af316535736dccb7a53c7b`): a cópia é feita pelo dono (`pnpm build:state-deputy-card-assets` copia o asset aprovado de `docs/plans/cards-estadual-dobradinha-ui-design-assets/` e o skip por sha deixa as 106 webps intactas) e o unit do catálogo passa a pinar o sha literal novo dos dois lados (fonte aprovada × arquivo público). Na pós-escolha sem foto, a silhueta desenhada sai — decisão do gate de 2026-09-24 («deixa vazio até adicionar a foto do usuário»): `CARD_VISITOR_SILHOUETTE_FILL`, `drawCardVisitorSilhouette` e o subject `{kind:'silhouette'}` foram **removidos** do `src/lib/cardRender.ts`; o `renderTeamCard` aceita `subject: TeamCardSubject | null` (tipo achatado, sem o discriminante morto) e compõe base → overlay → banner `TIME DE` → banner do nome, então a janela do visitante fica vazia (a arte do estadual escolhido como está) até a foto entrar, como o `Time de você` — a pré-escolha segue com as duas silhuetas da própria arte entregue (não são desenho). Pins acompanham a remoção: unit `cardRender` com o novo teste "sem foto" (base + overlay + banners, `transform: null`, zero ops de `beginPath/arc/ellipse/rect/fill`) e e2e S30 com o probe `(582,540)` = `[239,223,196,255]` (a cor da arte do JULIO na janela; antes `[0,26,66,255]`). Sem migration, sem superfície/copy/estado/analytics novo; fluxo, seletor, catálogo das 53, as demais artes e o evento S32 intocados. Crítica final do `designer` (trigger c) **certificada** em tier primário (`Design tier: openai/gpt-5.6-sol`), zero ajustes. diff --git a/docs/plans/cards-estadual-modelo-generico-impl.md b/docs/plans/cards-estadual-modelo-generico-impl.md new file mode 100644 index 00000000..249c9f79 --- /dev/null +++ b/docs/plans/cards-estadual-modelo-generico-impl.md @@ -0,0 +1,141 @@ +# Impl: S40 — Time do estadual: a arte de modelo genérica entregue pelo humano (sem estadual específico) + +Status: aprovado +Atualizado em: 2026-09-24 +Issue: #1316 +Intenção: docs/plans/cards-estadual-modelo-generico.md +Appetite restante: herdado — ~0,5 dia eng; 4 fases somam ~0,5 dia, sem migration, sem asset novo (a arte já está no repo desde 2515e86b), sem dependência nova + +## Leitura da intenção + +- **Outcome:** o tile e o placeholder de pré-escolha do `Time do estadual` passam a ser a arte genérica aprovada (`SEU ESTADUAL` + duas silhuetas), byte-a-byte, no mesmo caminho público 1080×1440; pós-escolha sem foto, a janela fica vazia (a arte do escolhido como está) até a foto entrar — sem silhueta desenhada — e o desenho removido sai do dono junto com os pins. +- **O que NÃO negociar:** arte byte-a-byte (sem re-encode, sem recorte, sem edição e sem apagar as silhuetas dela); caminho público `/cards/modelo-time-de-voce-com-estadual.jpeg` e modelo `time-do-estadual` imutáveis; as 53 entradas do catálogo e as 106 artes intocadas; copy/notice/CTA/download/evento S32 intocados; nenhuma superfície, controle ou estado novo; a pré-escolha segue com a arte entregue (as silhuetas dela são arte, não desenho); fluxo 100% client-side, nada novo sai do aparelho. +- **O que reavaliar (hipóteses da direção) — verificadas:** + - o asset público ainda tem os bytes antigos (`7fc8a50a…`) enquanto a fonte canônica já tem os novos (`1f5dc821…`); o unit do catálogo compara os dois (`stateDeputyCatalog.unit.spec.ts:75-82`) e está vermelho até a cópia; + - o dono da cópia é `scripts/build-state-deputy-card-assets.mjs` (`APPROVED_EXAMPLE_SRC`/`APPROVED_EXAMPLE_OUT`, linhas 38–41 e 131–136), com `--from` obrigatório (linha 57) e skip idempotente por sha nas 106 webps (linhas 110–129); + - o único consumidor da silhueta é o branch sem foto do composer (`CardComposer.tsx:408-423`); `CardPreviewCanvas.tsx` não a usa; `cardModels.ts:120` (`previewSrc`) serve tile e placeholder de pré-escolha pelo mesmo path; + - no e2e, só o teste `:2251-2308` depende da silhueta (pixel `[0,26,66,255]` em `(582,540)`, linha 2276); com JULIO sem foto o novo valor no mesmo ponto é `[239,223,196,255]` (cor da `julio-fotos.webp`; a BASE é transparente ali); + - a decisão preferida mantém banner + `drawCardName` no `renderTeamCard` (não duplicar a geometria no composer); + - `tests/fixtures/state-deputy-catalog.snapshot.json` não contém bytes de arte — intocado. + +## Abordagem recomendada + +```mermaid +flowchart LR + ART["arte aprovada S40 · sha256 1f5dc821…"] --> SCRIPT["pnpm build:state-deputy-card-assets --from=… · dono da cópia byte-a-byte"] + SCRIPT -->|"copia + skip por sha · 106 webp intactas"| PUB["public/cards/modelo-time-de-voce-com-estadual.jpeg · mesmo caminho · 1080×1440 jpeg sRGB"] + PUB --> TILE["CardModelTile · tile da galeria"] + PUB --> PRE["CardComposer pré-escolha · placeholder + notice"] + PUB --> U1["unit stateDeputyCatalog · sha fonte×público"] + PRE --> PICK["escolhe o estadual · StateDeputySelect 53"] + PICK --> ART53["FOTOS + BASE do escolhido · /cards/estaduais/<slug>-fotos.webp + -base.webp"] + ART53 --> RC["renderTeamCard · subject null · base → overlay → banner TIME DE → banner do nome"] + RC --> EMPTY["prévia 1080×1440 · janela vazia até a foto"] + RC --> U2["unit cardRender · sem foto: base + overlay + banners · transform null · zero ops de path/fill"] + RC --> E2E["e2e S30 · probe 582,540 = 239/223/196"] + SIL["drawCardVisitorSilhouette · CARD_VISITOR_SILHOUETTE_FILL · subject kind silhouette"] -.->|"removidos no S40"| RC +``` + +**Opções consideradas:** A) dono-propagação + remoção na raiz — rodar o script dono (copia byte-a-byte com skip por sha) e remover a silhueta desenhada no `renderTeamCard`, com `subject: null` e os pins (unit, e2e, sha) acompanhando | B) fora do dono — `cp` manual da arte e remover a silhueta | C) só a arte — trocar a arte e manter a silhueta desenhada (no-op) em vez de removê-la. +**Recomendação:** A — o script já é o contrato explícito da cópia byte-a-byte e o skip por sha mantém as 106 artes intactas; a remoção no dono do render é a limpeza pedida pelo gate, com `null` como "sem sujeito" natural e pins que provam o novo estado. +**Rejeitadas:** B porque fura o dono (o artefato público passaria a ser escrito por fora do caminho que o script e o teste definem) e ainda deixa a limpeza desacompanhada do dono; C porque viola a decisão de produto do gate (a silhueta desenhada sai) e deixa código morto sem consumidor. + +### Decisões de engenharia + +**1. Asset (A) — propagar pelo script dono e pinar o sha literal.** +**Opções:** A) rodar `pnpm build:state-deputy-card-assets -- --from="…/DOBRADINHAS SITE"` (dono copia byte-a-byte e re-deriva as 106 webps com skip por sha) + pinar o sha novo `1f5dc821…` no unit, mantendo a igualdade fonte×público | B) `cp` manual do asset aprovado para `public/` | C) editar o script para copiar só o exemplo. +**Recomendação:** A — o script é o dono explícito da cópia (`APPROVED_EXAMPLE_SRC`/`OUT`, linhas 38–41 e 131–136) e o skip por sha deixa as 106 webps intactas; a execução prova que o caminho do dono segue vivo e o sha literal protege o aceite contra "asset errado que casa consigo mesmo". +**Rejeitadas:** B porque fura o dono (o público deixa de ser produzido pelo fluxo que o script e o teste definem); C porque é churn sem uso — o script já copia o exemplo e o teste do catálogo já cobre o resultado. + +**2. Renderer (B) — remover a silhueta; sujeito `null`.** +**Opções:** A) remover `CARD_VISITOR_SILHOUETTE_FILL`, `drawCardVisitorSilhouette` e a variante `{kind:'silhouette'}`; `TeamCardSubject` fica só com os campos da foto, achatado (o discriminante `kind: 'photo'` ficou morto e saiu no simplify); `TeamCardRenderArgs.subject: TeamCardSubject | null`; com `null`, o renderer pula o desenho do sujeito e devolve `transform: null` (base → overlay → banner `TIME DE` → banner do nome) | B) variante nova `{kind:'none'}` | C) manter a função como no-op | D) o composer desenhar base/overlay + nome por conta própria. +**Recomendação:** A — a intenção manda remover, não esconder; `null` é a forma natural de "sem sujeito" e o retorno `transform: null` é o contrato que o composer já entende (não seta transform); a composição inteira permanece no dono do render. +**Rejeitadas:** B porque cria variante para 1 caller; C porque deixa código morto (knip `exports` em ERROR) e convida a divergir; D porque duplicaria a geometria do banner/`drawCardName` fora do dono do render. + +**3. `CardDrawContext` (C) — não estreitar; não-escopo com gatilho.** +**Opções:** A) manter o subset estrutural como está (a remoção orfanou `beginPath/arc/ellipse/rect/fill`; `roundRect/clip/createLinearGradient/globalAlpha` já eram mortos antes do S40) | B) estreitar removendo o que a silhueta usava. +**Recomendação:** A — `roundRect`/`clip`/`createLinearGradient`/`globalAlpha` já eram mortos antes do S40; estreitar forçaria churn no fake recorder do unit sem efeito de runtime e misturaria limpeza de tipo com o escopo de S40. O simplify (passo 5) levantou o mesmo ponto e manteve a recomendação; o precedente é o S34, que também preservou o subset ao estender o contexto. +**Rejeitadas:** B — fora do appetite; fica registrada como não-escopo com gatilho de revisita (próximo consumidor que precise do subset). + +**4. Testes (D) — acompanhar a remoção, não silenciar.** +**Opções:** A) unit `cardRender`: remover os imports (`:19,22`), o teste do subject (`:320-339`) e a suíte da silhueta (`:342-373`), substituindo por "sem foto: desenha base + overlay + banners e devolve `transform: null`" (pins: `drawCalls` = `[base, overlay]`, `fit.ok`, `transform === null`, zero ops de `beginPath/arc/ellipse/rect/fill`); e2e: renomear teste/comentários ("silhouette" → janela vazia) e trocar o probe `(582,540)` para `[239,223,196,255]`; unit `stateDeputyCatalog`: pinar o sha literal novo (fonte e público) | B) deletar os testes sem substituto | C) manter nomes/comentários "silhouette" e trocar só o probe. +**Recomendação:** A — o novo estado precisa de um teste que o fixe (arte + banners, sem sujeito) e o e2e continua provando a prévia real; o pin de sha literal é o aceite byte-a-byte no unit. +**Rejeitadas:** B porque "sem foto" perderia cobertura (o e2e só olha 1 pixel); C porque deixaria comentários mentindo e o nome do teste preso a um desenho que saiu. + +**5. Escopo negativo (E) — sem migration/schema/route/copy/analytics.** +**Opções:** A) tocar só: asset público, `src/lib/cardRender.ts`, `src/components/cards/CardComposer.tsx`, `tests/unit/cardRender.unit.spec.ts`, `tests/unit/stateDeputyCatalog.unit.spec.ts`, `tests/e2e/frontend.e2e.spec.ts`, changelog e este plano | B) tocar também `cardModels.ts`, `CardModelTile.tsx` e o manifesto e2e por precaução. +**Recomendação:** A — path e modelo não mudam; `src/components/cards` + `src/lib/card*` já mapeiam para o spec `frontend` no manifesto (`e2e-affected-manifest.mjs`); sem collection/global/field, logo sem migration. +**Rejeitadas:** B — churn preventivo; o que não muda não se toca. + +**6. UI/design (C) — artefato aprovado cobre as cenas; crítica final (c) obrigatória.** +**Opções:** A) tratar `docs/plans/cards-estadual-modelo-generico-ui-design.html` (tier 1 · `openai/gpt-5.6-sol`) como craft aprovado e rodar a crítica final (c) do `designer` contra o app renderizado no fechamento | B) reabrir design gate/dispatch novo. +**Recomendação:** A — os triggers (a)/(b)/(d) não disparam (sem superfície/paleta/estrutura nova) e as 3 cenas já cobrem tile, placeholder e pós-escolha; a crítica (c) é obrigatória porque o diff muda o canvas. +**Rejeitadas:** B — reabrir o gate sem evidência nova de produto; o artefato tier 1 já decide composição. + +### Componentes / mudanças + +- **`public/cards/modelo-time-de-voce-com-estadual.jpeg`** (sobrescrito pelo dono): recebe a arte genérica byte-a-byte; mesmo caminho, mesma dimensão 1080×1440 jpeg sRGB; nada mais é escrito (as 106 webps ficam por conta do skip por sha). +- **`scripts/build-state-deputy-card-assets.mjs`** (sem editar): é o dono da cópia; rodar com `--from="/home/fsolla/Downloads/DOBRADINHAS SITE-20260923T033020Z-1-001/DOBRADINHAS SITE"`; `APPROVED_EXAMPLE_SRC`/`OUT` (linhas 38–41), cópia condicional (131–136), loop das webps com skip (110–129). +- **`src/lib/cardRender.ts`** (editar): removem-se `CARD_VISITOR_SILHOUETTE_FILL` (`:236-237`), `drawCardVisitorSilhouette` (`:239-268`), a variante `{kind:'silhouette'}` (`:270-278`) e o ramo correspondente (`:334-336`); `TeamCardSubject` fica só `photo`; `TeamCardRenderArgs.subject: TeamCardSubject | null` (`:280-289`); com `null`, o sujeito é pulado e o retorno é `transform: null`; doc de `TeamCardRenderResult.transform` (`:291-299`) e de `renderTeamCard` (`:301-307`) atualizadas; `drawCardName`/`renderNameCard`/`renderColinhaCard` intocados. +- **`src/components/cards/CardComposer.tsx`** (editar): o branch pós-escolha sem foto (`:408-423`) chama `renderTeamCard` com `subject: null` e comentário atualizado ("chosen but no photo: the art shows as-is; the window stays empty until the photo lands"); o ramo com foto (`:386-406`), o notice (`:877-883`), o CTA, o download e o evento S32 ficam como estão. +- **Testes** (ver seção própria): `tests/unit/cardRender.unit.spec.ts`, `tests/unit/stateDeputyCatalog.unit.spec.ts`, `tests/e2e/frontend.e2e.spec.ts`. +- **`docs/changelog/2026-09-24-s40-cards-estadual-modelo-generico.md`** (novo): arte genérica no tile/placeholder + remoção da silhueta desenhada e dos pins. +- **Migration:** sem migration — nenhuma collection/global/field no schema; `push:false` intocado. +- **Access / Consent:** não se aplica — nenhuma superfície Payload, nenhum opt-in; fluxo client-side e a escolha não sai do aparelho. +- **UI:** Impeccable C — shape aprovado em tier 1 (`docs/plans/cards-estadual-modelo-generico-ui-design.html`; a arte do próprio arquivo é o asset S30 sobrescrito in-place, sem pasta S40). Craft = a cópia byte-a-byte pelo dono + a remoção do desenho; critique = a crítica final (c) do `designer` contra o app renderizado (tile desktop/mobile, placeholder pré-escolha, pós-escolha com JULIO sem foto). Shells/pins reusados: `CardComposer`, `CardModelTile`, `StateDeputySelect`, `renderTeamCard`; nada de superfície nova. + +### Dados → forma (se aplicável) + +Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a mudança é visual (arte + remoção de um desenho) e a medição de uso segue S32, intocada. Nenhum dado novo é apresentado. + +## Fases verificáveis + +1. **Asset pelo dono (tracer)** — quota ~0,1 dia. Rodar `pnpm build:state-deputy-card-assets -- --from="…/DOBRADINHAS SITE"`; revisar `git status` (só o jpeg público deve mudar; se o sharp re-encodar alguma webp, revertê-las — fora de escopo); pinar o sha literal `1f5dc8212662697149f0acac5701e8de58c53a86f5af316535736dccb7a53c7b` no unit do catálogo (fonte e público), mantendo a igualdade byte-a-byte. + Prova: `sha256sum` igual nos dois arquivos; `pnpm gate:fast` com o unit do catálogo verde; `git status` limpo exceto o jpeg. +2. **Renderer + composer (janela vazia)** — quota ~0,2 dia. Remover fill/função/variante/ramo (Decisão 2); `subject: null` no branch do composer; docs atualizadas; substituir o teste unitário do subject/silhueta pelo teste "sem foto" (Decisão 4). + Prova: `pnpm gate:fast`; grep de produção sem `CARD_VISITOR_SILHOUETTE_FILL|drawCardVisitorSilhouette|kind: 'silhouette'`; `?model=time-do-estadual` + JULIO no browser mostrando a arte com a janela vazia e os banners. +3. **E2E + gates** — quota ~0,1 dia. Renomear teste/comentários do S30 ("silhouette" → janela vazia) e trocar o probe `(582,540)` para `[239,223,196,255]`; manter notice/CTA/analytics como estão; rodar a cascata. + Prova: `pnpm gate:fast`; `pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Time do estadual"` verde; `pnpm test:e2e:affected` verde; `pnpm knip`; `pnpm check:cycles`; `pnpm format:check`. +4. **Crítica final (c) + changelog + PR** — quota ~0,1 dia. Crítica do `designer` contra o app renderizado nas 3 cenas (tile, placeholder, pós-escolha vazia) — obrigatória; changelog; prova no PR; `pnpm push` (pre-push roda o gate de CI). + Prova: parecer da crítica (c) anexado ao PR; changelog commitado; CI do PR verde até `checks`. + +### Testes previstos por camada + +- **unit (recorder falso) — `tests/unit/cardRender.unit.spec.ts` (substituir):** removem-se imports (`:19,22`), o teste do subject (`:320-339`) e a suíte `drawCardVisitorSilhouette` (`:342-373`); entra "sem foto: base + overlay + banners" — `drawCalls.map(image)` = `[base, overlay]`, `fit.ok`, `transform === null`, zero ops de `beginPath/arc/ellipse/rect/fill` (os banners usam `save/translate/rotate/fillRect/fillText/restore`). O fake recorder continua implementando o subset estrutural (Decisão 3/C). +- **unit (node + sharp) — `tests/unit/stateDeputyCatalog.unit.spec.ts` (atualizar):** pin do sha literal novo (`1f5dc821…`) na fonte aprovada e no arquivo público, mantendo a comparação byte-a-byte; snapshot de identidade intocado. +- **int:** não se aplica — sem Payload/DB/access/escrita multi-collection; fluxo client-side + asset estático (o manifesto e2e também não muda). +- **e2e — `tests/e2e/frontend.e2e.spec.ts` (editar o describe S30):** probe `(582,540)` = `[239,223,196,255]`; teste/comentários renomeados para "janela vazia"; os demais testes do describe (troca mantém nome/foto, nome que não cabe, picker vazio, dots mobile) e o evento S32 seguem iguais. + +## Rabbit holes / Não escopo (engenharia) + +- Estreitar o `CardDrawContext` (Decisão 3/C) — gatilho de revisita: próximo consumidor que precise do subset hoje morto. +- Re-encodar/editar/recortar a arte aprovada ou apagar as silhuetas dela (rabbit holes de produto da intenção: a pré-escolha usa a arte como está). +- Tocar as 106 webps, o catálogo das 53, `stateDeputyCatalog` (além do pin), `cardModels.ts`, `CardModelTile.tsx` e o manifesto e2e (já mapeado). +- Renomear/mover o caminho público; criar campo/asset novo; segunda arte por estadual. +- Copy/notice/CTA/download/estado/analytics S32; "melhorar" o card pós-foto. +- Editar `scripts/build-state-deputy-card-assets.mjs` (o dono roda como está). +- Capturar/mediar a escolha do estadual (segue 100% local; S32 intocado). + +## Riscos e mitigação + +- **Rerodar o script re-deriva as 106 webps e o sharp pode re-encodar alguma:** o skip por sha evita escrita quando o buffer é idêntico; revisar `git status` e reverter webps tocadas (`git checkout -- public/cards/estaduais`) — fora de escopo. Somente o jpeg exemplo deve mudar. +- **Fonte e arte evoluírem separadas depois:** o unit pina o sha literal dos dois lados — atualizar fonte+público juntos no mesmo commit é o contrato. +- **Probe e2e dependente da arte do estadual:** usar o ponto `(582,540)` com JULIO sem foto (valor confirmado por sharp: `[239,223,196,255]`; a BASE é transparente ali); não pinar a região da antiga silhueta nem usar região com FOTOS+BASE sobrepostas. +- **Símbolos removidos ainda importados (knip `exports` em ERROR):** `tests/**/*.spec.ts` são entry — limpar os imports do unit no mesmo commit da remoção. +- **Composer `photoWindow` ausente:** o branch mantém o guard existente (`if (!photoWindow) return`) e passa `subject: null`; o modelo declara `photoWindow` fixo (`cardModels.ts:124`). +- **Comentários/doc presos à silhueta:** atualizar `cardRender.ts` (doc de `renderTeamCard`/`transform`) e o cabeçalho/probe do e2e no mesmo PR; grep de `silhouette` como prova de limpeza. +- **Crítica final (c) reprovar o fechamento:** os ajustes entram antes do push (como no S34); nenhum código novo nasce da crítica sem evidência — se reprovar, o ajuste é de craft dentro do escopo (arte/canvas), nunca de produto. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: tile e placeholder de pré-escolha = arte genérica byte-a-byte no mesmo caminho; pós-escolha sem foto = arte do estadual escolhido com a janela vazia até a foto; pré-escolha com as silhuetas da arte; nenhum outro pixel/fluxo/copy/estado/analytics muda; desenho removido do dono sem sobras. +- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent (nenhuma superfície Payload); `src/lib` client-safe; copy pt-BR e identificadores em inglês; arte aprovada intocada (a cópia é o único write); knip sem órfãos; `pnpm gate:fast`, e2e da superfície, `pnpm knip`, `pnpm check:cycles`, `pnpm format:check` e `pnpm push` verdes. +- [ ] Testes de domínio previstos (unit/int): unit `cardRender` (sem foto: base + overlay + banners, `transform: null`, zero path/fill) e unit `stateDeputyCatalog` (sha literal fonte×público) obrigatórios; e2e do descriptor S30 atualizado (probe da janela vazia) com fluxo/download/evento verdes; **int não se aplica** — sem Payload/DB/access nem escrita multi-collection. + +## Self-score decision-quality: 5/5 + +1. **Decisões caras com rejeitadas:** asset pelo dono × `cp` × editar script; renderer (remoção × variante nova × no-op × composer compondo); `CardDrawContext` (não estreitar); testes (substituir × deletar × renomear sem mudar); escopo negativo; UI/crítica (c) — todas com Opções/Recomendação/Rejeitadas. +2. **Cabe no appetite:** 4 fases ~0,5 dia; sem migration, sem asset novo, sem dependência; o maior peso é reescrever/renomear testes existentes. +3. **Rabbit holes nomeados:** estreitar o contexto (com gatilho), editar a arte, tocar as 106/53, segundo asset/arte por estadual, copy/estados/analytics, editar o script, medição da escolha. +4. **Depth check:** reusa o dono `renderTeamCard`/`drawCardName`, o script dono da cópia, o composer/tile/seletor existentes e os pins unit+e2e do estúdio; nenhuma abstração nova — a remoção acontece no dono e o `subject: null` reusa o contrato de `transform: null`. +5. **Intenção preservada:** o outcome ("arte genérica no tile/placeholder + janela vazia pós-escolha, sem silhueta desenhada") é intocado; a engenharia só resolveu a forma (dono da cópia, `null`, limpeza e pins). diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx index 4a017387..496a30bd 100644 --- a/src/components/cards/CardComposer.tsx +++ b/src/components/cards/CardComposer.tsx @@ -388,7 +388,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer base: artBase, overlay: artOverlay, subject: { - kind: 'photo', photo: harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas, photoSize: { width: teamReady.width, height: teamReady.height }, transform: effectiveTransform, @@ -406,13 +405,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer } if (isStateDeputyModel) { - // Chosen but no photo yet: the visitor silhouette marks the photo slot. + // Chosen but no photo yet: the art shows as-is — the window stays empty + // until the visitor photo lands (no drawn placeholder). if (!photoWindow) return setNameFit( renderTeamCard(ctx, model, { base: artBase, overlay: artOverlay, - subject: { kind: 'silhouette' }, + subject: null, window: photoWindow, name, fontFamily, diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index df3676fc..68e2033d 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -140,7 +140,7 @@ export const resolveCardBannerWidth = ( */ const drawCardBanner = (ctx: CardDrawContext, args: CardBannerDrawArgs): void => { const { banner } = args - const width = args.width ?? banner.width + const width = args.width ctx.save() ctx.translate(banner.centerX, banner.centerY) @@ -233,54 +233,18 @@ export const renderPhotoCard = ( return clampCardPhotoTransform(args.transform, args.photoSize, args.window) } -/** S30 — silhouette tone ported from the design gate's visitor placeholder. */ -export const CARD_VISITOR_SILHOUETTE_FILL = '#001a42' - -/** - * S30 — the pre-photo state of the state-deputy model: the visitor placeholder - * of the design gate drawn over the deputy art (head circle + shoulder dome), - * normalized on the photo window so every art uses the same ruler. The shape - * mirrors the design's CSS (head = 34% of the window width at the top; shoulders - * = full width, 72% of the height with their base 3% past the window bottom, so - * the front overlay covers the seam). The gate's small bottom corner radius - * (10%) is deliberately omitted: it falls entirely behind the front overlay. - */ -export const drawCardVisitorSilhouette = (ctx: CardDrawContext, window: CardRect): void => { - const centerX = window.x + window.width / 2 - const headRadius = window.width * 0.17 - const shoulderHeight = window.height * 0.72 - const shoulderBottom = window.y + window.height * 1.03 - const shoulderTop = shoulderBottom - shoulderHeight - const domeRadiusX = window.width * 0.48 - const domeRadiusY = shoulderHeight * 0.48 - const bodyTop = shoulderTop + domeRadiusY - - ctx.fillStyle = CARD_VISITOR_SILHOUETTE_FILL - - ctx.beginPath() - ctx.arc(centerX, window.y + headRadius, headRadius, 0, Math.PI * 2) - ctx.fill() - - ctx.beginPath() - ctx.rect(window.x, bodyTop, window.width, shoulderBottom - bodyTop) - ctx.ellipse(centerX, bodyTop, domeRadiusX, domeRadiusY, 0, Math.PI, 0) - ctx.fill() -} - /** S30 — the foreground subject of a team composition. */ -type TeamCardSubject = - | { - kind: 'photo' - photo: CanvasImageSource - photoSize: CardPhotoSize - transform: CardPhotoTransform - } - | { kind: 'silhouette' } +type TeamCardSubject = { + photo: CanvasImageSource + photoSize: CardPhotoSize + transform: CardPhotoTransform +} export type TeamCardRenderArgs = { base: CanvasImageSource overlay: CanvasImageSource - subject: TeamCardSubject + /** `null` leaves the window empty: the chosen art shows exactly as delivered. */ + subject: TeamCardSubject | null window: CardRect name: string fontFamily: string @@ -293,17 +257,17 @@ export type TeamCardRenderResult = { /** * The normalized photo transform (zoom always in range; S33 — the offsets * follow the model's `photoPosition`, so a `free` model keeps them as sent); - * `null` for the silhouette subject. + * `null` when there is no photo subject. */ transform: CardPhotoTransform | null } /** * S15/S30 — the team card composition, in the measured order: master base → - * foreground subject (the framed cutout photo, or the visitor silhouette of the - * state-deputy pre-photo state) → master front overlay → `TIME DE` banner → - * blue name banner (only when the name fits; never cut silently). The two - * banners are drawn by the renderer because neither master carries them. + * foreground subject (the framed cutout photo; `null` skips it and the window + * stays empty) → master front overlay → `TIME DE` banner → blue name banner + * (only when the name fits; never cut silently). The two banners are drawn by + * the renderer because neither master carries them. */ export const renderTeamCard = ( ctx: CardDrawContext, @@ -313,7 +277,7 @@ export const renderTeamCard = ( ctx.drawImage(args.base, 0, 0, model.width, model.height) let transform: CardPhotoTransform | null = null - if (args.subject.kind === 'photo') { + if (args.subject) { // S33 — the drawing and the returned transform follow the same policy read // from the model: a `free` team model never re-clamps the visitor position // (that would snap the photo back on the next paint). @@ -331,8 +295,6 @@ export const renderTeamCard = ( args.window, options, ) - } else { - drawCardVisitorSilhouette(ctx, args.window) } ctx.drawImage(args.overlay, 0, 0, model.width, model.height) diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index ad462c78..2f4d236a 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -2230,16 +2230,17 @@ test.describe('Cards personalizados (S15 — enquadramento automático)', () => /** * S30 — the state-deputy model reuses the S15 cutout stub; the new surfaces are - * the picker (search + ↑↓/Enter), the visitor silhouette that marks the photo - * slot and the art swap that must keep the typed name and the processed photo. + * the picker (search + ↑↓/Enter), the empty photo window after the pick (S40 + * removed the drawn silhouette) and the art swap that must keep the typed name + * and the processed photo. */ test.describe('Cards personalizados (S30 — Time do estadual)', () => { /** * Window probes at 1080×1440: x=582 is the photo window center (286 + 592/2), - * y=540 the silhouette head center (439 + 0.17·592) and y=727 the window - * center where the stub cutout lands. + * y=540 a point inside the window top and y=727 the window center where the + * stub cutout lands. */ - const windowCenterPixel = (canvas: Locator, x: number, y: number) => + const windowPixel = (canvas: Locator, x: number, y: number) => canvas.evaluate( (element, point) => { const node = element as HTMLCanvasElement @@ -2248,7 +2249,7 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => { { x, y }, ) - test('picks a deputy, draws the silhouette, cuts the photo and downloads the PNG', async ({ + test('picks a deputy, keeps the window empty, cuts the photo and downloads the PNG', async ({ page, }) => { await setCutoutStub(page, 'ok') @@ -2271,9 +2272,10 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => { await expect(trigger).toContainText('13999') await expect(dialog.getByRole('button', { name: 'Escolher foto' })).toBeVisible() - // The visitor silhouette (#001a42) marks the photo window head. + // S40 — no drawn silhouette: the chosen art (julio-fotos) shows through the + // empty photo window until the visitor photo lands. const canvas = dialog.locator('canvas') - await expect.poll(() => windowCenterPixel(canvas, 582, 540)).toEqual([0, 26, 66, 255]) + await expect.poll(() => windowPixel(canvas, 582, 540)).toEqual([239, 223, 196, 255]) await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') await expect(primary).toBeDisabled() @@ -2320,7 +2322,7 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => { // The cutout landed: harmony OFF samples the stub fixture at the window center. const canvas = dialog.locator('canvas') await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click() - await expect.poll(() => windowCenterPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) + await expect.poll(() => windowPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) // Swap the deputy through the trigger; the new one confirms on it. await dialog.getByRole('button', { name: /Seu estadual/ }).click() @@ -2334,7 +2336,7 @@ test.describe('Cards personalizados (S30 — Time do estadual)', () => { // Name and cutout survive the swap: still ready, CTA enabled, photo pixel intact. await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled() - await expect.poll(() => windowCenterPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) + await expect.poll(() => windowPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) }) test('a name that cannot fit fails closed on the estadual model', async ({ page }) => { diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts index 21a2bc8d..3c4ad8b8 100644 --- a/tests/unit/cardRender.unit.spec.ts +++ b/tests/unit/cardRender.unit.spec.ts @@ -16,10 +16,8 @@ import { type CardPhotoSize, } from '@/lib/cardPhotoTransform' import { - CARD_VISITOR_SILHOUETTE_FILL, createCardMeasure, drawCardName, - drawCardVisitorSilhouette, renderColinhaCard, renderNameCard, renderPhotoCard, @@ -233,7 +231,7 @@ describe('renderTeamCard', () => { const result = renderTeamCard(fake.ctx, teamModel, { base, overlay, - subject: { kind: 'photo', photo, photoSize, transform }, + subject: { photo, photoSize, transform }, window, name, fontFamily: 'Brexter', @@ -305,7 +303,7 @@ describe('renderTeamCard', () => { const clamped = renderTeamCard(fake.ctx, teamModel, { base: { id: 'base' } as unknown as CanvasImageSource, overlay: { id: 'overlay' } as unknown as CanvasImageSource, - subject: { kind: 'photo', photo, photoSize, transform: outOfBounds }, + subject: { photo, photoSize, transform: outOfBounds }, window, name: 'Maria', fontFamily: 'Brexter', @@ -317,14 +315,14 @@ describe('renderTeamCard', () => { expect(fake.drawCalls[1]).toMatchObject({ dx: 10_000, dy: -10_000 }) }) - it('draws the visitor silhouette instead of the photo when no cutout is ready (S30)', () => { + it('leaves the window empty instead of drawing a subject when no photo is ready (S40)', () => { const fake = createFakeContext() const base = { id: 'base' } as unknown as CanvasImageSource const overlay = { id: 'overlay' } as unknown as CanvasImageSource const result = renderTeamCard(fake.ctx, teamModel, { base, overlay, - subject: { kind: 'silhouette' }, + subject: null, window, name: 'Maria', fontFamily: 'Brexter', @@ -333,42 +331,11 @@ describe('renderTeamCard', () => { expect(result.transform).toBeNull() expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] }) + // Base + overlay only: nothing is drawn inside the photo window. expect(fake.drawCalls.map((call) => call.image)).toEqual([base, overlay]) - // Head circle + shoulder dome (rect + upper-half ellipse), both filled. - expect(fake.ops.filter((op) => op.op === 'fill')).toHaveLength(2) - }) -}) - -describe('drawCardVisitorSilhouette (S30)', () => { - const window = teamModel.photoWindow! - - it('normalizes the head and shoulders on the photo window', () => { - const { ctx, ops } = createFakeContext() - - drawCardVisitorSilhouette(ctx, window) - - const shoulderHeight = window.height * 0.72 - const shoulderBottom = window.y + window.height * 1.03 - const bodyTop = shoulderBottom - shoulderHeight + shoulderHeight * 0.48 - const rect = ops.find((op) => op.op === 'rect')! - const ellipse = ops.find((op) => op.op === 'ellipse')! - - expect(rect.args).toEqual([window.x, bodyTop, window.width, shoulderBottom - bodyTop]) - expect(ellipse.args).toEqual([ - window.x + window.width / 2, - bodyTop, - window.width * 0.48, - shoulderHeight * 0.48, - 0, - Math.PI, - 0, - ]) - // The shoulders never grow wider than the window they sit in. - expect(window.x + window.width / 2 - ellipse.args[2]!).toBeGreaterThanOrEqual(window.x) - expect(window.x + window.width / 2 + ellipse.args[2]!).toBeLessThanOrEqual( - window.x + window.width, - ) - expect(ctx.fillStyle).toBe(CARD_VISITOR_SILHOUETTE_FILL) + expect( + fake.ops.filter((op) => ['beginPath', 'arc', 'ellipse', 'rect', 'fill'].includes(op.op)), + ).toEqual([]) }) }) diff --git a/tests/unit/stateDeputyCatalog.unit.spec.ts b/tests/unit/stateDeputyCatalog.unit.spec.ts index d1d2d3f8..e6ce0912 100644 --- a/tests/unit/stateDeputyCatalog.unit.spec.ts +++ b/tests/unit/stateDeputyCatalog.unit.spec.ts @@ -38,6 +38,9 @@ const APPROVED_EXAMPLE_SRC = fileURLToPath( ), ) +/** S40 — the generic art delivered by the campaign (SEU ESTADUAL + two silhouettes). */ +const APPROVED_EXAMPLE_SHA256 = '1f5dc8212662697149f0acac5701e8de58c53a86f5af316535736dccb7a53c7b' + describe('State-deputy card catalog (S30 — 53 dobradinhas)', () => { it('has the 53 delivered deputies with unique slugs, names and ballot numbers', () => { expect(stateDeputyCatalog).toHaveLength(53) @@ -73,12 +76,13 @@ describe('State-deputy card catalog (S30 — 53 dobradinhas)', () => { }) it('uses the exact approved example file for the gallery tile', () => { - const approved = readFileSync(APPROVED_EXAMPLE_SRC) - const shipped = readFileSync(publicFile('/cards/modelo-time-de-voce-com-estadual.jpeg')) + const approved = createHash('sha256').update(readFileSync(APPROVED_EXAMPLE_SRC)).digest('hex') + const shipped = createHash('sha256') + .update(readFileSync(publicFile('/cards/modelo-time-de-voce-com-estadual.jpeg'))) + .digest('hex') - expect(createHash('sha256').update(shipped).digest('hex')).toBe( - createHash('sha256').update(approved).digest('hex'), - ) + expect(approved).toBe(APPROVED_EXAMPLE_SHA256) + expect(shipped).toBe(APPROVED_EXAMPLE_SHA256) }) it('matches the frozen identity snapshot (slug/name/number/assets are the public contract)', () => {