mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(S40): arte genérica do Time do estadual e janela vazia sem silhueta (Closes #1316)
This commit is contained in:
parent
275dd15f95
commit
02ca466469
7 changed files with 188 additions and 111 deletions
|
|
@ -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.
|
||||
141
docs/plans/cards-estadual-modelo-generico-impl.md
Normal file
141
docs/plans/cards-estadual-modelo-generico-impl.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue