Compare commits

...

12 commits

Author SHA1 Message Date
fsolla
048df6f828 docs: registra merge, verify e staging do fix #1415 no post-mortem
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
2026-10-01 20:50:34 -03:00
Francisco Solla
ff969ec8e4 docs(bug-fix): registra o PR #1421 no post-mortem 2026-10-01 20:41:37 -03:00
Francisco Solla
3c3af5c6bc docs(bug-fix): post-mortem e changelog das imagens lentas da home 2026-10-01 20:41:37 -03:00
Francisco Solla
0fa0e8f41b chore(images): avisa saídas com 2000px ou mais no gerador de assets 2026-10-01 20:41:37 -03:00
Francisco Solla
16f959752a fix(images): masters do hero no tamanho de exibição e TTL de 4h no otimizador 2026-10-01 20:41:37 -03:00
Francisco Solla
9a049bbbb4 docs(C244-C247): planos de intenção — filtro facial de pessoa pública, catalogação em produção, integridade do Garage e toggle de globais 2026-10-01 20:28:53 -03:00
fsolla
8db841f778 docs: registra o PR #1415 no post-mortem e no changelog 2026-10-01 19:38:07 -03:00
fsolla
14e0197df0 docs: post-mortem e changelog do verify (refresh do live preview x hash #novidades) 2026-10-01 19:38:07 -03:00
fsolla
18ec5454fa fix(live-preview): gate do bridge pelo contexto de preview — desbloqueia o verify (hash #novidades)
O bridge do Payload live preview montava em toda página pública e disparava
router.refresh() no mount; quando o RSC chegava depois do clique no CTA do
hero, o HistoryUpdater do App Router regravava a URL canônica sem hash.
Agora o bridge só monta em iframe/popup (window.parent/opener), com unit de
regressão, e2e determinístico (segura o _rsc e libera depois do clique) e
entrada no manifesto de afetados.
2026-10-01 19:38:07 -03:00
Francisco Solla
ab441d36df docs(changelog): S45 — cards verticais na Central de Conteúdos 2026-10-01 19:17:06 -03:00
Francisco Solla
4ac13dc351 feat(S45): cards verticais no catálogo, na home e na página da peça 2026-10-01 19:17:06 -03:00
Francisco Solla
fcc47bb111 docs(plan): S45 — plano de implementação e crítica visual do designer 2026-10-01 19:17:06 -03:00
50 changed files with 1200 additions and 86 deletions

View file

@ -0,0 +1 @@
**Fix — home da campanha lenta: masters AVIF 4K servindo recortes de 177–747px (2026-10-01):** o relato "Images are loading slowly in jorgesolla1313.com.br" reproduziu em produção: `/_next/image` com TTFB ~1,6s nas miniaturas 256w e um request de `RUI - 2-2.avif&w=256` pendurado >120s. Causa-raiz: os 5 masters do hero (`CampaignHero`) iam de 3840×5757 a 3840×5760 (14–22MP, 1,03–3,60MB) para exibir 177–702px — decode de ~0,7–1,1s de sharp por derivado (benchmark local; soma 4324ms) —, e a combinação `images.minimumCacheTTL` default 60s + cache volátil do container + `cf-cache-status: DYNAMIC` regenerava quase toda miniatura no homeserver; os masters nasceram do default do `pnpm images:resize` ("2x desktop", 3840px) aplicado a recortes pequenos. O fix regera os 5 masters com o próprio `pnpm images:resize` (`RUI`/`WAGNER`/`Jeronimo` 768px, `Lula` 1024px, `JOA00162` 1536px; 350,9KB vs 8,7MB — otimizador 11× mais rápido, soma 381ms), sobe `images.minimumCacheTTL` para 14400 (`next.config.mjs:10`), tira o percent-encoding dos srcs (`/RUI - 2-2.avif`, `/WAGNER - 2-9 final.avif`) e corrige os `sizes` do hero para os caps reais do CSS (211px/284px/747px); o gerador ganha aviso quando a saída ≥2000px. Guardas: `tests/unit/imageDelivery.unit.spec.ts` (teto 1600×2400 nos masters do hero e em todo asset local literal entregue via `next/image`, piso de TTL) e caso de src sem `%` no `campaignHero.unit.spec.tsx` — vermelhos sem o fix, verdes com; e2e `frontend.e2e.spec.ts` 42 passed; `pnpm gate:fast` 460 arquivos/5100 testes. Post-mortem em `docs/postmortems/2026-10-01-imagens-lentas-home-campanha.md` (prevenções caras registradas: Cache Rule do Cloudflare para `/_next/image*`, volume persistente do cache de imagens, observabilidade de TTFB e lote de assets fora do orçamento). Sem migration, sem schema, sem URL pública alterada.

View file

@ -0,0 +1 @@
**S45 — Central de Conteúdos: cards verticais no catálogo, na seção da home e na página da peça (2026-10-01):** o board deixa de ser uma parede de thumbs 16:9: a peça passa a aparecer na forma em que foi criada — Reel **9:16**, foto/carrossel **4:5**, arte dos modelos S38 **3:4** — sempre inteira, e a peça genuinamente horizontal aparece **inteira em letterbox** na superfície neutra (`object-contain` sobre a `--campaign-band`), nunca esticada nem cortada; áudio, link, texto e outros seguem largos (sem enquadramento a preservar). A regra é pura e tem um dono só (`src/lib/contentPieceCatalog.ts`): `CONTENT_PIECE_ASPECT_CLASS`/`contentPieceAspectClass` (forma por tipo) e `contentPieceBoardBucket` (vertical/wide/compact), reusados pelo `ContentPieceCard`, `ContentPieceHomeCard`, `ContentPieceDetail` e pelo item S38 — a v1 decide **pelo tipo**, sem ffprobe/width-height/migration (adaptativa por dimensão real fica fora). O catálogo ganha três faixas (artefato cena 01): board vertical de **4 colunas em `lg`** (vídeo intercalado com os modelos S38, `interleaveCatalogItems` intacto), áudio/link numa **linha larga de 2 colunas** (mídia à esquerda, corpo à direita, 156/190px) e a **linha compacta** preservada (foto/texto/outro) com thumb **96×120 (4:5)** em caixa fixa — flex child com `aspect-ratio` é o bug WebKit #265243, e o spacer do `CardModelTile` segue como fallback nomeado. A seção da home mantém a grade de 3 colunas e o thumb mobile de 124px, agora com a forma do tipo (foto 4:5, Reel 9:16, largos `h-[156px]` no phone) e `items-start`; a página da peça usa o mesmo palco por tipo na **coluna fixa de 360px** (`lg:grid-cols-[360px_minmax(0,1fr)]`, cena 05), com o detalhe cumprindo a promessa do card. Paridade com o artefato aprovado na crítica do `designer` (tier primário `openai/gpt-5.6-sol`): play restaurado a **56px no card / 44px no thumb** (sem o cromo de 68px da C228), ações da linha compacta e `Ver esta peça →` a **44px no mobile**, foco público C243 (amarelo + separador azul-escuro) no play e no link do card, skeleton edge-to-edge com `motion-reduce`, e capturas de 390/1280 (board, linhas, home, detalhe, estados sem frame/em reprodução) em `docs/plans/central-conteudos-cards-verticais-ui-design-critique/`. Guardrails intactos: sem rota/tela/player novos, sem autoplay (`preload="none"` só no toque), filtros/busca/ações/copy/kill switch/beacon C213 intocados, itens S38 seguem convite a `/cards` (sem cromo de mídia), ficha interna e demais seções da home fora, overflow 390 ≤1px. Supersede a decisão D5 do C226 ("o slot é 16:9 nas três superfícies") na parte do 16:9. Cobertura: unit do mapa/bucket, pin de `object-contain` no media, pin de forma por tipo no board da home, e2e do catálogo (geometria 9:16/96×120/3:4/156, `object-fit: contain`, contagens, overflow 390), da home (124px 4:5 e 9:16) e do detalhe (palco 9:16 e 4:5 a 360px). Sem migration, sem Consent/PII, sem analytics novos.

View file

@ -0,0 +1 @@
**Fix CI — refresh RSC do live preview apagava o hash `#novidades` e derrubava o verify do deploy (2026-10-01):** o `verify` do deploy ficou vermelho em 2026-10-01 (run `36924161319`, push em `main` no commit `0a8b0774` — "feat(S44): home divulga a Plenária da Vitória…"; Issue #1413, auto-unblock) porque o e2e `campaignNewsletter` ("captures the visitor and confirms in place") recebeu `http://localhost:3000/?e2e=…` sem o hash `#novidades` nas 3 tentativas; 1 failed, 4 flaky (passaram no retry, sem relação causal) e 329 passed (8.8m), com `deploy-staging` e `deploy-production` skipped — deploy do main bloqueado, sem impacto em produção (o `verify` roda antes de publicar). O clique no CTA secundário do hero (`<a href="#novidades">`, `src/components/CampaignHero.tsx:107`) era sobrescrito por uma resposta RSC que chegava ~30 ms depois: o bridge de live preview do Payload montado em toda página pública (`RefreshRouteOnSave`, layout `src/app/(frontend)/layout.tsx:84`) chamava `router.refresh()` incondicionalmente no mount, e o `HistoryUpdater` do App Router regravava a URL canônica sem hash; a corrida existia desde julho/2026 (`375398af`/`f23c2582`) e só virou sistemática no S44, quando a home ganhou a seção da Plenária. O fix gateia o bridge pelo contexto real de preview (`window.parent !== window || window.opener != null`; standalone renderiza `null`, SSR-safe) e adiciona prova de regressão: unit `tests/unit/refreshRouteOnSave.unit.spec.tsx` (standalone não monta/não refresca; iframe monta/refresca), e2e determinístico em `tests/e2e/campaignNewsletter.e2e.spec.ts:245` que segura a resposta `_rsc` e só libera depois do clique, e entrada no manifesto de afetados (`scripts/lib/e2e-affected-manifest.mjs`); sem mudança de schema/produto. Provas: RED sem o fix (unit 1 failed; e2e falha determinística, logs `/tmp/unit-red.log` e `/tmp/e2e-red.log`) e GREEN (unit 2/2; e2e novo 1 passed; arquivo `--repeat-each=3 --retries=0` 18/18 e `--repeat-each=2 --retries=0` 12/12; hero irmão 1 passed; `pnpm gate:fast` com lint/typecheck verdes e os 6 unit failed sendo flakes de ambiente de CLIs alheias ao diff, 31/31 no re-run isolado). PR #1415; a confirmação é o `verify` verde no deploy pós-merge e produção depende do approve humano (não aprovado por esta sessão).

View file

@ -0,0 +1,72 @@
# C246 — Integridade do acervo no Garage: achar e recuperar as fotos quebradas
Status: rascunho
Atualizado em: 2026-10-01
Issue: #1418
Priority: P2
Impeccable: A — sem UI
Design UI: N/A — sem UI
Appetite: ~0,5–1 dia eng + ops; um outcome verificável — nenhuma foto pública serve mídia quebrada
Responsável: —
## Intenção
Ao publicar as 6.492 fotos (C242) e indexar os rostos, apareceram objetos corrompidos no bucket: as fotos **80** e **83** falham o download com _checksum mismatch_ e o proxy público `/fotos/<id>/midia` responde **500** para elas. Uma amostra de 20 ids achou 1 com erro, então o problema é raro, mas hoje o álbum pode exibir (e exibe) imagens quebradas. O acervo precisa de uma varredura de integridade e de um caminho de recuperação a partir do Flickr (a ficha guarda a `sourceUrl`), sem tocar em curadoria e com relatório honesto.
## Persona e fluxo
- **Persona / contexto:** assessoria/ops que publica o acervo; visitante que abre uma foto e vê imagem quebrada.
- **Job principal:** garantir que toda foto aprovada sirva arquivo; recuperar as que não servem.
- **Fluxo desejado:** roda a varredura → recebe a lista de objetos ilegíveis (com motivo) → recupera do Flickr (re-ingestão idempotente pelo `flickrId`) → roda o índice facial nas recuperadas → o álbum não tem mais mídia quebrada.
- **Anti-goals de produto:** baixar/reprocessar tudo por precaução; alterar curadoria; apagar objetos sem rastro; mexer no Flickr.
## Objetivo e aceite
- Varredura read-only de **todas** as fotos: detecta objeto ausente/corrompido (download com validação — o mesmo caminho que o índice facial usa) e emite receita com a lista e o motivo.
- Recuperação re-ingere do Flickr pelo `flickrId`/`sourceUrl`, para o objeto no bucket, sem duplicar linha nem sobrescrever campos curados; as fotos recuperadas voltam ao público pelo fluxo normal (`draft → approved`, com o canal já configurado).
- Fotos irrecuperáveis (fonte sumiu) saem do público com registro — nunca ficam quebradas.
- Reexecutar converge; relatório diz o que foi varrido, achado, recuperado e falhou.
## Dados (intenção)
- **Vou apresentar dados?** Contagens no recibo da CLI (varridas/achadas/recuperadas/falhas) — operador, não público.
- **Forma:** tabela de recibo (JSON/markdown), sem exposição pública.
## Dados da decisão (literais)
- **Achado em produção (2026-10-01):** fotos 80 e 83 com _checksum mismatch_ (`x-amz-checksum-crc32`), mídia 500; amostra 20 ids → 1 erro.
- **Fonte de recuperação:** `archivePhoto.sourceUrl` (Flickr) — re-ingestão existente (`archivePhotoIngest`), determinística pelo `flickrId`.
- **Sem tocar curadoria:** campos curados nunca são sobrescritos (mesma régua do C231/C232).
## Direção no codebase (hipótese)
- **Áreas prováveis:** CLI nova de varredura (reusar `downloadPrivateMediaToFile` + checksum/`headObject` no cliente S3 do bucket, ou o próprio caminho de download do índice), e um modo de recuperação que reusa `ingestArchivePhoto`; reuso das guardas de CLI (`TEQO_ENV`, `*_CONFIRM`, S3), recibos em `data/archive/reports/`.
- **Precedente:** `pnpm archive:catalog --verify` (inventário read-only), `pnpm media:recover` (C233/OPS52 — recuperação de objeto sem tocar no DB), ingestão Flickr C231.
- **Risco de acoplamento:** `publicationStatus` da foto recuperada e o índice facial (marker) precisam ser reprocessados; mídia privada passa pelo proxy e pela allowlist do otimizador.
## Dependências
- Duras: C231 (ingestão/collection) e C242/C240 (publicação/índice) — entregues.
## Fora de escopo
- Re-varredura de mídia pública do C233 (`media`), vídeos, outras plataformas.
## Rabbit holes de produto
- **"Só completar" re-ingerindo o acervo inteiro.** **Corte:** só o que a varredura acusar.
- **"Só completar" apagando fotos problemáticas.** **Corte:** recuperar primeiro; sair do público só quando a fonte não existir.
## Questões em aberto (produto)
- **Canal de saída para irrecuperáveis:** `draft` (some do público) com registro no recibo, ou `removed`? **Recomendação:** `draft` + registro (não é pedido de remoção de pessoa). _(assumido — validar)_
## Referências
- `docs/plans/acervo-fotos-flickr-ingestao.md` / `-impl.md` (C231)
- `docs/plans/album-fotos-publico-busca-impl.md` (C233 — proxy de mídia)
- `src/utilities/flickr/archivePhotoIngest.ts` · `src/utilities/privateMedia/privateMediaResponse.ts`
## Self-score (shaping)
5/5 — (1) outcome verificável (nenhuma mídia pública quebrada); (2) appetite pequeno, reusando ingestão e proxy; (3) persona/job/aceite claros; (4) direção com precedentes nomeados; (5) achado de produção registrado com literais.

View file

@ -0,0 +1,75 @@
# C245 — Catalogação do acervo em produção (município, atividade, legenda)
Status: rascunho
Atualizado em: 2026-10-01
Issue: #1417
Priority: P2
Impeccable: A — sem UI nova (as facetas/cards do C233 já existem; faltam os dados)
Design UI: N/A — sem UI
Appetite: ~1 dia eng + tempo de máquina; um outcome verificável — o álbum mostra município/atividade e o card ganha legenda real
Responsável: —
## Intenção
Depois de publicar as 6.492 fotos (C242), a única faceta que aparece em `/fotos` é **Data** e o card mostra só título/metadados básicos do Flickr. Causa verificada em produção (2026-10-01): a **catalogação do C232 nunca rodou lá** — `catalog.*` está 100% vazio (`scene=0`, `municipio=0`, `caption=0`, `catalogedAt=0`). Os dados existem (título, descrição, tags, 6.2k álbuns do Flickr, `searchText`), mas não foram derivados para a ficha; e a parte de IA (legenda/atividade a partir da imagem) exige o engine de visão local, que não está de pé.
Este item entrega o caminho de catalogação em produção em duas camadas: **(1) metadata-only** (sem modelo): município pelo gazetteer, temas, e o que o texto sustentar; **(2) IA de visão** (legenda/descrição/atividade/texto visível) com o engine local documentado no runbook. O C244 (pessoa por facial), quando aprovado, toma o lugar do "pessoas" derivado de texto.
## Persona e fluxo
- **Persona / contexto:** visitante que procura "fotos em Feira de Santana" ou "de plenária"; assessoria que abre a ficha e quer legenda/atividade reais.
- **Job principal:** navegar o álbum por município/atividade e ver no card o que a foto é, onde e quando.
- **Fluxo desejado:** abre `/fotos` → as facetas Município e Atividade aparecem com valores reais → filtra → o card mostra `data · município · atividade` e legenda; a ficha no admin traz a proposta com procedência (`metadata`/`ia`).
- **Anti-goals de produto:** inventar nome de pessoa; publicar proposta de IA sem revisão por exceção; criar segunda curadoria.
## Objetivo e aceite
- Rodar a catalogação no acervo de produção (metadata-only primeiro; IA quando o engine estiver no ar) com recibos e reexecução idempotente.
- `/fotos` passa a exibir as facetas `Município` e `Atividade` quando houver valores; os cards ganham legenda quando houver (curadoria vence a IA).
- Nenhuma proposta sobrescreve campo curado (`curatedFields`); nada é publicado; a IA só é chamada por endereço privado (guardrail PII).
- Reexecutar não duplica nem repaga o que já está catalogado; o marker da camada metadata não pode bloquear a camada IA (design no impl).
## Dados (intenção)
- **Vou apresentar dados?** Não — a ficha exibe valores; sem números agregados.
- **Forma:** N/A.
## Dados da decisão (literais)
- **Estado verificado (2026-10-01):** produção com `catalog.*` zerado e searchText populado; a entrega pode derivar do texto sem modelo.
- **Engine de visão:** Ollama/host privado (`ARCHIVE_VISION_*`), hoje fora do ar; o runbook documenta como subir e rodar a camada IA.
- **Pessoas:** no metadata-only, não escrever `catalog.people` (o C244 assume a faceta pelo facial); temas/município seguem.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `scripts/catalog-archive-photos.mjs` + `src/utilities/flickr/archivePhotoCatalog.ts` (modo `--metadata-only` reaproveitando `resolveMentionedMunicipalityId`, `classifySpeechByGazetteer`, `archivePhotoThemesFrom` e o write transacional com `buildArchivePhotoCatalogWrite`); marker próprio (ex.: `catalog.metadataCheckedAt`) para não colidir com o `catalogedAt` da IA; runbook do engine de visão.
- **Precedente:** C232 (pipeline e guardas), C230 (writes por CLI + bust de cache via `/api/revalidate`).
- **Risco de acoplamento:** o skip por `catalogedAt` da IA precisa continuar válido; a curadoria (`curatedFields`) sempre vence; facetas públicas dependem de revalidação da tag `archivePhotos`.
## Dependências
- Duras: C232 (pipeline) e C242 (acervo publicado) — entregues.
- Soft: C244 (se aprovado antes, o metadata-only não escreve pessoas).
## Fora de escopo
- OCR dedicado, recorte/edição de imagem, segunda curadoria, superfície nova de revisão.
## Rabbit holes de produto
- **"Só completar" catalogando com terceiro/híbrido.** **Corte:** só engine local privado (PII).
- **"Só completar" publicando direto a proposta da IA.** **Corte:** publica é ato humano; a IA propõe.
## Questões em aberto (produto)
- **Subir o engine de visão agora ou só metadata-only?** **Recomendação:** metadata-only já; IA quando o host local estiver disponível. _(assumido — validar)_
## Referências
- `docs/plans/acervo-fotos-catalogacao-ia.md` / `-impl.md` (C232)
- `docs/plans/album-fotos-publico-busca.md` (C233 — facetas/cards)
- `docs/ops/teqo-1313-deploy.md` (runbook do engine)
## Self-score (shaping)
5/5 — (1) outcome verificável (facetas e cards com dados reais no ar); (2) appetite pequeno, reusando o dono; (3) persona/job/aceite claros; (4) direção com precedentes; (5) decisão de camadas registrada.

View file

@ -0,0 +1,86 @@
# C244 — Filtro "Pessoa pública" por reconhecimento facial no álbum
Status: rascunho
Atualizado em: 2026-10-01
Issue: #1416
Priority: P1
Impeccable: B — muda a origem da faceta/card que já existem em `/fotos` (sem tela nova)
Design UI: N/A — a faceta e a linha "Quem aparece" já foram desenhadas no C233; muda a origem do dado
Appetite: ~2–3 dias eng + curadoria dos descritores de referência + aval jurídico registrado
Responsável: —
## Intenção
Hoje a faceta **Pessoa pública** do álbum (`/fotos?pessoa=<slug>`) sai do **texto** da ficha (`catalog.people`, derivado de título/álbum/legenda pelo C232): mostra fotos em que o nome foi _escrito_, não em que a pessoa _aparece_. A decisão do dono (2026-10-01) é que o filtro siga o **reconhecimento facial**: listar as fotos em que aquela pessoa realmente aparece. O C240 já deixou o motor e o índice de rostos em produção (18k descritores anônimos); falta a camada de **figuras curadas**: descritores de referência das pessoas públicas do catálogo (Solla, Lula, Jerônimo, Geraldinho, Wagner, Júlio Pinheiro e os 53 deputados estaduais), o vínculo foto↔figura no lote e a faceta lendo esse vínculo em vez do texto.
Isto é identificação biométrica de pessoas nomeadas — permitida **apenas** para o catálogo curado de figuras públicas, com o aval jurídico declarado que abriu o índice do C240; nunca para terceiros, nunca inferida de rosto para quem não está no catálogo.
## Persona e fluxo
- **Persona / contexto:** visitante que quer as fotos de uma figura (ex.: Lula, Jerônimo) para rever/guardar; assessoria que quer medir presença por pessoa.
- **Job principal:** filtrar o álbum por pessoa e ver **só as fotos em que ela aparece**.
- **Fluxo desejado:** abre `/fotos` → escolhe "Pessoa pública: Lula" → grade traz só fotos com o rosto de Lula (mesmo sem o nome escrito na ficha) → abre a foto em contexto; a linha "Quem aparece" lista as figuras curadas reconhecidas naquela foto.
- **Anti-goals de produto:** identificar/nomear terceiros; virar diretório de rostos; buscar por rosto de outra pessoa; score/percentual; vídeo.
### Design UI (B)
- N/A — a superfície (`pessoa` na URL, chip, card "Quem aparece") é a do C233; o item muda a origem do dado e o texto vazio da faceta quando não há figuras reconhecidas.
## Objetivo e aceite
- A faceta `pessoa` lista **apenas figuras do catálogo curado** com ≥1 foto aprovada reconhecida; a grade da URL `?pessoa=<slug>` traz exatamente essas fotos (por rosto, não por texto).
- "Quem aparece" no card/overlay mostra só figuras curadas reconhecidas na foto; nunca nome de terceiro.
- Despublicar/remover uma foto tira-a da faceta e do "Quem aparece" imediatamente; desligar o kill switch fecha tudo.
- Opt-out (C240 "Minha presença") remove o rosto do índice — e, se uma figura sair do catálogo/for removida, deixa de aparecer na faceta.
- Nada de score; nenhuma superfície promete "todas as fotos"; o texto vazio da faceta explica que só entram figuras reconhecidas com curadoria.
- **Guardrails:** Consent/LGPD fail-closed (o aviso do índice e o consentimento da consulta continuam sendo o gate); sem cruzamento com `Contact`/leadership; referência biométrica de figura é curada e auditável.
## Dados (intenção)
- **Vou apresentar dados?** Não — o resultado é a lista de fotos; nenhum número de semelhança.
- **Forma:** N/A — sem score/percentual em nenhum estado.
## Dados da decisão (literais)
- **Origem do filtro — decisão do dono (2026-10-01): facial.** A faceta passa a ler o vínculo foto↔figura do índice facial; o `catalog.people` (texto) pode continuar como conteúdo da ficha, mas não governa o filtro.
- **Catálogo inicial (curadoria):** Jorge Solla; Luiz Inácio Lula da Silva ("Lula"); Jerônimo Rodrigues ("Jerônimo"); Geraldo Júnior ("Geraldinho"); Jaques Wagner ("Wagner"); Júlio Pinheiro; + os 53 deputados estaduais do roster S30 (já no catálogo).
- **Referências:** cada figura precisa de descritor(es) de referência (retratos oficiais/arquivo, curadoria humana); a origem e o aval são registrados no runbook (mesmo caminho do consentimento do índice do C240).
- **Sem terceiros:** rostos que não casam com o catálogo continuam anônimos no índice; nunca são nomeados.
## Direção no codebase (hipótese)
- **Áreas prováveis:** collection nova de figuras (`faceFigure`: nome curado + descritores de referência, admin-only) sobre o padrão do antigo `faceSubject`; `scripts/lib/faceEngine.mjs` + `pnpm faces:enroll-figure` (referência) e `faces:index` estendido para gravar o vínculo foto↔figura (o marker `faces.checkedKey` passa a incluir a revisão das figuras); a faceta/card em `src/lib/archivePhotoPublicCatalog.ts` trocam a fonte de `pessoa`; leitura pública em `archivePhotoReads`.
- **Precedente a olhar:** C240 (`faceIndex`, purge por despublicação, opt-out), histórico A/C do C234 (`faceSubject.matchedPhotos`), `publicFigureCatalog` (slugs/aliases), catálogo público do C233.
- **Risco de acoplamento:** a faceta é contrato público (URL/chip) — o valor `pessoa` não muda, só a origem; `catalog.people` continua na ficha (texto) para não quebrar a Central/curadoria; migration aditiva; o índice atual (18k rostos) precisa ser reprocessado ao introduzir figuras (marker novo).
## Dependências
- Duras: C240 (índice facial e opt-out) e C233 (álbum/faceta) — entregues; C232 (ficha curada) para o catálogo.
- Duras de produto: aval jurídico do uso biométrico para figuras nomeadas (o dono declarou na sessão de 2026-10-01; registrar no runbook).
## Fora de escopo
- Nomear terceiros ou qualquer pessoa fora do catálogo curado; busca por rosto de outra pessoa; API pública de reconhecimento; vídeo; ranking/score por figura.
## Rabbit holes de produto
- **"Só completar" identificando todos os rostos.** **Corte:** só o catálogo curado; o resto segue anônimo.
- **"Só completar" com score por figura.** **Corte:** sem número em nenhum estado.
- **"Só completar" automático sem curadoria.** **Corte:** figura só entra com referência curada e revisão humana; falso positivo é tratado como débito de curadoria, não como dado.
## Questões em aberto (produto)
- **Origem das referências faciais de cada figura?** Retratos oficiais/arquivo do mandato (assessoria), 1–3 por pessoa; quem aprova. _(assumido — validar)_
- **A faceta facial substitui o texto (decisão do dono) — e o `people` de texto sai do card?** **Recomendação:** sim para a faceta; manter o campo na ficha como conteúdo, sem aparecer como "Quem aparece" para não confundir as duas origens. _(assumido — validar)_
- **Falsos positivos (limiar):** manter 0.45 e revisar por exceção, com lista de revisão no runbook. _(assumido — validar)_
## Referências
- `docs/plans/busca-selfie-escopo-b.md` (C240 — índice anônimo; este item adiciona a camada curada)
- `docs/plans/album-fotos-publico-busca.md` (C233 — faceta/card desenhados)
- `docs/plans/acervo-fotos-catalogacao-ia.md` (C232 — de onde vem `catalog.people`)
- `src/lib/publicFigureCatalog.ts` · `src/lib/archivePhotoPublicCatalog.ts`
## Self-score (shaping)
5/5 — (1) outcome verificável (filtrar por pessoa e ver só as fotos em que ela aparece, por rosto); (2) appetite comporta reuso do motor do C240 + curadoria de referências; (3) persona/job/aceite claros; (4) direção no codebase com donos e precedentes nomeados; (5) decisões de produto registradas (facial, catálogo, sem terceiros).

View file

@ -0,0 +1,182 @@
# Impl: S45 — Central de Conteúdos — cards verticais no catálogo e na seção da home
Status: aprovado
Atualizado em: 2026-10-01
Issue: #1410
Intenção: docs/plans/central-conteudos-cards-verticais.md
Appetite restante: herdado (~1–2 dias eng). Distribuição: Fase 1 ~0,3 · Fase 2 ~0,6 · Fase 3 ~0,4 · Fase 4 ~0,2. Corte nomeado: o card largo em linha (D4) é o primeiro a cair — a grade larga de 2 colunas permanece e o áudio/link volta à mídia no topo. Design UI (gate): `docs/plans/central-conteudos-cards-verticais-ui-design.html`.
## Leitura da intenção
- **Outcome:** no catálogo `/conteudos` (cards full, compactos e itens sintéticos S38), na seção da Central na home e na página da peça, a peça vertical aparece inteira na forma do tipo — Reel 9:16, foto 4:5, modelo 3:4 — e a peça horizontal aparece inteira, em letterbox na superfície neutra; o board deixa de ser uma parede de thumbs 16:9 e o eleitor reconhece a peça antes de tocar.
- **O que NÃO negociar:** sem rota/tela/player/galeria novos; sem migration/schema/ffprobe (a v1 decide pelo tipo, nunca pela dimensão real); mídia só no toque (sem autoplay, sem `preload`); filtros, busca, ações, copy, kill switch e o beacon C213 intocados; itens S38 continuam item-convite para `/cards` (sem cromo de mídia, sem download/share); C228 fora (play 56/44 e cromo atuais); demais seções da home, shell/nav e ficha interna da campanha intocados; overflow horizontal a 390 ≤1 px; `ContentPiecePublicItem`, URLs públicos e `/cards?model=` intactos.
- **O que reavaliar (hipóteses confirmadas no código):**
- "o slot não tem ratio próprio; o ratio vem do `className` do host" — **confirmado** (`ContentPieceMedia.tsx:110-126`); a forma será mapeada no dono `contentPieceCatalog.ts` (junto de `contentPieceMediaKind:544`) e aplicada por cada host.
- "large = video/audio/link; compact = image/text/other" — **confirmado** (`ContentPieceCatalog.tsx:47-49`); o artefato (cena 01) separa áudio/link numa grade larga e pede 4 colunas para o board principal ⇒ três buckets, não dois.
- "a cena 01 desenha um card de foto cheio no board principal" — **leitura do gate:** a decisão A manda a foto compacta **permanecer na linha**; a cena 01 informa a forma 4:5 (o card cheio é a referência de proporção do thumb 96×120) e o board principal é vídeo + modelos. O e2e vigente pina a foto como linha compacta (`frontendConteudos.e2e.spec.ts:302-304`).
- "o frame stub 320×180 e o comentário 'same 16:9 box' seguram o e2e" — **confirmado** (`tests/e2e/frontendConteudos.e2e.spec.ts:215-226,335`); o contain no palco 9:16 exige atualizar o comentário e adicionar os pins de geometria.
- "há token neutro pronto" — **corrigido:** não existe `neutral-stage`; a superfície neutra é `--campaign-band` #ebe9e9 (`src/app/(frontend)/styles.css:185`), já base do slot — sem token novo.
- "os itens S38 cortam a arte" — **confirmado** (`object-cover object-top`, `ContentCatalogCardItem.tsx:43-49`); palco 3:4 + contain resolve.
## Abordagem recomendada
```mermaid
flowchart TB
KIND["contentPieceMediaKind(item)"] --> FORM["contentPieceAspectClass(kind) — puro"]
FORM -->|video| V["aspect-[9/16]"]
FORM -->|image| F["aspect-[4/5]"]
FORM -->|audio · link · texto · outro| W["largo — sem classe vertical"]
MODEL["CardCatalogItem (S38)"] --> M["CONTENT_PIECE_ASPECT_CLASS.model = aspect-[3/4]"]
V --> CAT["ContentPieceCard — grade principal 4 col"]
V --> HOME["ContentPieceHomeCard — thumb 124 / bloco sm+"]
V --> DET["ContentPieceDetail — palco + coluna 360px"]
F --> ROW["ContentPieceCard — linha compacta 96×120"]
F --> HOME
F --> DET
W --> WIDE["ContentPieceCard — card largo na grade 2 col"]
W --> HOME
W --> DET
V --> CONT["ContentPieceMedia — imagem e frame object-contain"]
F --> CONT
```
**Opções consideradas (forma geral):** A) mapear a forma por tipo num helper puro no dono (`contentPieceCatalog.ts`) aplicado pelos hosts, com o catálogo em três buckets (principal 4 col, larga 2 col, compacta 2 col), contain na imagem/frame e palco do detalhe por tipo | B) classes inline em cada host, mantendo os dois buckets e só trocando o ratio 16:9 | C) ratio interno no `ContentPieceMedia` por `variant` + masonry/altura variável por peça.
**Recomendação:** A — é o artefato (cenas 01/03/04/05/06/07) e o único desenho em que áudio/link ficam largos sem virar blocos 16:9 estreitos; o mapa é reusado por 3 hosts reais (card full, home card, detalhe) + o item S38, e o contain é consequência direta do letterbox.
**Rejeitadas:** B porque deixa o board principal como parede de 16:9 (áudio/link incluídos) e duplica o mapa de forma em três hosts; C porque o sizing é host-dependent (thumb 96×120, coluna 360px do detalhe, bloco da home) e o item S38 nem usa o `ContentPieceMedia` — o ratio interno engessaria hosts futuros; masonry é vetado pela intenção.
### Decisões de engenharia
#### D1 — Como a forma por tipo é expressa (helper puro × inline × ratio interno)
**Opções:** A) tabela + função puras no dono (`src/lib/contentPieceCatalog.ts`, junto de `contentPieceMediaKind:544`): `CONTENT_PIECE_ASPECT_CLASS = { video: 'aspect-[9/16]', image: 'aspect-[4/5]', model: 'aspect-[3/4]' }` e `contentPieceAspectClass(kind)` → `video`/`image` ou `undefined` (largo) | B) módulo novo `src/lib/contentPieceAspect.ts` | C) ratio interno no `ContentPieceMedia` por `kind` + classes inline nos demais hosts | D) classes inline em cada host.
**Recomendação:** A — o dono do `kind` é `contentPieceMediaKind` e o unit do catálogo (`tests/unit/contentPieceCatalog.unit.spec.ts`) já cobre o módulo; o manifest e2e (`scripts/lib/e2e-affected-manifest.mjs:170-201`) já acorda `frontendConteudos` pelo prefixo `src/lib/contentPieceCatalog`; 3 call sites reais usam a função (card full, home card, detalhe) e o S38 lê a entrada `model` (4º call site) — depth check satisfeito. O 3:4 do modelo **não** entra na função de media kind (não é `ContentPieceMediaKind`); é lido da tabela pelo host do S38.
**Rejeitadas:** B é pass-through de ~8 linhas ao lado do dono, exigiria prefixo novo no manifest e arquivo de teste próprio; C engessa hosts com sizing diferente e não cobre o S38 (que não usa o slot); D duplica a tabela em 3 hosts e garante drift (alguém corrige só o card).
#### D2 — `object-cover` → `object-contain` na imagem e no frame
**Opções:** A) `contain` nos dois (`ContentPieceMedia.tsx:190` imagem; `:277` frame), mantendo o player já `contain` (`:235`) | B) manter `cover` na imagem (foto de feed 4:5 "casa" com o slot) e `contain` só no frame | C) decidir por dimensão real (ffprobe/migration).
**Recomendação:** A — é a consequência direta de "nunca corta": foto 4:5 em slot 4:5 continua preenchendo (contain = cover no mesmo ratio), a horizontal ganha letterbox na `--campaign-band` e o frame do C226 (16:9) aparece inteiro no palco 9:16.
**Rejeitadas:** B quebra o aceite para foto horizontal (recorte); C é vetado pela intenção (v1 por tipo).
**Efeito nos testes:** o unit `contentPieceMedia.unit.spec.tsx` não pina `object-cover` hoje ⇒ adicionar pin de `object-contain` na imagem e no frame; o e2e ganha `toHaveCSS('object-fit', 'contain')` e o comentário "same 16:9 box" (`:335`) vira "same 9:16 box".
#### D3 — Buckets e densidade do catálogo (4 colunas · grade larga · linha compacta)
**Opções:** A) três buckets no `ContentPieceCatalog`: principal (vídeo intercalado com modelos S38, `sm:grid-cols-2 lg:grid-cols-4`), larga (áudio + link, `sm:grid-cols-2`) e compacta (imagem/texto/outro, `sm:grid-cols-2`), todas com `items-start` e `gap-5` | B) manter dois buckets e só mudar a grade large para 4 colunas | C) masonry/altura variável por peça.
**Recomendação:** A — é o artefato (cena 01: principal 4 col; áudio/link largos em 2; compacta mantém a linha; "Grade: 4 colunas · gap 20px"); `interleaveCatalogItems` continua só no principal (modelos não pertencem à grade larga); `items-start` impede o stretch de igualar alturas diferentes; o skeleton `(catalog)/loading.tsx:8-15` acompanha (4 col, mídia 9:16) por ser a mesma superfície.
**Rejeitadas:** B — áudio/link 16:9 em 4 colunas viram blocos estreitos e o vídeo vertical domina a linha (o artefato separa); C é vetado pela intenção ("não vira masonry nem grade de altura variável").
**Nota:** texto/outro permanecem na linha compacta — a própria linha é larga; o artefato não traz cena de texto e o gate só separou áudio/link (cena 07 lida como "não vertical", não como "bloco largo").
#### D4 — Forma do card largo de áudio/link
**Opções:** A) linha larga do artefato: `grid min-h-[156px] grid-cols-[112px_minmax(0,1fr)] sm:min-h-[170px] sm:grid-cols-[190px_minmax(0,1fr)]`, mídia esticada à esquerda, corpo à direita, `ContentPieceMedia` no variant default, ações atuais | B) manter o card atual (mídia no topo, `aspect-video`) dentro da grade larga | C) card vertical.
**Recomendação:** A — "permanecem largos" (gate B) e o artefato desenha a linha (cena 01/02); reusa o card e as ações sem variant novo. Ajuste nomeado: o painel de plataforma (link) usa escala compacta no phone (`text-base sm:text-2xl`, em vez do `text-2xl` fixo de `ContentPieceMedia.tsx:166`) para "Instagram" caber na coluna de 112 px.
**Rejeitadas:** B diverge da cena 01 e desperdiça a largura; C é vetado pelo gate ("verticalizá-los produziria vazio sem ganho").
**Corte nomeado do appetite:** se a janela apertar, B é o corte (a grade larga permanece); a crítica do designer decide se a linha volta depois.
#### D5 — Home: thumb mobile e bloco `sm+` por tipo
**Opções:** A) classe por `kind` no `ContentPieceHomeCard`: foto `aspect-[4/5] self-start`, vídeo `aspect-[9/16] self-start`, largo `max-sm:h-[156px] self-start sm:aspect-video`; grade de 3 colunas mantida + `items-start` | B) thumb único 4:5 para tudo | C) altura variável/masonry.
**Recomendação:** A — cena 04 (foto 4:5 · vídeo 9:16 · áudio 156 px) e cena 03 (bloco `sm+` com o mesmo ratio; áudio 16:9 largo); a grade `sm:grid-cols-2 lg:grid-cols-3` (`ContentPieceHomeBoard.tsx:241-245`) fica, ganhando `items-start` para as alturas diferentes não esticarem; o layout de peça única (`:176`) permanece e herda a forma do tipo.
**Rejeitadas:** B corta/achata o Reel; C é vetado.
#### D6 — Página da peça: palco por tipo e coluna de mídia
**Opções:** A) palco por tipo (`video` 9:16, `image` 4:5, largo `aspect-video`) e coluna fixa `lg:grid-cols-[360px_minmax(0,1fr)]` como a cena 05 | B) só trocar o ratio mantendo `lg:grid-cols-[1.2fr_.8fr]` | C) manter as colunas e capar o palco (`lg:max-w-[360px]`).
**Recomendação:** A — a cena 05 decide a coluna ("A coluna de mídia tem 360px no desktop e não cresce até dominar o texto"); com 1.2fr o palco 9:16 daria ~576×1024 e dominaria a página. É uma classe no grid do `ContentPieceDetail.tsx:53` (`lg:grid-cols-[360px_minmax(0,1fr)]`), mantendo `gap-8 lg:gap-10`; o host `max-w-5xl` (`[slug]/page.tsx:86`) fica.
**Rejeitadas:** B deixa o palco dominar; C cria duas regras de largura e o texto não ganha a largura liberada.
#### D7 — Item S38: arte 3:4 inteira sem mudar a semântica
**Opções:** A) `CONTENT_PIECE_ASPECT_CLASS.model` (`aspect-[3/4]`) na arte + `object-contain` no `next/image`; coluna mobile `112px`; placeholder, badge, `href` e semântica de convite intactos | B) manter `object-cover object-top` | C) palco 9:16 para todos os modelos.
**Recomendação:** A — cena 07 ("a arte real cabe inteira") e coerência do board; o item continua um único `<Link href={item.href}>` (`/cards?model=<id>`) com o beacon C213 no clique; o `sizes` do `next/image` acompanha a grade de 4 colunas (`(min-width: 1024px) 260px, (min-width: 640px) 45vw, 112px`).
**Rejeitadas:** B corta a arte (o defeito que a entrega corrige); C alonga as artes 3:4/1:1 (o artefato fixa 3:4).
#### D8 — WebKit `aspect-ratio` × spacer
**Opções:** A) `aspect-*` nos slots block/grid (card full, home card, detalhe, arte S38) e caixa fixa `h-[120px] w-24` no thumb da linha compacta (flex child) | B) spacer in-flow do `CardModelTile` (`CardModelTile.tsx:42-55`) em todos os novos slots | C) `aspect-*` inclusive no flex child.
**Recomendação:** A — o bug do WebKit #265243 é de **flex item** em relayout; o único flex child novo é o thumb compacto (`flex items-center` + `shrink-0`) e o artefato já o desenha como caixa fixa 96×120 — a caixa fixa elimina a classe do bug sem wrapper; nos demais contextos (block/grid) `aspect-*` é o que o repo já usa em produção (`CampaignStorySection.tsx:50-52`, `ReelLibraryCard.tsx:13`, `ContentPieceHomeCard.tsx:86` hoje).
**Rejeitadas:** B é cerimônia onde o bug não existe; C reintroduz a classe do bug de produção no único ponto flex.
**Gatilho de revisitação:** se a crítica em Safari/Orion mostrar colapso em algum slot, o fallback nomeado é o spacer do `CardModelTile` naquele slot.
#### D9 — Testes (unit do mapa, pins de contain, e2e de geometria)
**Opções:** A) unit do mapa puro no arquivo do dono + pins de `object-contain` no unit do media + pin de classe por kind no unit do board da home + bboxes/overflow nos testes e2e existentes | B) suíte nova de screenshot/visual regression | C) só ajustar o e2e que quebrar.
**Recomendação:** A — o mapa é contrato puro (unit barato); `contain` é uma classe (pin unit); o e2e mede os boxes (9:16 do vídeo, 96×120 da foto, 3:4 do modelo, 156 px do largo, palco 9:16/4:5 do detalhe) e o overflow a 390 do catálogo, **dentro dos testes que já criam as peças**, sem novo teste global.
**Rejeitadas:** B não há infra de snapshot no repo e o CI não roda e2e full (OPS72); C deixaria o aceite (geometria) sem prova.
**OPS72:** rodar os e2e afetados com `--workers=1` (ou a cadeia padrão de projetos) por causa da limitação de seed (`seedTestUser` colide com `--no-deps` + projetos paralelos).
#### D10 — Cromo e play (fechado, não decidir de novo)
**Opções:** A) não tocar em cromo: play atual (56/44), marcador, sombra, cores | B) puxar o cromo do C228 (play 68 px, acentos, badge, sombra) | C) autoplay no card.
**Recomendação:** A — C228 é só cromo do mesmo slot e o body manda fechá-lo antes de mexer no `ContentPieceMedia`; a superfície/cores atuais ficam (o cream do áudio na cena 01 é nuance cosmética, não portada — gatilho: crítica do designer).
**Rejeitadas:** B está fora de escopo e não bloqueia (cromo ≠ ratio); C quebra o contrato do S27 (mídia só no toque).
### Componentes / mudanças
- **`CONTENT_PIECE_ASPECT_CLASS` / `contentPieceAspectClass`** (`src/lib/contentPieceCatalog.ts`, junto de `contentPieceMediaKind:544`): tabela pura `video → aspect-[9/16]`, `image → aspect-[4/5]`, `model → aspect-[3/4]`; função `contentPieceAspectClass(kind): string | undefined` (áudio/link/texto/outro = `undefined` = largo). Classes literais (o Tailwind JIT varre `src/lib`). Unit novo no `contentPieceCatalog.unit.spec.ts`.
- **`ContentPieceMedia`** (`src/components/conteudos/ContentPieceMedia.tsx`): `object-cover → object-contain` na imagem (`:190`) e no frame (`:277`); painel do link responsivo (`text-base sm:text-2xl` em `:166`). Nada mais: play, marcador, durações e superfícies intocados.
- **`ContentPieceCard`** (`ContentPieceCard.tsx`): três formas — compacta (imagem/texto/outro) com thumb `h-[120px] w-24 shrink-0` (96×120 do artefato); full (vídeo) com `className={contentPieceAspectClass(kind)}` (9:16) no lugar de `aspect-video:143`; larga (áudio/link) com o grid de D4 e corpo `min-w-0 p-4` reusando tag/título/meta/evidência/ações (`:145-160`).
- **`ContentPieceCatalog`** (`ContentPieceCatalog.tsx:39-84`): três buckets; principal `interleaveCatalogItems(videoPieces, cards)`; grades `items-start gap-5 sm:grid-cols-2 lg:grid-cols-4` (principal) e `mt-6 items-start gap-5 sm:grid-cols-2` (larga/compacta); guardas `length > 0` mantidas.
- **`ContentPieceHomeCard`** (`ContentPieceHomeCard.tsx:74-87`): `className` por kind (D5) no lugar do `aspect-video self-start:86`.
- **`ContentPieceHomeBoard`** (`ContentPieceHomeBoard.tsx:241-245`): `items-start` na grade; mais nada.
- **`ContentPieceDetail`** (`ContentPieceDetail.tsx:53-61`): palco por kind + `lg:grid-cols-[360px_minmax(0,1fr)]`.
- **`ContentCatalogCardItem`** (`ContentCatalogCardItem.tsx:23-58,75`): arte `aspect-[3/4]` + `object-contain` (no lugar de `min-h-40 sm:aspect-video` e `object-cover object-top`), coluna mobile `112px`, `sizes` atualizado; `href`, badge, placeholder e beacon intactos.
- **Skeleton** (`src/app/(frontend)/conteudos/(catalog)/loading.tsx:8-15`): grade `lg:grid-cols-4` e mídia `aspect-[9/16]`, 4 itens.
- **Migration:** nenhuma — nenhuma collection/field muda.
- **Access / Consent:** nenhum — sem escrita nova, sem PII, sem rota de API.
- **UI:** Impeccable B — port do artefato aprovado (`central-conteudos-cards-verticais-ui-design.html`, cenas 01–07); sem token novo (band/cream existentes); sem seção/rota nova.
- **Manifest e2e:** sem mudança — `src/components/conteudos` e `src/lib/contentPieceCatalog` já acordam `frontendConteudos` (`scripts/lib/e2e-affected-manifest.mjs:170-201`).
### Dados → forma
Não aplicável — a intenção decidiu que não há dado novo (nenhum contador, série ou métrica); a forma é enquadramento visual por tipo e a "contagem" continua no beacon anônimo existente (C213), intocado.
## Fases verificáveis
1. **Fase 1 — mapa puro + contain (tracer, ~0,3 dia):** `CONTENT_PIECE_ASPECT_CLASS`/`contentPieceAspectClass` no dono; `object-contain` na imagem e no frame; unit do mapa (`contentPieceCatalog.unit.spec.ts`) e pins de contain (`contentPieceMedia.unit.spec.tsx`). Prova: `pnpm test:unit` verde; nenhuma UI nova ainda.
2. **Fase 2 — catálogo (grade, card largo, compacto, S38, skeleton; ~0,6 dia):** três buckets/`items-start`/`gap-5`; card largo de áudio/link; thumb 96×120; arte S38 3:4 contain; skeleton. E2E: peça de áudio no primeiro teste (2 → 3 artigos), bbox 9:16 do vídeo, 96×120 da foto, 3:4 do modelo, `object-fit: contain`, overflow 390 ≤1, comentário "same 9:16 box", painel do link na linha. Prova: `pnpm test:e2e --no-deps -- tests/e2e/frontendConteudos.e2e.spec.ts` (workers=1) verde e screenshots locais batendo com as cenas 01/02/06.
3. **Fase 3 — home + página da peça (~0,4 dia):** classe por kind no `ContentPieceHomeCard` + `items-start` no board; palco/coluna 360 do detalhe; pin de classe por kind no `contentPieceHomeBoard.unit.spec.tsx`; e2e da home (foto 4:5, vídeo 9:16, overflow já existente) e da página da peça (palco 9:16 com frame contain e largura 360; foto 4:5). Prova: e2e verde; cenas 03/04/05 conferidas.
4. **Fase 4 — gates (~0,2 dia):** `pnpm gate:fast`; `pnpm test:int` (nada de domínio mudou, mas roda); `pnpm test:e2e:affected` (OPS72: `--workers=1`); entrada `docs/changelog/2026-10-01-s45.md`; `pnpm push`; PR ready com CI verde e crítica do designer (trigger c).
## Rabbit holes / Não escopo (engenharia)
- **Dimensão real / ffprobe / width-height / migration:** v1 é por tipo; adaptativa é decisão futura, não requisito.
- **Masonry / altura variável por peça:** uma forma por tipo, grade fixa.
- **Cromo C228** (play 68, acentos, badge, sombra) e qualquer polimento do slot; superfície/cream do áudio.
- **Autoplay/preload no card** — mídia só no toque.
- **Filtros, busca, tema, facetas, copy, kill switch, analytics/beacon** — intocados.
- **Ficha interna da campanha** (`ContentPieceCardList`, markup próprio) e demais seções da home.
- **C240** (carrossel/galeria multi-mídia) e a promoção de carrossel a conteúdo de primeira classe.
- **Imagem OG** e qualquer mudança de metadata/URL pública.
- **Reescrever o `ContentPieceMedia`** com ratio interno ou variants novos sem call site; criar `neutral-stage`/tokens novos.
- **Suíte de screenshot/visual regression** e "melhorar" o frame (ffmpeg intocado: `scale=640:-2`).
- **Débito deferido (triage do simplify):** o valor `156` aparece como `min-h-[156px]` (card largo) e `max-sm:h-[156px]` (thumb da home) — semânticas diferentes; **gatilho** para decidir token × coincidência: um 3º uso de 156px ou a edição simultânea dos dois hosts. Não vira Issue. O contrato `contentPieceAspectClass(): string | undefined` (fallback por host) reavalia-se só se surgir um 4º host.
## Riscos e mitigação
- **WebKit #265243 no thumb compacto (flex child):** caixa fixa `h-[120px] w-24` (D8); fallback nomeado = spacer do `CardModelTile`; o `codebaseConventions.unit.spec.ts:914-937` só guarda `src/components/cards` e segue verde.
- **Pins do e2e:** frame stub 320×180, comentário "same 16:9 box" (`:335`) e contagens (`:296-304`) mudam — atualizar no mesmo PR; a peça de áudio nova leva o primeiro teste de 2 → 3 artigos, mantendo 1 artigo na grade principal (o áudio vai para a grade larga); rodar com `--workers=1` (OPS72).
- **Split do board quebra o locator estrutural** (`[data-card-model]` → parent, `:302-304`): a grade principal continua com 1 artigo (vídeo) + 6 modelos; verificar antes do push.
- **Painel do link na coluna de 112 px:** escala `text-base sm:text-2xl` (D4); conferir visualmente a 390 (o artefato não desenha o link mobile).
- **`sizes` do S38 desatualizado** com 4 colunas: atualizar para não super-baixar imagem.
- **Home `sm+` com vídeo 9:16 alto:** `items-start` evita o stretch das linhas; grade 3 colunas mantida (cena 03).
- **Detalhe a 360 px:** texto com ~560 px em `lg` — decisão do artefato; conferir sem overflow no e2e (bbox do palco a 1280) e na crítica.
- **Sem dimensão real:** foto horizontal letterboxa no 4:5 e vídeo vertical preenche o 9:16 — comportamento honesto por tipo; adaptativa fica fora.
- **C240 (#1399):** reescreve os mesmos arquivos e não roda em paralelo (ordem sem fixação; quem rodar depois porta sobre a base do outro — o design do C240 se ajusta ao palco vertical se o S45 vier primeiro). C228 (#1358) não bloqueia (cromo ≠ ratio).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: vertical inteiro (9:16/4:5/3:4), horizontal em letterbox na `--campaign-band`, no catálogo, na home e na página da peça; S38 3:4 inteiro e ainda convite para `/cards`; compacta mantém a linha com thumb 96×120.
- [ ] Invariantes AGENTS/engineering-standards: sem migration/Consent/PII/access; sem autoplay; ações, filtros, busca, copy, kill switch e C213 intocados; identificadores em inglês, copy pt-BR; ficha interna e demais seções da home intocadas.
- [ ] Testes: unit do mapa puro + pins de `object-contain` + pin de classe por kind na home; e2e de geometria (9:16, 96×120, 3:4, 156 px, palco 9:16/4:5, largura 360) e overflow 390 ≤1; int verde; sem inflar suíte.
- [ ] **PARADA OBRIGATÓRIA: nenhuma.** Auditoria: sem Consent/LGPD, sem migração, sem rota/tela nova, sem mudança de URL pública ou de shape de dados; nenhuma escrita nova; produção/DB de prod/merge sem CI intocados.
## Self-score (decision-quality, gate ≥4)
1. **Decisões caras com rejeitadas — 5/5:** D1–D10 no formato Opções/Recomendação/Rejeitadas; o mapa de forma, o split do board e a coluna do detalhe (as caras de reverter) têm alternativas explícitas e corte nomeado.
2. **Cabe no appetite — 4/5:** ~1,5 dia dentro da janela de 1–2; sem migration/rota/collection; o card largo em linha é o corte nomeado se estourar.
3. **Rabbit holes nomeados — 5/5:** ffprobe/migration, masonry, C228, autoplay, filtros/copy, ficha interna, C240, OG, tokens novos e visual regression com corte explícito.
4. **Depth check / reuso — 5/5:** edita os donos (`contentPieceCatalog` para o mapa, `ContentPieceMedia` para o contain, `ContentPieceCatalog`/`ContentPieceCard`/`ContentPieceHomeCard`/`ContentPieceDetail` para os hosts); nenhum gêmeo, nenhum módulo pass-through, sem variant novo, sem token novo, sem manifest novo.
5. **Intenção preservada — 5/5:** o outcome não foi reescrito; a engenharia só escolheu a forma de expressar a regra (mapa por tipo, contain, buckets, palcos) e manteve todos os guardrails — inclusive "forma por tipo, não por dimensão real".
Média ≈ 4,8/5 — acima do gate.

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

@ -0,0 +1,69 @@
# C247 — Ligar/desligar globais públicos fora do admin (revalidação + ops)
Status: rascunho
Atualizado em: 2026-10-01
Issue: #1419
Priority: P3
Impeccable: A — sem UI
Design UI: N/A — sem UI
Appetite: ~0,5 dia eng; um outcome verificável — a ops muda uma flag pública por CLI e o site reflete sem depender do admin
Responsável: —
## Intenção
Na abertura do C242 as ops descobriram um atrito real: o update do global `photoAlbum` (canal de remoção, `selfieSearchEnabled`) foi feito por CLI/script, mas o **cache do global** (`unstable_cache`, tag `global_photoAlbum`) só é revalidado pelo `afterChange` **dentro do processo Next** — a allowlist do `POST /api/revalidate` não cobre tags de globais (só `global_privacy-policy` e afins). Na prática: a ops não consegue ligar/desligar a busca por selfie por CLI; precisou do clique no admin. Este item dá à ops um caminho de primeira classe, com guardas, para flags públicas operadas fora do admin.
## Persona e fluxo
- **Persona / contexto:** operador (homeserver/CLI) que precisa abrir/fechar uma superfície pública sem depender do navegador/admin.
- **Job principal:** mudar a flag e ver o efeito no site em segundos, com registro e rollback.
- **Fluxo desejado:** roda o comando com a flag nova e as guardas → grava o global pelo Local API → busta a(s) tag(s) do global no servidor → confere em `/fotos`, `/fotos/encontre` e na home.
- **Anti-goals de produto:** abrir SQL cru; criar segundo caminho de permissão; expor segredo no log.
## Objetivo e aceite
- Comando de ops (ex.: `pnpm ops:global photoAlbum --selfie-search on`) que atualiza o global com `overrideAccess` e **revalida a tag do global** no processo servidor (via allowlist do `/api/revalidate` derivada de `getGlobalCacheTag`, ou rota de ops dedicada com segredo).
- Efeito visível em ≤60 s em `/fotos`, `/fotos/encontre` e na home (entrada/seção), sem restart.
- Guardas de escrita existentes (`TEQO_ENV` casando o banco, `*_CONFIRM=1` fora do dev local), recibo e rollback (mesmo comando com o valor anterior).
- A allowlist continua fechada: só tags de globais/superfícies explicitamente permitidas; nada de tag arbitrária.
## Dados (intenção)
- **Vou apresentar dados?** Não — operação.
## Dados da decisão (literais)
- **Achado da abertura do C242 (2026-10-01):** update de global por CLI não revalida o cache do servidor; a ops teve de usar o admin para ligar `selfieSearchEnabled`.
- **Allowlist atual:** `posts`, `global_privacy-policy`, `election-tse`, `municipality-catalog`, `social-feed`, `archivePhotos`, `contentPieces` — sem tags de globais operacionais.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/utilities/revalidateRequest.ts` (allowlist derivada de `getGlobalCacheTag` para os globais públicos operacionais), `src/utilities/globals.ts` (tag owner), script novo `scripts/ops-global.mjs` (padrão de guardas/recibos das CLIs).
- **Precedente:** `SocialFeedSettings` (revalidação de global) e as CLIs de ops do C230/C242 (`assertEnvironmentDatabaseTarget`, `assertWriteConfirm`).
- **Risco de acoplamento:** allowlist é superfície de segurança do endpoint; manter a lista explícita e testada (unit) e nunca aceitar tag arbitrária; o script precisa do segredo do endpoint no ambiente do alvo.
## Dependências
- Nenhuma dura (independente); nasceu do C242.
## Fora de escopo
- UI de ops, botões no admin, exposição de flags ao público.
## Rabbit holes de produto
- **"Só completar" com uma API genérica de revalidação.** **Corte:** allowlist fechada por tag explícita.
- **"Só completar" gravando por SQL.** **Corte:** sempre Local API (hooks/versões).
## Questões em aberto (produto)
- **Quais globais entram na allowlist?** **Recomendação:** os operacionais públicos (`photoAlbum`, `site-settings`, `home-page`, `metadata`) — nada além. _(assumido — validar)_
## Referências
- `docs/plans/busca-selfie-escopo-b-impl.md` (C242 — o atrito observado)
- `src/utilities/revalidateRequest.ts` · `src/utilities/globals.ts` · `src/app/(frontend)/api/revalidate/route.ts`
## Self-score (shaping)
5/5 — (1) outcome verificável (flag muda por CLI e o site reflete); (2) appetite pequeno; (3) persona/job/aceite claros; (4) direção com donos e precedentes; (5) achado real de ops registrado.

View file

@ -0,0 +1,85 @@
# Post-mortem: home da campanha lenta — masters AVIF 4K servindo recortes de 177–747px
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| Data do post-mortem | 2026-10-01 |
| Severidade | alta (degradação mensurável de segundos na página principal; sem indisponibilidade nem perda de dados) |
| Ambiente | prod |
| Issue(s) | sem Issue |
| PR do fix | [#1421](https://github.com/fsolla/teqo/pull/1421) |
| Detectado por | humano (relato na sessão de 2026-10-01 ~19:30 -03; confirmou a lentidão na própria máquina) |
## Timeline
| Momento | Data/hora | Evento |
| --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Início provável | 2026-08-16 00:49 -03 | Commit `7ddc281f` (`feat(site): publica home da campanha Jorge Solla 1313`) publica o hero com os 5 masters AVIF de 3840px (14–22MP) — a home passou a gerar derivados caros no otimizador. |
| Detecção | 2026-10-01 ~19:30 -03 | Humano relata "Images are loading slowly in jorgesolla1313.com.br" e confirma a lentidão na própria máquina. Medição em prod: `/_next/image` com TTFB ~1,6s em miniaturas 256w; um request de `RUI - 2-2.avif&w=256` pendurou >120s. Sem Issue. |
| Correção implementada | 2026-10-01 | Worktree `fix/15`: 5 masters regerados, `minimumCacheTTL` 14400, srcs/sizes do hero corrigidos, aviso no gerador e 3 guards. Benchmark pós-fix: 38–171ms por miniatura (soma 381ms, 11× mais rápido); `pnpm gate:fast` verde. |
| Correção mergeada | pendente | PR [#1421](https://github.com/fsolla/teqo/pull/1421) aberto em 2026-10-01 com o fix, os guards e este post-mortem; merge pendente do required check + auto-merge. |
| Deploy | pendente | Merge em `main` dispara o deploy; produção só após approve humano no environment `production`. |
| Verificado em prod | pendente | Aguardando deploy e confirmação do humano. |
## O bug
Em produção, a home da campanha (`jorgesolla1313.com.br`) entregava imagens lentamente: `/_next/image` respondia com TTFB ~1,6s nas miniaturas de 256w, e um request de `RUI - 2-2.avif&w=256` chegou a ficar pendurado por mais de 120s. O hero usa 5 imagens eager/preload (4 aliados + o retrato `priority`), e a home referenciava 23 URLs únicas de `/_next/image`. As evidências de cache mostravam o motivo da recorrência: `cache-control: public, max-age=60, must-revalidate` com `x-nextjs-cache: STALE` e `cf-cache-status: DYNAMIC` (o Cloudflare não cacheia `/_next/image`). O sintoma afetava a página principal inteira, sem indisponibilidade nem perda de dados. **Sintoma — não a causa.**
## Causa-raiz
Os masters dos aliados estavam em `public/`: `Jeronimo.avif` 3840×5760 (3,60 MB), `Lula.avif` 3840×3786 (1,57 MB), `JOA00162.avif` 3840×5757 (1,44 MB), `RUI - 2-2.avif` 3840×5757 (1,05 MB) e `WAGNER - 2-9 final.avif` 3840×5757 (1,03 MB) — 14–22MP, todos exibidos entre 177px e 747px (CSS: aliado ≤211px, Lula ≤284px, retrato ≤702px). Esses masters foram gerados pelo default do `pnpm images:resize` (`--max-width` avif 3840, o "2x desktop" de `scripts/lib/imageResize.mjs`) aplicado a recortes pequenos de hero. O otimizador do Next decodifica o master inteiro a cada derivado: benchmark sharp local com os masters antigos deu 0,67–1,13s de CPU por miniatura 256w (Jeronimo 1130ms, Lula 672ms, JOA00162 880ms, RUI 837ms, WAGNER 805ms; soma 4324ms). O download cru de `JOA00162` levou 7,24s numa medição.
A recorrência vem do cache: o default de `images.minimumCacheTTL` é 60s (`node_modules/next/dist/shared/lib/image-config.js:56`), o cache é volátil (container recriado no deploy, sem volume para `.next/cache/images`) e o Cloudflare não cacheia `/_next/image` (`DYNAMIC`) — ou seja, sharp regenerava praticamente toda miniatura a cada janela de TTL no homeserver. Commit que publicou a home/hero com esses masters: `7ddc281f` (2026-08-16 00:49 -0300).
### 5 whys
1. Por que a home estava lenta? Porque cada derivado de `/_next/image` custava ~0,7–1,1s de CPU (TTFB ~1,6s; um request >120s).
2. Por que custava isso? Porque o sharp decodifica o master AVIF inteiro (até 22MP) para produzir cada derivado, mesmo um de 256w.
3. Por que o master era 3840px? Porque o default do `pnpm images:resize` ("2x desktop") foi aplicado a recortes pequenos de hero.
4. Por que regenerava sempre? Porque o TTL default de 60s + cache volátil no container + Cloudflare `DYNAMIC` não seguram nenhuma geração.
5. Por que passou? Porque nenhum teste/guard olhava dimensões e bytes dos masters nem o TTL da entrega de imagem; o default do gerador parecia razoável e a home publicada não foi medida contra o otimizador real.
## Correção
- **Masters regerados** com `pnpm images:resize` (avif, default q60): aliados 768px, Lula 1024px, retrato 1536px → total 350,9 KB (vs 8,7 MB).
- **`next.config.mjs:10`**: `images.minimumCacheTTL: 14400` — o cache do otimizador deixa de expirar em 60s.
- **`src/components/CampaignHero.tsx`**: srcs do hero sem percent-encoding (`/RUI - 2-2.avif`, `/WAGNER - 2-9 final.avif`) e `sizes` corrigidos para o CSS real (caps 211px, 284px e 747px).
- **`scripts/resize-images.mjs`**: aviso quando a saída ≥2000px + nota no USAGE.
Resolve a causa: o master deixa de dimensionar o custo por derivado (masters pequenos), o cache deixa de expirar em 60s (TTL 4h) e a entrega do hero passa a pedir as larguras que o CSS realmente usa.
## Verificação
- Teste de regressão: `tests/unit/imageDelivery.unit.spec.ts` — teto 1600×2400 dos masters do hero, orçamento de dimensão de todo asset local literal do `src` entregue via `next/image`, TTL ≥3600 — e caso novo em `tests/unit/campaignHero.unit.spec.tsx` (src sem `%`): vermelhos sem o fix (3 falhas: 3840>1600, TTL null, `%`), verdes com.
- Verificação independente confirmou stash/pop limpo (sem o fix os guards falham; com o fix passam).
- Benchmark pós-fix: 38–171ms por miniatura (soma 381ms, 11× mais rápido que os 4324ms dos masters antigos).
- Suíte: `pnpm gate:fast` verde (460 arquivos / 5100 testes); e2e `tests/e2e/frontend.e2e.spec.ts` 42 passed (rodado duas vezes, antes e depois dos `sizes`).
- CI: pendente (PR ainda não aberto).
- Prod: pendente (merge/deploy/confirmação humana ainda não aconteceram).
## Prevenção
| Estratégia | Custo | Estado |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------- |
| Guards no `imageDelivery.unit.spec` (teto 1600×2400, orçamento de dimensão de todo asset local literal do `src` entregue via `next/image`, TTL ≥3600) | barata | implementada agora (este PR) |
| Caso no `campaignHero.unit.spec.tsx` exigindo src sem `%` | barata | implementada agora (este PR) |
| Aviso no `scripts/resize-images.mjs` quando a saída ≥2000px + nota no USAGE | barata | implementada agora (este PR) |
| Cache Rule Cloudflare para `/_next/image*` (infra, fora do repo) | cara | documentada — não implementada neste fluxo |
| Volume persistente para `.next/cache/images` no compose/deploy | cara | documentada — não implementada neste fluxo |
| Observabilidade de p95/TTFB de `/_next/image` + alerta | cara | documentada — não implementada neste fluxo |
| Lote de assets públicos fora do orçamento (`fundo.avif` 2063px ainda passa por `next/image`, `BG.jpg` 21,6MP/CSS, Pranchetas 2400², `.webp` irmãos 1920px sem uso, `LULA_E_SOLLA.png` 859KB, `SOLLA.png` 559KB) | cara | documentada — Issue de conteúdo com QA visual |
**Estratégia implementada:** os 3 guards de regressão (teto de dimensão dos masters do hero, orçamento de dimensão de todo asset local literal do `src` entregue via `next/image` e piso de TTL) + o caso de src sem `%` + o aviso do gerador quando a saída passa de 2000px.
**Estratégia documentada (cara):** Cache Rule do Cloudflare para `/_next/image*` (infra, fora do repo); volume persistente para `.next/cache/images` no compose/deploy; observabilidade de p95/TTFB com alerta; e o lote de assets públicos fora do orçamento — candidato a Issue de conteúdo com QA visual.
## Lições
- **Default de ferramenta é para o caso comum, não para o asset:** `--max-width 3840` ("2x desktop") aplicado a recortes de 177–747px gerou masters de 14–22MP. O otimizador paga por área do master, não pelos bytes que o usuário baixa — a miniatura continua pequena, o CPU é que explode.
- **Self-hosted muda o contrato do cache de imagem:** TTL default de 60s + cache efêmero no container + CDN que não cacheia `/_next/image` = regeneração perpétua. Nenhum dos três (TTL maior, volume, cache no edge) estava configurado.
- **Teste de entrega de imagem mede o que o usuário recebe:** dimensão do master, orçamento de bytes e TTL teriam pegado o bug no PR do hero; teste de fumaça não mede performance. Nomes com espaço/`%` no `src` também viraram contrato testado.
- **A detecção foi humana e ~6 semanas depois do commit:** nada media TTFB/`/_next/image` em prod, então a lentidão era visível mas não alertável — é exatamente a prevenção cara registrada (observabilidade).
- **Reduzir a exibição não reduz a decodificação enquanto o master fica:** `sizes` e CSS definem o derivado; o custo por derivado é governado pelo master.

View file

@ -0,0 +1,87 @@
# Post-mortem: refresh RSC do live preview apaga o hash `#novidades` e derruba o verify do deploy
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| Data do post-mortem | 2026-10-01 |
| Severidade | alta (bloqueou o deploy do main; CI vermelho, sem impacto em produção — o `verify` roda antes de publicar) |
| Ambiente | CI (job `verify` do Deploy no GitHub Actions) |
| Issue(s) | #1413 (auto-unblock; não fechar — o wrapper cuida) |
| PR do fix | #1415 |
| Detectado por | CI (deploy run vermelho) / wrapper auto-unblock |
## Timeline
| Momento | Data/hora | Evento |
| --------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Início provável | julho/2026 | Bridge de live preview (`RefreshRouteOnSave`) montado em toda página pública (`375398af`/`f23c2582`) — a corrida fica latente desde então. |
| Detecção | 2026-10-01 21:19Z | run `36924161319` (push em `main`, commit `0a8b0774`), job `verify`, step "E2E tests (full suite, single job, 4 workers)": 1 failed, 4 flaky, 329 passed (8.8m); o failed é `campaignNewsletter` (hash `#novidades` ausente nas 3 tentativas); Issue #1413 às 21:21:50Z (auto-unblock). |
| Bloqueio do deploy | 2026-10-01 | `deploy-staging` e `deploy-production` ficaram skipped no run vermelho — deploy do main bloqueado. |
| Correção implementada | 2026-10-01 | worktree `fix/falha-do-job-verify-do-deploy-github-actions-desbloque-2`. |
| Correção mergeada | 2026-10-01 22:38Z | PR #1415 mergeado pelo auto-merge nativo (rebase; merge commit `8db841f7`) após o check `checks` verde. |
| Deploy | 2026-10-01 23:43Z | run `36938361233` (requeue OPS104 do merge `8db841f7`): `verify` verde às 23:25:54Z e `deploy-staging` verde às 23:43:33Z; `deploy-production` aguardando approve humano (não aprovado por esta sessão). |
| Verificado em prod | não se aplica | bug de CI/teste; a confirmação é o `verify` verde do run `36938361233` (e2e full 332 passed, 0 failed — o `campaignNewsletter` que falhava passou); produção depende do approve humano (não aprovado por esta sessão). |
## O bug
O clique no CTA secundário do hero (`<a href="#novidades">`, `src/components/CampaignHero.tsx:107`) deixava de gravar o hash `#novidades` na URL de forma intermitente, e a seção de novidades não era alcançada por âncora. Não havia caminho de usuário determinístico — era corrida de timing —, então só a suíte e2e pegava.
No run vermelho `36924161319` (evento `push` em `main`, commit `0a8b0774` — "feat(S44): home divulga a Plenária da Vitória…", criado 2026-10-01T20:47:33Z), o job `verify` falhou no step "E2E tests (full suite, single job, 4 workers)": 1 failed, 4 flaky, 329 passed (8.8m). O failed foi `[campaign] tests/e2e/campaignNewsletter.e2e.spec.ts:142:3 › Campaign home novidades capture › captures the visitor and confirms in place`: `expect(page).toHaveURL(/#novidades/)` recebeu `http://localhost:3000/?e2e=…` (sem hash) nas 3 tentativas. Os flaky passaram no retry e não têm relação causal — `campaignColumnPicker`, `campaignSavedFilters`, `campaignTerritoriesHttp` e `frontendJingles`. Com o `verify` vermelho, `deploy-staging` e `deploy-production` ficaram skipped — sem impacto em produção, já que o `verify` roda antes de publicar. **Sintoma — não a causa.**
## Causa-raiz
O trace do Playwright provou o mecanismo: o clique aplicava `#novidades` e, ~30 ms depois, uma resposta RSC (`GET /?e2e=…&_rsc=acjh4`) regravava a URL sem hash; nos runs que passavam, o refresh chegava antes do clique. A regravação vinha do `HistoryUpdater` do App Router (`next/dist/client/components/app-router.js`, `window.history.replaceState(…, canonicalUrl)`); o refresh RSC, por sua vez, era disparado pelo bridge de live preview do Payload (`@payloadcms/live-preview-react`), montado em toda página pública via `RefreshRouteOnSave` no layout `src/app/(frontend)/layout.tsx:84`.
### 5 whys
1. Por que a URL perdia o hash? Porque o `HistoryUpdater` do App Router aplicava o refresh RSC e regravava a URL canônica sem hash.
2. Por que havia refresh RSC no mount? Porque `src/components/RefreshRouteOnSave.tsx` montava o bridge de live preview do Payload em TODA página pública; o pacote chama `refresh()` (`router.refresh()`) incondicionalmente no mount (e posta `ready` para `window.opener || window.parent`).
3. Por que o bridge era montado sem gate? Porque foi adotado como cidadão do layout (padrão da doc do Payload) — não checava se a página estava de fato dentro do preview do admin (iframe/popup).
4. Por que só derrotou a suíte agora? Porque a corrida existia desde julho/2026 (bridge em `375398af`/`f23c2582`), mas só virou sistemática no verify do S44 (`0a8b0774`): a home ganhou uma seção nova (Plenária) e o payload/janela do refresh cresceu o bastante para o RSC cair depois do clique.
5. Por que escapou até aqui? Porque nenhum teste afirmava "o hash sobrevive ao refresh", o unit não monta o layout, o e2e de PR (`selected`) nem sempre selecionava o spec, e `retries: 2` sem `failOnFlakyTests` transformava corrida real em "flaky verde" — o verify full do deploy foi o primeiro a pegar.
## Correção
Sem mudança de schema/produto, sem Consent/PII e sem migration. O que mudou:
- `src/components/RefreshRouteOnSave.tsx`: o bridge só monta quando há contexto real de preview (`window.parent !== window || window.opener != null`); standalone público renderiza `null` (estado inicial `false` + `useEffect`, SSR-safe) e deixa de disparar o `router.refresh()` de mount. O live preview (iframe/popup) segue funcionando.
- Prova de regressão: `tests/unit/refreshRouteOnSave.unit.spec.tsx` (2 casos: standalone não monta/não refresca; iframe monta/refresca) e caso e2e novo em `tests/e2e/campaignNewsletter.e2e.spec.ts:245` ("hero CTA keeps the hash when an on-mount refresh lands late"), que segura a resposta `_rsc` do home com `page.route` e só libera DEPOIS do clique (determiniza a corrida).
- `scripts/lib/e2e-affected-manifest.mjs`: entrada nova `['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx']` → specs `['frontend','campaignNewsletter']`.
Resolve a causa: sem refresh posterior ao clique, o `HistoryUpdater` não regrava a URL e o hash sobrevive — o gate elimina o refresh espúrio no standalone em vez de mascarar o assert.
## Verificação
- Teste de regressão: `tests/unit/refreshRouteOnSave.unit.spec.tsx` (2 casos) e o caso e2e novo `tests/e2e/campaignNewsletter.e2e.spec.ts:245` — falham sem o fix e passam com.
- RED sem o fix: unit 1 failed (`livePreview.mounts` = 1 no standalone); e2e novo falhou determinístico (`toHaveURL` sem `#novidades`) — logs `/tmp/unit-red.log` e `/tmp/e2e-red.log`.
- GREEN com o fix: unit 2/2; e2e novo 1 passed; arquivo inteiro `--repeat-each=3 --retries=0` 18/18; arquivo inteiro `--repeat-each=2 --retries=0` 12/12 (verificador); hero irmão em `frontend.e2e.spec.ts` 1 passed.
- Repro local (2026-10-01, worktree, modo prod com build `.next-e2e`, banco isolado `teqo_wt817_test`): `tests/e2e/campaignNewsletter.e2e.spec.ts` com `--retries=0 --repeat-each=4` deu 4 falhas em 20 execuções (16 passed), sempre no mesmo assert.
- `pnpm gate:fast`: lint e typecheck verdes; unit 5089 passed / 6 failed — todos flakes de ambiente (timeouts de 5s em `archiveCatalogCli` (2), `flickrImportCli` (2), `faceCli` (1) — CLIs que spawnam subprocesso — e `contentPiecePeopleField` (1)), arquivos alheios ao diff; re-run isolado dos 4 arquivos: 31/31 passed, exit 0.
- CI: check `checks` verde no PR #1415 e `verify` do deploy run `36938361233` verde — unit 460 arquivos / 5098 testes, int 109 arquivos / 1078 testes, e2e full 332 passed / 0 failed / 3 flaky no retry (o `campaignNewsletter` que falhava passou às 23:23Z, incluindo o caso de regressão novo).
- Prod: não se aplica (bug de CI/teste); a confirmação é o `verify` verde no deploy pós-merge; produção depende do approve humano (não aprovado por esta sessão).
## Prevenção
| Estratégia | Custo | Estado |
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------ |
| Gate de contexto do bridge (só monta em iframe/popup; standalone renderiza `null`) | barata | implementada neste PR |
| Unit de regressão `refreshRouteOnSave.unit.spec.tsx` (standalone não monta × iframe monta) | barata | implementada neste PR |
| E2E determinístico que segura a resposta `_rsc` e só libera depois do clique | barata | implementada neste PR |
| Entrada no manifesto de e2e afetado (`scripts/lib/e2e-affected-manifest.mjs`: componente + layout) | barata | implementada neste PR |
| Auditoria/inventário de mounts de terceiros que mutam URL/history/scroll (detector que falha quando `history.replaceState` roda sem interação) | cara | documentada — não implementada neste fluxo |
| `failOnFlakyTests`/quarentena global e triagem das classes flaky (já apontada no postmortem C104) | cara | documentada — não implementada neste fluxo |
| E2E real do live preview admin→iframe→frontend com URL por ambiente | cara | documentada — não implementada neste fluxo |
| Trocar `serverURL="http://localhost:3000"` hardcoded no bridge (e `livePreview.url` equivalente em `src/payload.config.ts:110`) por URL por ambiente | cara | documentada — não implementada neste fluxo — débito separado |
**Estratégia implementada:** o gate de contexto do bridge (bridge só monta em iframe/popup), o unit de regressão (standalone × iframe), o e2e determinístico (segura a resposta `_rsc` e libera depois do clique) e a entrada do componente + layout no manifesto de e2e afetado — tudo neste PR.
**Estratégia documentada (cara):** auditoria/inventário de mounts de terceiros que mutam URL/history/scroll (detector que falha quando `history.replaceState` roda sem interação); `failOnFlakyTests`/quarentena global e triagem das classes flaky (já apontada no postmortem C104); e2e real do live preview admin→iframe→frontend com URL por ambiente; e o `serverURL="http://localhost:3000"` hardcoded no bridge (e o `livePreview.url` equivalente em `src/payload.config.ts:110`), que deixa o preview de produção frágil — trocar por URL por ambiente é débito separado. Nada disso implementado neste fluxo.
## Lições
- Componente de terceiro montado no layout global pode mutar a URL da página inteira sem interação; bridge de preview deve ser gated pelo contexto de preview.
- Corrida real de ~20% local virou 3/3 no CI sob carga; teste de hash tem que segurar a resposta RSC e liberar depois do clique para ser determinístico.
- `retries: 2` sem `failOnFlakyTests` esconde corridas verdadeiras atrás de "flaky".

View file

@ -7,6 +7,7 @@ const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || '.next',
...(process.env.NEXT_OUTPUT_STANDALONE === '1' ? { output: 'standalone' } : {}),
images: {
minimumCacheTTL: 14400,
remotePatterns: [
new URL(`${allowedImageHost}/**`),
// YouTube video thumbnails (campaign home content board, S2) come from

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -93,6 +93,14 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: ['src/app/(frontend)'],
specs: ['frontend'],
},
{
// The Payload live-preview bridge mounts on every `(frontend)` page and
// owns when a soft refresh may run; a diff there (or in the layout that
// mounts it) can silently drop the in-page anchors, so it wakes the hero
// owner (`frontend`) and the novidades anchor spec (`campaignNewsletter`).
prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'],
specs: ['frontend', 'campaignNewsletter'],
},
{
// S19/S29 — the share-link surface: the `[type]` branch serves the OG card,
// the instant handoff and the announcement page (`.ics` included), and the

View file

@ -53,6 +53,10 @@ Opções:
--out <dir> pasta de saída (default public/)
-h, --help mostra esta ajuda
Recortes exibidos pequenos (ex.: hero) pedem --max-width ≈ 2x a maior
exibição: um master 3840px faz o /_next/image decodificar dezenas de MP
por derivado — a causa clássica de imagem lenta na home.
Caminhos podem ser arquivos de imagem ou pastas (varridas recursivamente).
O nome do arquivo é mantido; só a extensão muda (foto.jpg -> foto.avif + foto.webp).`
@ -243,6 +247,13 @@ export const runResizeImages = async (options) => {
`\n${results.length} arquivo(s) em ${join(outDir, '')} (${formats.join(', ')}, ${quality ? `qualidade ${quality}, ` : ''}${widthSummary}${removeBg ? ', fundo removido' : ''})\n`,
)
const oversized = results.filter(({ dimensions }) => Number(dimensions.split('×')[0]) >= 2000)
if (oversized.length > 0) {
console.warn(
`\naviso: ${oversized.length} saída(s) com 2000px ou mais de largura — o default AVIF (3840) é 2x um desktop de 1920px. Se o destino exibe menor (ex.: recorte de hero), repita com --max-width <2x a maior exibição>.`,
)
}
if (failures.length > 0) {
console.error(`\n${failures.length} falha(s):`)
for (const failure of failures) console.error(` - ${failure}`)

View file

@ -1,23 +1,28 @@
/**
* S27 — the loading state of the Central (artefato: cena 05): the skeleton
* shape of the board, so a slow connection sees the page it is about to get.
* S45 — the vertical board shape (4 columns from `lg`; the dominant 9:16 video
* slot, an approximation of the mixed board) with the media edge-to-edge like
* the real card.
*/
export default function ConteudosLoading() {
return (
<div className="mx-auto w-full max-w-6xl px-5 py-10 sm:px-8">
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
<div className="mt-9 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((index) => (
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-9 grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((index) => (
<div
key={index}
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white p-4"
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white"
>
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
<div className="mt-4 h-5 w-3/4 animate-pulse rounded bg-(--campaign-band)" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band)" />
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
<div className="aspect-[9/16] animate-pulse bg-(--campaign-band) motion-reduce:animate-none" />
<div className="p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
</div>
</div>
</div>
))}

View file

@ -5,31 +5,32 @@ const SUPPORT_URL = 'https://apoiar.me/jorgesolla'
const allyImages = [
{
id: 'rui',
src: '/RUI%20-%202-2.avif',
src: '/RUI - 2-2.avif',
width: 177,
height: 265,
sizes: '(min-width: 1366px) calc(6.14vw + 93px), 177px',
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'wagner',
src: '/WAGNER%20-%202-9%20final.avif',
src: '/WAGNER - 2-9 final.avif',
width: 177,
height: 265,
sizes: '(min-width: 1366px) calc(6.14vw + 93px), 177px',
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'jeronimo',
src: '/Jeronimo.avif',
width: 177,
height: 265,
sizes: '(min-width: 1366px) calc(6.14vw + 93px), 177px',
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'lula',
src: '/Lula.avif',
width: 216,
height: 213,
sizes: '(min-width: 1366px) calc(8.3vw + 125px), (min-width: 1024px) 238px, 216px',
sizes:
'(min-width: 1920px) 284px, (min-width: 1366px) calc(8.3vw + 124.6px), (min-width: 1024px) 238px, 216px',
},
]
@ -71,7 +72,7 @@ export const CampaignHero = () => (
alt="Jorge Solla, candidato a deputado federal pela Bahia"
width={495}
height={742}
sizes="(min-width: 1720px) 747px, (min-width: 1280px) 595px, (min-width: 1024px) 495px, 449px"
sizes="(min-width: 1920px) 747px, (min-width: 1024px) calc(28.16vw + 206.7px), 449px"
priority
className="campaign-hero-portrait-image absolute h-auto max-w-none object-contain"
/>

View file

@ -1,10 +1,28 @@
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import React, { useEffect, useState } from 'react'
/**
* The Payload live-preview bridge belongs to the preview surface the admin
* opens (an iframe in the edit view, or the popup variant). The package fires
* `router.refresh()` as soon as it mounts to pick up the saved data, and the
* App Router answers a refresh by rewriting the canonical URL — which drops
* any `#hash` applied between the click and the refresh (the hero CTA →
* `#novidades` deploy-verify race). A standalone public load must not pay that
* refresh, so the bridge mounts only when there is a preview parent
* (`window.parent` for the iframe, `window.opener` for the popup); the render
* stays `null` on every other page view.
*/
export const RefreshRouteOnSave: React.FC = () => {
const router = useRouter()
const [previewing, setPreviewing] = useState(false)
useEffect(() => {
setPreviewing(window.parent !== window || window.opener != null)
}, [])
if (!previewing) return null
return <PayloadLivePreview refresh={() => router.refresh()} serverURL="http://localhost:3000" />
}

View file

@ -3,7 +3,7 @@ import Link from 'next/link'
import { sendCardOpeningEvent } from '@/lib/contentEvents'
import { contentPieceTypeLabels } from '@/lib/contentPiece'
import type { CardCatalogItem } from '@/lib/contentPieceCatalog'
import { CONTENT_PIECE_ASPECT_CLASS, type CardCatalogItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import {
@ -15,7 +15,7 @@ import {
/**
* S38 — the art of a model item (artefato: cenas 01–03): the model's real file
* cropped from the top (the banners and the name line live there), or the
* whole in the 3:4 vertical slot (S45 — the art is never cropped), or the
* neutral dashed placeholder of the photo models, whose master is only the
* frame with a transparent photo window. The `NOVO` badge of the newest model
* sits on the art.
@ -23,7 +23,8 @@ import {
const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
<span
className={cn(
'relative grid min-h-40 place-items-center overflow-hidden sm:aspect-video sm:min-h-0',
'relative grid place-items-center overflow-hidden',
CONTENT_PIECE_ASPECT_CLASS.model,
item.art.kind === 'placeholder'
? 'bg-[linear-gradient(145deg,#eee9e5,#fff)]'
: 'bg-[linear-gradient(145deg,#184e92,#0876ac)]',
@ -44,8 +45,8 @@ const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
src={item.art.src}
alt=""
fill
sizes="(min-width: 1024px) 360px, (min-width: 640px) 45vw, 128px"
className="object-cover object-top"
sizes="(min-width: 1024px) 260px, (min-width: 640px) 45vw, 112px"
className="object-contain"
/>
)}
@ -72,7 +73,7 @@ export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => (
onClick={() => sendCardOpeningEvent(item.modelId)}
className={cn(
CONTENT_PIECE_CARD,
'grid grid-cols-[128px_1fr] text-black no-underline sm:block',
'grid grid-cols-[112px_minmax(0,1fr)] text-black no-underline sm:block',
item.badge ? 'border-2 border-[#e4102f]' : null,
CONTENT_PIECE_FOCUS,
)}

View file

@ -1,6 +1,11 @@
import Link from 'next/link'
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
CONTENT_PIECE_ASPECT_CLASS,
contentPieceBoardBucket,
contentPieceMediaKind,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
@ -84,8 +89,9 @@ const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => {
}
/**
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video/audio/card
* and link pieces take the full card with the asset on top; a photo/text is the
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video takes the
* full card with the 9:16 frame on top, audio/link take the wide row (the
* media column at the left, no framing to preserve), and a photo/text is the
* compact horizontal row. Actions work from the card itself — download never
* needs the piece page.
*/
@ -97,23 +103,25 @@ export const ContentPieceCard = ({
onShare,
themeMode,
}: ContentPieceCardProps) => {
const kind = contentPieceMediaKind(item)
const isCompact = kind === 'image' || kind === 'text' || kind === 'other'
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
if (isCompact) {
if (bucket === 'compact') {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(CONTENT_PIECE_CARD, 'flex items-center gap-4 p-3')}
>
{/* S45 — a fixed 96×120 (4:5) box: the compact row is a flex child, and
`aspect-ratio` on a flex item is the WebKit #265243 trap; the fixed
box keeps the whole photo, never a crop. */}
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
variant="thumb"
className="h-24 w-32 shrink-0 rounded-lg"
className="h-[120px] w-24 shrink-0 rounded-lg"
/>
<div className="min-w-0 flex-1">
<CardTag item={item} themeMode={themeMode} />
@ -121,14 +129,49 @@ export const ContentPieceCard = ({
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className="mt-3 flex flex-wrap gap-2">
<ContentPieceDownloadLink item={item} className="min-h-9 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-9 px-3" />
<ContentPieceDownloadLink item={item} className="min-h-11 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-11 px-3" />
</div>
</div>
</article>
)
}
const body = (
<div className="min-w-0 p-4">
<CardTag item={item} themeMode={themeMode} />
<ContentPieceCardTitle item={item} className="text-base" />
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)] sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
)
if (bucket === 'wide') {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(
CONTENT_PIECE_CARD,
'grid min-h-[156px] grid-cols-[112px_minmax(0,1fr)] sm:min-h-[170px] sm:grid-cols-[190px_minmax(0,1fr)]',
)}
>
<ContentPieceMedia item={item} playing={playing} onToggle={onToggle} onEnded={onEnded} />
{body}
</article>
)
}
return (
<article
data-content-piece={item.slug}
@ -140,24 +183,9 @@ export const ContentPieceCard = ({
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
className="aspect-video"
className={CONTENT_PIECE_ASPECT_CLASS.video}
/>
<div className="p-4">
<CardTag item={item} themeMode={themeMode} />
<ContentPieceCardTitle item={item} className="text-base" />
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red) sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
{body}
</article>
)
}

View file

@ -3,6 +3,7 @@
import { useState } from 'react'
import {
contentPieceBoardBucket,
contentPieceMediaKind,
interleaveCatalogItems,
isCardCatalogItem,
@ -16,14 +17,15 @@ import { ContentPieceCard } from './ContentPieceCard'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
/**
* S27 — the catalogue board (artefato: cena 01): full cards for video, audio
* and link pieces, compact rows for photo/text, one piece plays at a time
* (switching unmounts the previous element) and the share sheet is owned here,
* so every card opens the same vote message.
* S27 — the catalogue board (artefato: cena 01): the vertical board (video and
* the S38 models) runs four columns from `lg`, audio/link keep a wide two-column
* grid (no framing to preserve) and photo/text keep the compact scan row. One
* piece plays at a time (switching unmounts the previous element) and the share
* sheet is owned here, so every card opens the same vote message.
*
* S38 — the six card models are items of the same board: they take the full-card
* bucket and the whole item is one link to the studio (`/cards?model=<id>`); the
* old single invite tile is gone and no section groups them.
* S38 — the six card models are items of the same board: they take the vertical
* board bucket and the whole item is one link to the studio (`/cards?model=<id>`);
* the old single invite tile is gone and no section groups them.
*/
export const ContentPieceCatalog = ({
items,
@ -36,7 +38,8 @@ export const ContentPieceCatalog = ({
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(null)
const largePieces: ContentPiecePublicItem[] = []
const videoPieces: ContentPiecePublicItem[] = []
const widePieces: ContentPiecePublicItem[] = []
const compactItems: ContentPiecePublicItem[] = []
const cards: CardCatalogItem[] = []
for (const item of items) {
@ -44,13 +47,14 @@ export const ContentPieceCatalog = ({
cards.push(item)
continue
}
const kind = contentPieceMediaKind(item)
if (kind === null || kind === 'video' || kind === 'audio') largePieces.push(item)
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
if (bucket === 'vertical') videoPieces.push(item)
else if (bucket === 'wide') widePieces.push(item)
else compactItems.push(item)
}
// S38 — the models alternate with the full-card pieces (never a block of
// their own); the compact rows keep the S27 board below.
const largeItems = interleaveCatalogItems(largePieces, cards)
// S38 — the models alternate with the video pieces (never a block of their
// own); the wide and compact rows keep the S27 board below.
const verticalItems = interleaveCatalogItems(videoPieces, cards)
const cardProps = (item: ContentPiecePublicItem) => ({
item,
@ -63,9 +67,9 @@ export const ContentPieceCatalog = ({
return (
<>
{largeItems.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{largeItems.map((item) =>
{verticalItems.length > 0 ? (
<div className="grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
{verticalItems.map((item) =>
isCardCatalogItem(item) ? (
<ContentCatalogCardItem key={`card:${item.modelId}`} item={item} />
) : (
@ -75,8 +79,16 @@ export const ContentPieceCatalog = ({
</div>
) : null}
{widePieces.length > 0 ? (
<div className="mt-6 grid items-start gap-5 lg:grid-cols-2">
{widePieces.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
</div>
) : null}
{compactItems.length > 0 ? (
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<div className="mt-6 grid items-start gap-5 sm:grid-cols-2">
{compactItems.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}

View file

@ -3,7 +3,11 @@
import { useEffect, useRef, useState } from 'react'
import { sendContentPieceEvent } from '@/lib/contentEvents'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
contentPieceAspectClass,
contentPieceMediaKind,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
@ -50,14 +54,17 @@ export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) =
return (
<>
<div className="grid gap-8 lg:grid-cols-[1.2fr_.8fr] lg:gap-10">
{/* S45 — the piece page keeps the card's promise: the 360px media column
(artefato cena 05) and the frame of the type — Reel 9:16, photo 4:5,
wide kinds 16:9 — with the media whole, never cropped. */}
<div className="grid items-start gap-8 lg:grid-cols-[360px_minmax(0,1fr)] lg:gap-10">
<ContentPieceMedia
item={item}
playing={playing}
onToggle={() => setPlaying((current) => !current)}
onEnded={() => setPlaying(false)}
variant="detail"
className="aspect-video"
className={contentPieceAspectClass(contentPieceMediaKind(item)) ?? 'aspect-video'}
/>
<div>

View file

@ -241,7 +241,9 @@ export const ContentPieceHomeBoard = ({
<div
className={cn(
'mt-6',
isSingle ? 'lg:mt-0' : 'grid gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
isSingle
? 'lg:mt-0'
: 'grid items-start gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
)}
>
{selection.map(({ item, match }) => (

View file

@ -1,6 +1,10 @@
import Link from 'next/link'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import {
buildContentPieceCatalogHref,
contentPieceAspectClass,
contentPieceMediaKind,
} from '@/lib/contentPieceCatalog'
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
@ -67,6 +71,10 @@ export const ContentPieceHomeCard = ({
}) => {
const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type })
const matchFacetHref = matchHref(item, match)
// S45 — the thumb keeps the form of the type (photo 4:5, Reel 9:16); the
// wide kinds (audio/link/text) keep a short fixed column on the phone and the
// wide block from `sm` up, where the card becomes a full block.
const aspectClass = contentPieceAspectClass(contentPieceMediaKind(item))
return (
<article
@ -83,7 +91,7 @@ export const ContentPieceHomeCard = ({
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
className={cn('self-start', aspectClass ?? 'max-sm:h-[156px] sm:aspect-video')}
/>
<div className="min-w-0 p-4">
<div className="flex flex-wrap gap-1.5">

View file

@ -50,7 +50,12 @@ const PlayButton = ({
aria-label={label}
className={cn(
'absolute grid place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none',
compact ? 'size-11 sm:size-16' : 'size-14 sm:size-16',
// C243 — public light-surface focus: the yellow signal plus the dark blue
// outer separator, so the ring separates from the yellow button itself.
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]',
// S45 — the approved play (artefato cena 07): 56px on the card, 44px on
// the compact thumb (44 → 56 from `sm`, where that card becomes a block).
compact ? 'size-11 sm:size-14' : 'size-14',
)}
>
<PlayIcon />
@ -141,6 +146,7 @@ export const ContentPieceMedia = ({
if (kind === null) {
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]',
@ -163,7 +169,9 @@ export const ContentPieceMedia = ({
className={cn(
'mt-2 font-[family-name:var(--font-exo2)] font-black',
item.origin === 'instagram' ? 'text-[#e4102f]' : 'text-[#184e92]',
variant === 'detail' ? 'text-4xl' : 'text-2xl',
// S45 — the wide row of the catalogue and the home thumb carry a
// narrow media column on the phone; from `sm` the full scale returns.
variant === 'detail' ? 'text-4xl' : 'text-base sm:text-2xl',
)}
>
{item.originLabel}
@ -180,14 +188,17 @@ export const ContentPieceMedia = ({
if (kind === 'image') {
return (
<div className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}>
<div
data-content-piece-media=""
className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}
>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.file?.path}
alt={item.title}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
className="h-full w-full object-contain"
/>
</div>
)
@ -196,6 +207,7 @@ export const ContentPieceMedia = ({
if (kind === 'text') {
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-(--campaign-cream) px-3 text-center',
@ -218,6 +230,7 @@ export const ContentPieceMedia = ({
if (playing) {
return (
<div
data-content-piece-media=""
className={cn(
base,
'bg-[#180a09]',
@ -252,6 +265,7 @@ export const ContentPieceMedia = ({
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)]',
@ -274,7 +288,7 @@ export const ContentPieceMedia = ({
// the browser's broken-image glyph over the slot for a moment, and
// the design's honest state is the neutral surface, never a glyph.
className={cn(
'absolute inset-0 h-full w-full object-cover transition-opacity duration-200',
'absolute inset-0 h-full w-full object-contain transition-opacity duration-200 motion-reduce:transition-none',
frameState !== 'ready' && 'opacity-0',
)}
/>
@ -304,6 +318,7 @@ export const ContentPieceMedia = ({
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)] px-3 text-center',

View file

@ -72,6 +72,6 @@ export const CONTENT_PIECE_LOCAL_TAG_LINK = cn(
/** S42 — the card's `Ver esta peça →` under the share button (artefato `.piece-link`). */
export const CONTENT_PIECE_PIECE_LINK = cn(
'mt-2 flex min-h-9 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline',
'mt-2 flex min-h-11 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline sm:min-h-9',
CONTENT_PIECE_FOCUS,
)

View file

@ -560,6 +560,44 @@ export const contentPieceMediaKind = (
return 'other'
}
/**
* S45 — the frame each form breathes in (artefato cena 07): a Reel keeps 9:16,
* a feed photo keeps 4:5 and the S38 model art keeps 3:4. The media itself
* never crops (`object-contain`), so a genuinely horizontal piece lands whole
* in the neutral letterbox of its slot; audio, link, text and other have no
* framing to preserve and stay wide (no class).
*/
export const CONTENT_PIECE_ASPECT_CLASS = {
video: 'aspect-[9/16]',
image: 'aspect-[4/5]',
model: 'aspect-[3/4]',
} as const
/**
* The slot class of a piece by its media kind; `undefined` means the host keeps
* its wide form (audio, link, text, other, unknown).
*/
export const contentPieceAspectClass = (kind: ContentPieceMediaKind | null): string | undefined => {
if (kind === 'video' || kind === 'image') return CONTENT_PIECE_ASPECT_CLASS[kind]
return undefined
}
export type ContentPieceBoardBucket = 'vertical' | 'wide' | 'compact'
/**
* S45 — the bucket of a piece on the board (artefato cena 01): video and the
* S38 models share the vertical board, audio/link keep the wide row and
* image/text/other keep the compact scan row. One rule for the card and the
* catalogue grids.
*/
export const contentPieceBoardBucket = (
kind: ContentPieceMediaKind | null,
): ContentPieceBoardBucket => {
if (kind === 'video') return 'vertical'
if (kind === 'audio' || kind === null) return 'wide'
return 'compact'
}
const DOWNLOAD_PREFIX = 'jorge-solla-1313'
const DOWNLOAD_FALLBACK_EXTENSION = 'bin'

View file

@ -241,6 +241,39 @@ test.describe('Campaign home novidades hash scroll', () => {
await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`)
await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport()
})
test('hero CTA keeps the hash when an on-mount refresh lands late', async ({ page }) => {
// The Payload live-preview bridge used to call `router.refresh()` on every
// standalone load; when the soft refresh landed after the CTA click, the
// App Router rewrote the canonical URL and dropped `#novidades` (the
// deploy-verify race). Hold the home soft refresh until after the click so
// the race is deterministic: outside the admin preview there must be no
// refresh at all, and the hash survives.
let releaseRefresh: (() => void) | undefined
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve
})
await page.route(
(url) => url.searchParams.has('_rsc'),
async (route) => {
if (new URL(route.request().url()).pathname !== '/') {
await route.continue()
return
}
await refreshGate
await route.continue()
},
)
await page.goto(`/?e2e=refresh-${scrollSuffix}`)
await page.locator('[data-cta="secondary"]').click()
await expect(page).toHaveURL(/#novidades/)
releaseRefresh?.()
await page.waitForTimeout(1_000)
await expect(page).toHaveURL(/#novidades/)
})
})
test.describe('Campaign home novidades hash scroll — desktop', () => {

View file

@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { APIRequestContext, Page } from '@playwright/test'
@ -46,9 +47,15 @@ const createdLeadershipIds: number[] = []
const uniqueMarker = () => randomUUID().slice(0, 8)
const mediaFor = (type: 'video' | 'foto' | 'texto') => {
const mediaFor = (type: 'video' | 'foto' | 'audio' | 'texto') => {
if (type === 'video') return { mimetype: 'video/mp4', buffer: TEST_MP4, extension: 'mp4' }
if (type === 'foto') return { mimetype: 'image/png', buffer: TEST_PNG, extension: 'png' }
if (type === 'audio')
return {
mimetype: 'audio/mpeg',
buffer: readFileSync(join(process.cwd(), 'tests/fixtures/jingle-tone.mp3')),
extension: 'mp3',
}
return {
mimetype: 'text/plain',
buffer: Buffer.from('Peça voto pra Solla 1313.'),
@ -92,7 +99,8 @@ const createPiece = async (
publicFigures?: string[]
},
): Promise<{ id: number; slug: string }> => {
const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto'
const mediaKind =
data.type === 'video' || data.type === 'foto' || data.type === 'audio' ? data.type : 'texto'
const mediaSpec = mediaFor(mediaKind)
const media =
data.withMedia === false
@ -279,8 +287,10 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
const headers = await adminHeaders(request, BASE_URL)
const videoTitle = `Fim da escala ${uniqueMarker()}`
const photoTitle = `Solla no SUS ${uniqueMarker()}`
const audioTitle = `Áudio da Central ${uniqueMarker()}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
const audio = await createPiece(request, headers, { title: audioTitle, type: 'audio' })
const media = mediaRequests(page)
const frames = frameRequests(page)
@ -293,12 +303,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }),
).toBeVisible()
await expect(page.getByText('Central de Conteúdos').first()).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
await expect(page.locator('article[data-content-piece]')).toHaveCount(3)
// S38 — the six models are items of the same board: the card item and the
// pieces share one grid (no section of their own, no invite tile).
await expect(page.locator('[data-card-model]')).toHaveCount(6)
// The video piece shares the full-card grid with the models (the photo is a
// compact row below): the models are items of the board, not a section.
// S45 — the video shares the vertical board with the models (the photo is a
// compact row below and the audio keeps the wide row): the models are items
// of the board, not a section.
const board = page.locator('[data-card-model]').first().locator('xpath=..')
await expect(board.locator('article[data-content-piece]')).toHaveCount(1)
await expect(board.locator('[data-card-model]')).toHaveCount(6)
@ -331,8 +342,29 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
.toBeGreaterThan(0)
await expect(page.getByRole('button', { name: `Reproduzir ${videoTitle}` })).toBeVisible()
// S45 — every piece appears in the form of its type: the Reel keeps the
// 9:16 slot (the still whole, `contain`), the photo keeps the 96×120 (4:5)
// compact thumb, the audio keeps the wide row and the model art keeps 3:4.
const mediaSlot = (slug: string) =>
page.locator(`article[data-content-piece="${slug}"] [data-content-piece-media]`)
const videoSlot = await mediaSlot(video.slug).boundingBox()
expect(videoSlot!.height / videoSlot!.width).toBeCloseTo(16 / 9, 2)
await expect(still).toHaveCSS('object-fit', 'contain')
const photoSlot = await mediaSlot(photo.slug).boundingBox()
expect(photoSlot!.width).toBeCloseTo(96, 0)
expect(photoSlot!.height).toBeCloseTo(120, 0)
await expect(mediaSlot(photo.slug).locator('img')).toHaveCSS('object-fit', 'contain')
const audioSlot = await mediaSlot(audio.slug).boundingBox()
expect(audioSlot!.width).toBeCloseTo(190, 0)
expect(audioSlot!.height).toBeGreaterThanOrEqual(156)
const modelSlot = await page
.locator('[data-card-model="time-de-voce"] > span')
.first()
.boundingBox()
expect(modelSlot!.height / modelSlot!.width).toBeCloseTo(4 / 3, 2)
// Play mounts only the tapped piece and fetches only its media. The still
// leaves the slot: the player takes the same 16:9 box (cena 03).
// leaves the slot: the player takes the same 9:16 box (cena 06).
await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click()
await expect(page.locator('video')).toHaveCount(1)
await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1)
@ -368,6 +400,17 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await sheet.getByRole('button', { name: 'Fechar', exact: true }).click()
await expect(sheet).toHaveCount(0)
// S45 — the vertical board never overflows the phone (cena 02).
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/conteudos')
await waitForSettledPage(page)
await expect(page.locator('article[data-content-piece]')).toHaveCount(3)
const overflow = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return container ? container.scrollWidth - container.clientWidth : 0
})
expect(overflow).toBeLessThanOrEqual(1)
})
test('filters by facet and term and shows the honest no-results state', async ({
@ -378,8 +421,9 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await unpublishEveryPiece(request, headers)
const marker = uniqueMarker()
const videoTitle = `Debate na rádio ${marker}`
const photoTitle = `Card do giro ${marker}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
await createPiece(request, headers, { title: `Card do giro ${marker}`, type: 'foto' })
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
await page.goto(`/conteudos?tipo=video`)
await waitForSettledPage(page)
@ -410,6 +454,19 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(page).toHaveURL(new RegExp(`/conteudos/${video.slug}$`))
await expect(page.getByRole('heading', { name: videoTitle })).toBeVisible()
await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible()
// S45 — the piece page keeps the card's promise: the 9:16 stage in the
// 360px media column (cena 05), never wider than the text.
const stage = await page.locator('[data-content-piece-media]').boundingBox()
expect(stage!.width).toBeCloseTo(360, 0)
expect(stage!.height / stage!.width).toBeCloseTo(16 / 9, 2)
// The photo keeps the 4:5 stage on its own page.
await page.goto(`/conteudos/${photo.slug}`)
await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible()
const photoStage = await page.locator('[data-content-piece-media]').boundingBox()
expect(photoStage!.width).toBeCloseTo(360, 0)
expect(photoStage!.height / photoStage!.width).toBeCloseTo(5 / 4, 2)
})
test('lists the card models by nickname, filters by Tipo Card and routes to the studio (S38)', async ({
@ -756,6 +813,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2)
await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0)
// S45 — the 124px phone thumb keeps the form of the type: photo 4:5, Reel 9:16.
const photoBox = await photoCard.locator('[data-content-piece-media]').boundingBox()
expect(photoBox!.width).toBeCloseTo(124, 0)
expect(photoBox!.height / photoBox!.width).toBeCloseTo(5 / 4, 2)
const videoBox = await videoCard.locator('[data-content-piece-media]').boundingBox()
expect(videoBox!.height / videoBox!.width).toBeCloseTo(16 / 9, 2)
// S42 — the card shares through the same S27 sheet, without leaving the home.
await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click()
const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' })

View file

@ -31,6 +31,19 @@ describe('CampaignHero', () => {
expect(novidadesLink.getAttribute('data-cta')).toBe('secondary')
})
it('não percent-encoda os caminhos das imagens', () => {
const { container } = render(<CampaignHero />)
const sources = Array.from(container.querySelectorAll('img')).map((image) =>
image.getAttribute('src'),
)
expect(sources.length).toBeGreaterThan(0)
for (const source of sources) {
expect(source).not.toContain('%')
}
})
it('expõe as provas e as fotos com nomes acessíveis', () => {
render(<CampaignHero />)

View file

@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest'
import {
buildContentPieceCatalogHref,
cardCatalogItems,
CONTENT_PIECE_ASPECT_CLASS,
contentCatalogItems,
contentPieceAspectClass,
contentPieceBoardBucket,
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceDownloadFilename,
@ -525,6 +528,26 @@ describe('content piece public view model', () => {
expect(contentPieceMediaKind(item({ media: null, sourceUrl: 'https://youtu.be/X' }))).toBeNull()
})
it('maps the frame of the type, leaving the wide kinds without a class (S45)', () => {
expect(contentPieceAspectClass('video')).toBe('aspect-[9/16]')
expect(contentPieceAspectClass('image')).toBe('aspect-[4/5]')
// The S38 model art is not a media kind: its host reads the table directly.
expect(CONTENT_PIECE_ASPECT_CLASS.model).toBe('aspect-[3/4]')
// Audio, link, text and other have no framing to preserve.
for (const kind of ['audio', 'text', 'other', null] as const) {
expect(contentPieceAspectClass(kind)).toBeUndefined()
}
})
it('buckets the board by type: vertical, wide or compact (S45)', () => {
expect(contentPieceBoardBucket('video')).toBe('vertical')
expect(contentPieceBoardBucket('audio')).toBe('wide')
expect(contentPieceBoardBucket(null)).toBe('wide')
for (const kind of ['image', 'text', 'other'] as const) {
expect(contentPieceBoardBucket(kind)).toBe('compact')
}
})
it('leads the metadata line with who appears in the piece (S37)', () => {
expect(item({ leaderNames: ['Maria Silva'] }).metaLabel).toBe(
'Maria Silva · Economia e Trabalho · Salvador',

View file

@ -256,6 +256,47 @@ describe('ContentPieceHomeBoard', () => {
await waitFor(() => expect(document.querySelector('video')).toBeNull())
})
it('keeps the frame of the type on the sample card (S45)', async () => {
readPermission.mockResolvedValue('denied')
const photo = item(1)
const video = item(2, {
title: 'Vídeo da home',
type: 'video',
typeLabel: 'Vídeo',
file: {
id: 102,
path: '/conteudos/peca-2/midia',
mimeType: 'video/mp4',
downloadFilename: 'peca-2.mp4',
},
})
const audio = item(3, {
title: 'Áudio da home',
type: 'audio',
typeLabel: 'Áudio',
file: {
id: 103,
path: '/conteudos/peca-3/midia',
mimeType: 'audio/mpeg',
downloadFilename: 'peca-3.mp3',
},
})
renderBoard([photo, video, audio])
await waitFor(() => expect(readPermission).toHaveBeenCalled())
const slot = (slug: string) =>
document.querySelector<HTMLElement>(
`article[data-content-piece="${slug}"] [data-content-piece-media]`,
)
// Photo keeps 4:5 and the Reel keeps 9:16; the wide kinds stay short on the
// phone and take the wide block from `sm` up.
expect(slot('peca-1')?.className).toContain('aspect-[4/5]')
expect(slot('peca-2')?.className).toContain('aspect-[9/16]')
expect(slot('peca-3')?.className).toContain('max-sm:h-[156px]')
})
it('keeps the recent selection when the position fails, without an error or a retry', async () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: false, reason: 'timeout' })

View file

@ -72,6 +72,8 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => {
renderSlot({})
expect(frameImage()).not.toBeNull()
// S45 — the still shows whole in the slot, never cropped.
expect(frameImage()?.className).toContain('object-contain')
expect(document.querySelector('video')).toBeNull()
expect(document.querySelector('audio')).toBeNull()
// S27: the element that would fetch the file only exists after the tap.
@ -246,5 +248,7 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => {
expect(document.querySelectorAll('img')).toHaveLength(1)
expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
// S45 — the photo shows whole in its 4:5 slot, never cropped.
expect(document.querySelector('img')?.className).toContain('object-contain')
})
})

View file

@ -0,0 +1,72 @@
// @vitest-environment node
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import { describe, expect, it } from 'vitest'
const MAX_WIDTH = 1600
const MAX_HEIGHT = 2400
const LOCAL_IMAGE_REF = /src[=:]\s*['"](\/[^'"`]+\.(?:avif|webp|png|jpe?g))['"]/g
const publicFile = (src: string) =>
fileURLToPath(new URL(`../../public${decodeURIComponent(src)}`, import.meta.url))
const campaignHeroSource = readFileSync(
fileURLToPath(new URL('../../src/components/CampaignHero.tsx', import.meta.url)),
'utf8',
)
const localHeroAssets = [
...new Set([...campaignHeroSource.matchAll(LOCAL_IMAGE_REF)].map(([, src]) => src)),
]
const collectSourceImageRefs = () => {
const refs = new Set<string>()
const srcDir = fileURLToPath(new URL('../../src', import.meta.url))
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) continue
const code = readFileSync(join(entry.parentPath, entry.name), 'utf8')
for (const [, src] of code.matchAll(LOCAL_IMAGE_REF)) refs.add(src)
}
return refs
}
describe('entrega das imagens do hero (perf do /_next/image)', () => {
it('usa masters no tamanho máximo de exibição, sem forçar decode de 3840px', async () => {
expect(localHeroAssets.length).toBeGreaterThanOrEqual(5)
for (const src of localHeroAssets) {
const { width, height } = await sharp(publicFile(src)).metadata()
expect(width, src).toBeLessThanOrEqual(MAX_WIDTH)
expect(height, src).toBeLessThanOrEqual(MAX_HEIGHT)
}
})
it('mantém todo asset local entregue via next/image dentro do orçamento', async () => {
const refs = [...collectSourceImageRefs()].filter((src) => existsSync(publicFile(src)))
expect(refs.length).toBeGreaterThan(0)
for (const src of refs) {
const { width, height } = await sharp(publicFile(src)).metadata()
expect(width, src).toBeLessThanOrEqual(MAX_WIDTH)
expect(height, src).toBeLessThanOrEqual(MAX_HEIGHT)
}
})
it('configura TTL longo para os derivados do otimizador', () => {
const nextConfig = readFileSync(
fileURLToPath(new URL('../../next.config.mjs', import.meta.url)),
'utf8',
)
const ttl = nextConfig.match(/minimumCacheTTL:\s*(\d+)/)
expect(ttl, 'next.config.mjs deve definir images.minimumCacheTTL').toBeTruthy()
expect(Number(ttl?.[1])).toBeGreaterThanOrEqual(3600)
})
})

View file

@ -0,0 +1,50 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const router = vi.hoisted(() => ({ refresh: vi.fn() }))
vi.mock('next/navigation.js', () => ({ useRouter: () => router }))
const livePreview = vi.hoisted(() => ({ mounts: 0 }))
vi.mock('@payloadcms/live-preview-react', () => ({
RefreshRouteOnSave: ({ refresh }: { refresh: () => void }) => {
livePreview.mounts += 1
refresh()
return null
},
}))
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
const setParentWindow = (value: Window) => {
Object.defineProperty(window, 'parent', { configurable: true, value })
}
afterEach(() => {
cleanup()
setParentWindow(window)
})
beforeEach(() => {
router.refresh.mockReset()
livePreview.mounts = 0
})
describe('RefreshRouteOnSave', () => {
it('does not mount the preview bridge (nor refresh) on a standalone public load', () => {
render(<RefreshRouteOnSave />)
expect(livePreview.mounts).toBe(0)
expect(router.refresh).not.toHaveBeenCalled()
})
it('mounts the bridge and refreshes inside the admin preview iframe', () => {
setParentWindow({} as Window)
render(<RefreshRouteOnSave />)
expect(livePreview.mounts).toBe(1)
expect(router.refresh).toHaveBeenCalledTimes(1)
})
})