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.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 / 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 + 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/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.tsx - Tailwind
important: true: cuidado com inline style + class colidindo. Use class condicional ou CSS var -
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)
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
- Layout Composition — onde os primitivos
Slideshow/Carouselsão usados - Icon System — ícones inline no bundle (zero fetch externo)