Compare commits
12 commits
0a8b0774d3
...
048df6f828
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
048df6f828 | ||
|
|
ff969ec8e4 | ||
|
|
3c3af5c6bc | ||
|
|
0fa0e8f41b | ||
|
|
16f959752a | ||
|
|
9a049bbbb4 | ||
|
|
8db841f778 | ||
|
|
14e0197df0 | ||
|
|
18ec5454fa | ||
|
|
ab441d36df | ||
|
|
4ac13dc351 | ||
|
|
fcc47bb111 |
|
|
@ -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.
|
||||
1
docs/changelog/2026-10-01-s45.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
72
docs/plans/acervo-integridade-garage.md
Normal 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.
|
||||
75
docs/plans/album-catalogacao-producao.md
Normal 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.
|
||||
86
docs/plans/album-pessoa-facial.md
Normal 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).
|
||||
182
docs/plans/central-conteudos-cards-verticais-impl.md
Normal 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.
|
||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 53 KiB |
69
docs/plans/ops-toggle-globais-publicos.md
Normal 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.
|
||||
85
docs/postmortems/2026-10-01-imagens-lentas-home-campanha.md
Normal 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.
|
||||
|
|
@ -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".
|
||||
|
|
@ -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
|
||||
|
|
|
|||
BIN
public/Lula.avif
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
72
tests/unit/imageDelivery.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
50
tests/unit/refreshRouteOnSave.unit.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||