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.tsx (snap-scroll) 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.

Pattern: Tailwind important: true × inline style

⚠️ O projeto usa important: true no tailwind.config.js (linha 62). Isso adiciona !important em toda utility class, invertendo a precedência CSS normal: classe vence inline style.

Sintoma: brand passa gradient: ["#FF4606", "#13051c"] per-item, dev tool mostra inline style="background-image: linear-gradient(...)", mas o pill renderiza na cor da Tailwind class default.

Solução 1 — emit class condicional

Quando há override per-item via inline style, omite a Tailwind class:

const themeBgClass = bgStyle ? "" : "bg-gradient-to-r from-sidebar-button-bg to-sidebar-bg";
className={`base classes ${themeBgClass}`.trim()}
style={bgStyle} // ← só vence sem competidor

Usado em SidebarButtonsGradient.tsx, SidebarButtonsGrid.tsx, e variants do GameCard que combinam pill gradient configurável + theme tokens default.

Solução 2 — CSS custom properties + Tailwind arbitrary literal

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 + emit class condicional (Solução 1 acima). Cores de brand entram no theme/colors.ts ou em config widget — nunca hardcoded em variant component.

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.tsx
  • Tailwind important: true: cuidado com inline style + class colidindo. Use class condicional ou CSS var
  • 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)

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