Performance — PSI checklist
Performance no front-web-base é requisito duro, não nice-to-have. Toda mudança visual tem que preservar ou melhorar as métricas que o PageSpeed Insights pontua: LCP, FCP, CLS, TBT.
Este doc consolida o checklist aplicável a qualquer mudança above-the-fold (hero, header, sidebar, primeira row de cassino/sports). Convenções saíram do trabalho do widget Home Banner em 2026-04-27 — histórico em front-dev/docs/superpowers/archive/2026-04-28-theme-7k-branch-changelog.md (entry "Home Banner widget").
Checklist por métrica
CLS — dimensões conhecidas antes do load
<img>, <iframe>, banner wrappers, card slots — todos precisam ter width/height ou aspect-ratio reservado no primeiro paint. Use inline style ou CSS var consumida por Tailwind arbitrary; nunca deixe o elemento "crescer" depois que o asset carrega.
❌ Errado: <img src={banner.url} /> (sem dimensões — layout shifta quando imagem carrega).
✅ Certo: <img src={banner.url} width={460} height={167} /> ou style={{ aspectRatio: "5 / 2" }}.
LCP — preload sincronizado com config do widget
Primeiro banner/imagem de hero tem fetchpriority="high" loading="eager" decoding="sync" + preload no <head>. O resto da mesma galeria vai com loading="lazy" fetchpriority="low" decoding="async".
Preload está em app/routes/_layout.tsx — sincronizar com a config real do widget (aspect ratio, widths, sizes) ou o browser baixa variante errada e o LCP piora.
// app/routes/_layout.tsx
const { preload } = homeBannerConfig; // ← lê do config, NÃO hardcoded
return preload.enabled ? (
<link
rel="preload" as="image"
imageSrcSet={buildSrcSet(firstBanner, preload.widths)}
imageSizes={`(max-width: 768px) 90vw, ${preload.widths.at(-1)}px`}
/>
) : null;
Sem esse sync, PSI acusa "wasted preload".
FCP / TBT — SSR paint limpo
- Não adicione work síncrono pesado em contexts/providers do root.
- Widgets novos com cálculo custoso vão em
lazy(() => import(…))ou hook comuseEffectpós-montagem. - Bots recebem menos dados — ver
useIsBot+ slicing de arrays pra 1 item emHomeBannerCarousel.
Bundle size — zero lib externa de carousel
Já temos Slideshow.tsx (fade/autoplay) e Carousel/ (snap-scroll — hoje é um diretório, não um arquivo único) em app/components/ui/. Adicionar swiper / embla / keen aumenta bundle em ~20-40kb → regressão de TBT + bytes parseados. Se faltar feature, estende o primitivo existente.
:::caution lazy() não é regra absoluta
app/components/section-title/RenderSectionIcon.tsx foi deliberadamente
revertido de lazy() pra import estático: os ícones piscavam visivelmente no
load. O custo aceito (~59 SVGs, ~10kb gzip no bundle principal) valeu menos que o
flash. Ou seja: lazy() pra peso real de código, não pra coisa pequena que
aparece no primeiro paint.
:::
Pattern: valor dinâmico de config → CSS custom property
:::note important: true foi REMOVIDO
O tailwind.config.js não usa important: true desde 2026-04-29 (commit
ec8ef0eae, "perf(tailwind): drop important: true — CSS -23.5KB raw,
-14.4%"). As chaves top-level hoje são só content, darkMode, theme e
plugins.
Isso significa que a precedência CSS voltou ao normal: inline style vence
utility class. Se você encontrar código antigo que omite condicionalmente uma
Tailwind class só pra o inline style "conseguir vencer" (padrão themeBgClass),
esse workaround não é mais necessário — o inline style já ganha sozinho.
:::
Quando o valor vem de config e precisa ser dinâmico, NÃO use template strings (Tailwind JIT não escaneia):
❌ Errado: className={`w-[${config.x}]`} — JIT não gera a classe.
✅ Certo: CSS var no wrapper + class arbitrary literal:
<div
style={{ "--hb-w-d": config.desktop.itemWidth }} // value runtime
className="lg:w-[var(--hb-w-d)]" // class literal — JIT escaneia
/>
JIT gera o CSS pra lg:w-[var(--hb-w-d)]; runtime substitui a CSS var com o valor.
Usado em HomeBannerCarousel, BannerSlide, home-banner widget.
Pattern: gap como Tailwind token, não inline style
O primitive Slideshow / Carousel lê gap string como className e usa o gap no cálculo de scrollLeft (via getComputedStyle). Quebrar essa convenção quebra autoplay e nav buttons.
✅ Certo: <Carousel gap="gap-4">…</Carousel> (Tailwind token).
❌ Errado: <div style={{ gap: 16 }}> por fora — Carousel não detecta.
Pattern: theme tokens brand-aware > hex hardcoded
Border/background/text/shadow via border-texts/20, bg-bg-secondary, etc. — se a brand afinar o tema, o componente acompanha sem refactor. Hex literal só em PR emergencial com TODO claro.
❌ Errado: <div className="bg-[#25284b]"> — hex preso, brand não consegue afinar.
✅ Certo: <div className="bg-bg-secondary"> — brand controla via theme tokens.
⚠️ Exceção: quando o stakeholder explicitamente quer cor brand-specific que não é theme token (ex: gradient pill do cl-bet7k-com #25284b → #3a3d62), use inline style — que hoje vence a utility class naturalmente. Cores de brand entram no theme/colors.ts ou em config widget — nunca hardcoded em variant component.
Pattern: gatear efeito caro por device class
app/hooks/useIsLowEndDevice.ts detecta aparelho fraco e o resultado é exposto
pela árvore via useIsLowEnd() (app/context/device.tsx):
import { useIsLowEnd } from "~/context/device";
const isLowEnd = useIsLowEnd();
const blur = isLowEnd ? "" : "backdrop-blur-sm";
O que a heurística sniffa: navigator.deviceMemory,
hardwareConcurrency, connection.saveData / effectiveType, userAgentData
(com fallback pra UA string na detecção de Android).
Como foi calibrada: contra Samsung Galaxy A16 (Helio G99) e Motorola Moto G35
(Unisoc T760) — aparelhos com GPU fraca em que usuários reportaram jank. Ambos
reportam hardwareConcurrency: 8, então cores isolado é sinal inútil. O
discriminador é deviceMemory, que o browser quantiza pra
{0.25, 0.5, 1, 2, 4, 8}: um threshold de <= 4 captura exatamente os
aparelhos de 4 GB sem falso-positivar em 6 GB (que reporta 8). A regra é gateada
por UA Android, então iOS não é afetado.
O que é gateado hoje: Marquee.tsx e VerticalMarquee.tsx (animação
contínua) e useCustomThumb.ts (pula só as fontes de vídeo, mantém a imagem).
Blur (backdrop-blur) segue o mesmo padrão nos consumidores.
Override por marca: featuresConfig.forceLowEndMode === true liga o modo
low-end brand-wide, ignorando a heurística. Útil quando a audiência da marca é
majoritariamente Android de entrada.
Pattern: arte de jogo tem perfil único
Toda superfície que renderiza game.image usa
getGameArtworkProps(image, sizes) (app/utils/game-artwork.ts). Um ladder de
larguras próprio significa a mesma arte baixada duas vezes, porque o
Cloudflare codifica o transform no path da URL.
Detalhes completos, com os números medidos e a regra do degrau do meio em DPR 1, em Imagens (Cloudflare Images).
Above-the-fold checklist (resumo)
Antes de mergear mudança visual em hero/header/sidebar/primeira row:
- CLS = 0: dimensões reservadas via
width/heightouaspect-rationo first paint - LCP image:
fetchpriority="high" loading="eager" decoding="sync"+ preload sync com config - Outros assets:
loading="lazy" fetchpriority="low" decoding="async" - SSR: zero work síncrono pesado em providers/contexts
- Lib externa de carousel: NÃO. Estende
Slideshow.tsxouCarousel/ - Valor dinâmico de config: CSS var + class arbitrary literal (nunca template string)
-
gapem carousel: usar Tailwind token via prop - Cores: theme tokens brand-aware, hex só com justificativa
- Bot path: slicing de array onde aplicável (homepage hero usa só 1 banner pra bots)
- Arte de jogo:
getGameArtworkProps(image, sizes)— sem ladder próprio, semheight/aspectRatio - Efeito caro (vídeo, blur, marquee): gateado por
useIsLowEndDevice()?
Se uma mudança conscientemente piora uma métrica PSI (ex: brand quer logo decorativo grande no hero), anotar no commit message: perf: trade LCP 100ms por X + justificativa.
Documentação relacionada
- Imagens (Cloudflare Images) — perfil único da arte de jogo, teto do master
- Layout Composition — onde os primitivos
Slideshow/Carouselsão usados - Icon System — ícones inline no bundle (zero fetch externo)