Pular para o conteúdo principal

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.tsxsincronizar 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 com useEffect pós-montagem.
  • Bots recebem menos dados — ver useIsBot + slicing de arrays pra 1 item em HomeBannerCarousel.

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 / Carouselgap 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 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/height ou aspect-ratio no 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.tsx ou Carousel/
  • Valor dinâmico de config: CSS var + class arbitrary literal (nunca template string)
  • gap em 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, sem height/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