Imagens (Cloudflare Images)
Todas as imagens vindas do backend passam pelo Cloudflare Image Delivery. O
helper canônico é getResponsiveImageProps (@cactus-agents/utils), e a arte de
jogo tem um wrapper obrigatório em cima dele —
getGameArtworkProps (app/utils/game-artwork.ts).
Duas regras dominam esta página, e ambas vêm da mesma propriedade do Cloudflare: o transform é codificado no PATH da URL.
A regra de base: o transform vai no path
Uma URL transformada tem esta forma (buildNormalizedCloudflareImageUrl em
packages/utils/src/image.ts):
/cdn-cgi/imagedelivery/<accountHash>/<imageId>/width=240,quality=75,format=auto
Consequências diretas:
1. Cada combinação de parâmetros é um recurso DISTINTO. Cache-key de browser própria, entrada própria no cache do Cloudflare, download próprio. Duas telas que mostram a mesma imagem com ladders de largura diferentes fazem dois downloads.
2. Qualidade e formato NUNCA funcionam em querystring. O Cloudflare ignora.
Medido em 2026-08-04 contra a CDN real: …/w=240, …/w=240?quality=60 e
…/w=240?quality=20 devolvem bytes idênticos (16.3KB), enquanto
…/width=240,quality=60,format=auto devolve 9.2KB — 43% menos pela mesma
imagem. Se você viu ?quality= numa URL, ou é o caminho sem width (variante
nomeada public, cujos params são inertes e mantidos só pra não trocar a
cache-key de todas as marcas) ou é bug.
Sem width, a URL cai na variante nomeada public e qualidade/formato vêm da
config da variante no dashboard do Cloudflare — não tente ajustar qualidade por
querystring; passe width pra entrar no caminho de path.
O default global de qualidade fica em app/config/scripts/images.ts
(quality: 75), que chama setImageDefaults do core. useProxy reescreve
imagedelivery.net → /cdn-cgi/imagedelivery (same-origin, elimina DNS + TLS
extra) e é desligado em dev local, onde não há proxy do Workers.
Arte de jogo: perfil ÚNICO, sem exceção
game.image aparece em muitas superfícies — card de listagem, carrossel, game
page, pre-game drawer, top-10, lista de busca, widget de último jogo. Todas
usam o mesmo helper:
import { getGameArtworkProps } from "~/utils/game-artwork";
const artwork = getGameArtworkProps(game.image, "(max-width: 640px) 115px, 180px");
<img src={artwork.src} srcSet={artwork.srcSet} sizes={artwork.sizes} />
sizes é o único parâmetro por superfície: descreve o slot de layout em CSS
px. O ladder de larguras é compartilhado.
O que NÃO fazer
- Não monte um ladder de
widthspróprio. Ladder divergente = a mesma arte baixada duas vezes. Era exatamente o que acontecia (medido em 2026-08-04, 7k mobile, DPR 3): o card do carrossel pediawidth=240,height=300(10.0KB) e a game page pediawidth=320,height=400(13.5KB) — 23.5KB pra mostrar a mesma arte duas vezes, com o segundo request entrando frio no 3G do usuário. - Não passe
aspectRationemheight. O resize é proporcional; o enquadramento é 100% CSS. - Não "corrija" um
sizesde game page pro slot real sem checar a convergência em DPR 1 — vira download extra silencioso.
Recorte é CSS
Cada slot tem caixa de aspect fixa + object-cover (ou object-contain). Isso
dá três ganhos:
- Uma URL por largura pra todo o app — o card
4/5, o poster59/79do donald e o thumb de proporção natural da game page compartilham o mesmo arquivo em vez de gerar três recortes. - Zero upscale — a altura sai do próprio master.
- Menos bytes no mesmo degrau — sem forçar
4/5,width=240devolve 240×280 em vez de 240×300.
Teto do ladder ≈ 300px
O master da arte de jogo é 300×350 (amostrado sobre a biblioteca real).
Pedir acima disso faz o Cloudflare inventar pixel: width=480,height=600
devolvia 21.5KB de upscale — o dobro dos bytes do degrau que já estava em
cache, com zero detalhe novo e cache frio.
O default global respeita o teto: GAME_CARD_THUMB_WIDTHS = [128, 192, 288],
fallback 192 (app/types/game-card.ts).
O knob por marca
gameCardConfig.thumbWidths / thumbFallbackWidth
(app/config/widgets/game-card.ts, overrideável por marca) é o único knob —
e desde 2026-08-04 vale pra toda arte de jogo, não só pros cards. Subir o
ladder de uma marca encarece toda a arte dela; é o trade-off desejado (é o mesmo
knob que a 7k usa pra cortar memória de bitmap decodado e atacar o flicker de
re-decode no scroll).
A regra do degrau do meio (DPR 1)
Ladder compartilhado não basta. Em DPR ≥ 2 (todo mobile real) todo slot satura no teto e qualquer declaração converge. Em DPR 1 os slots divergem de verdade e um degrau no meio do ladder separa card de game page — a mesma arte baixada 2x.
Duas regras, ambas cobertas por teste (game-artwork-brands.test.ts):
- O degrau do meio fica logo acima do maior slot de desktop, e não pode existir degrau entre o menor slot de card de desktop e ele.
- As superfícies de game page declaram o slot do CARD no mobile — sub-declaram de propósito (~19% no preview, ~2% no hero showcase) pra cair no mesmo degrau que o card. O custo é 1.19x de upscale só em DPR 1 estreito, imperceptível numa arte de jogo; o ganho é uma request em vez de duas.
Nunca super-declare. Era o caso de todas as superfícies de game page (220px onde o slot é 186px): gasta bytes a mais e empurra pro degrau errado.
O docblock de app/utils/game-artwork.ts mantém a tabela viva de slots medidos
por superfície — consulte lá antes de mexer em qualquer sizes de arte de jogo.
Imagens que não são arte de jogo
Pra banners, logos, thumbs de conteúdo e afins, use getResponsiveImageProps
direto:
import { getResponsiveImageProps } from "@cactus-agents/utils";
const props = getResponsiveImageProps(banner.url, {
widths: [460, 768, 1200],
sizes: "(max-width: 768px) 90vw, 1200px",
});
Aqui aspectRatio é aceito (resolveHeight gera height + fit=cover por
degrau) — a proibição vale só pra arte de jogo, que precisa convergir entre
superfícies. Ainda assim, mantenha o ladder de um mesmo asset consistente entre
telas pelo mesmo motivo de cache.
Docs relacionadas
- Performance — PSI checklist — CLS, LCP, preload sincronizado com config do widget
- Layout Composition — onde os slots de arte vivem