Pular para o conteúdo principal

Status: Approved · v1 · SPEC-DELIVERY-PERF-A11Y-AUDIT-001

depends_on: SPEC-ARCH-PERF-001, SPEC-ARCH-QUALITY-001

used_by: IS-MVP-15.3

Auditoria de performance, acessibilidade e qualidade visual

O que foi auditado, o que foi corrigido nesta pack, o baseline medido na Public Team Page e o que fica registrado como risco conhecido (EP-MVP-15.3).

Método

Leitura direta do código real (CSS + screens web, não apenas as specs), seguida de medição com Chromium headless (Playwright ad hoc, pré-instalado neste ambiente, não adicionado como dependência do repositório) contra next dev com mocks habilitados (a única forma de obter conteúdo real neste ambiente — build de produção recusa mocks por design, ver packages/shared/src/clients/client-config.ts, e não há staging real acessível daqui). Cada achado foi medido antes/depois com a mesma metodologia (throttle de rede ~slow-4G + CPU 4x, PerformanceObserver para LCP/CLS reais do navegador), não estimado.

Baseline medido — Public Team Page (/teams/unidos-do-6)

Viewport mobile (390×844), CPU throttling 4x, rede ~1.6 Mbps down / 750 Kbps up / 150ms latência (perfil aproximado de slow-4G), next dev (não é o bundle de produção minificado — não há staging real acessível neste ambiente para medir o bundle de produção com dados reais; os números abaixo são consistentes página-a-página entre si e servem de baseline relativo/regressão, não como SLA absoluto):

MétricaValor
TTFB~90ms
First Contentful Paint~990ms
Largest Contentful Paint~990ms
Cumulative Layout Shift0.021 (bom, < 0.1)
Requests de API (client-side)sem duplicação — dup-requests não encontrou nenhuma URL chamada mais de uma vez
Overflow horizontalnenhum em 5 breakpoints (375/390/768/1280/1440) × 3 rotas P0 (/teams/:slug, /search, /)

Este é o baseline exigido por SPEC-ARCH-PERF-001 ("Baseline medido na Public Team Page").

Achados corrigidos nesta pack

#AchadoSeveridadeEvidência (antes)CorreçãoTeste
1O anel de foco global (outline: 3px solid var(--focus), aplicado a todo button/a via :focus-visible em globals.css/auth.css/search.css) usava spotlight[500] (#F2B230, dourado). Contraste medido contra --background (#FBF7ED): 1.75:1; contra --surface-elevated (branco): 1.88:1 — ambos muito abaixo do mínimo 3:1 exigido por WCAG 1.4.11 (contraste não-textual) para indicadores de foco, na maioria das superfícies do app (formulários, feed, busca, praticamente toda a UI clara).HIGH (acessibilidade — usuário de teclado não enxerga onde está o foco na maior parte do produto)packages/ui/src/tokens/themes.tsfocus: colors.spotlight[500]Trocado para colors.earthRed[500] (já é o token primary) — único tom da paleta que atende ≥3:1 contra todas as superfícies onde o outline aparece: background 4.76:1, surfaceElevated 5.09:1, surfaceAlt 4.15:1, fundo escuro do hero (asphalt[950]) 3.71:1, asphalt[900] 3.47:1. Corrigido em lightTheme.focus e (por consistência, embora hoje sem uso direto) darkTheme.focus.packages/ui/test/tokens.test.mjs — teste dedicado calcula o contraste real (fórmula WCAG) contra as três superfícies; scripts/validation/check-a11y-perf-invariants.mjs (focus-ring-contrast, calcula o contraste real a partir do código-fonte, não só verifica um valor fixo)
2.feed-card-media (imagem do post no feed público e no preview do composer de posts de time/torcida — mesma classe, 3 usos) não reservava espaço antes da imagem carregar (width: 100%; object-fit: cover, sem aspect-ratio), causando layout shift quando a imagem termina de carregar — exatamente o que SPEC-ARCH-PERF-001 pede ("imagens com aspect ratio").MEDIUM (performance/CLS, não bloqueante, mas nomeado explicitamente pela spec)apps/web/app/feed.css.feed-card-media sem aspect-ratioAdicionado aspect-ratio: 16 / 9 (mantendo max-height/object-fit existentes).check-a11y-perf-invariants.mjs (feed-media-missing-aspect-ratio)
3As duas bottom sheets customizadas do app (role="dialog" em uma <div> simples, não um <dialog> nativo — precisam da animação de slide-up que <dialog> não tem) — a sheet "Por que você viu isso?/Silenciar/Deixar de torcer" do feed e a sheet de filtros da busca — não continham o foco de Tab dentro delas. Confirmado ao vivo: com a sheet aberta, Tab repetido eventualmente move o foco de teclado para trás do overlay, na página por baixo — uma armadilha de acessibilidade inversa (WCAG 2.4.3, contenção de foco em modais). A sheet do feed também não devolvia o foco ao elemento que a abriu ao fechar (o foco simplesmente ia para <body>).HIGH (usuário de teclado perde a localização do foco ao interagir com uma sheet aberta)home-feed.screen.tsx/search.screen.tsxuseEffect só tratava Escape, sem TabNovo utilitário apps/web/src/shared/focus-trap.ts (trapTabFocus, contém Tab/Shift+Tab dentro do container, sem dependência nova) usado nas duas sheets; a sheet do feed passou a guardar document.activeElement ao abrir e devolver o foco a ele ao fechar (a sheet da busca já fazia isso via filtersTriggerRef, só faltava o Tab-trap). Verificado ao vivo em Chromium real: 25 Tabs consecutivos nunca escapam da sheet, Shift+Tab também nunca escapa, Escape fecha e devolve o foco ao botão que abriu.apps/web/test/focus-trap.test.ts (lógica pura do trap: wrap em ambas direções, no-op no meio da lista, no-op para tecla não-Tab/lista vazia; mais teste de conteúdo confirmando as duas screens usam trapTabFocus e restauram foco) + verificação interativa via Chromium (ver acima) + check-a11y-perf-invariants.mjs (custom-sheet-missing-focus-trap)
4Achado durante a própria medição do baseline (não estava no radar antes de medir): a página de busca (/search, Smoke P0) tinha CLS medido de 0.14 — acima da faixa "boa" (≤0.1) do Core Web Vitals. Causa raiz: o bloco "Descoberta territorial" (chips de distrito) só aparecia depois que territoryCatalogService.listDistricts() resolvia, sem nenhum placeholder reservando o espaço — quando os distritos chegavam, o bloco inteiro aparecia de repente e empurrava as abas/resultados abaixo.MEDIUM (performance, achado por medição, não por leitura de spec)search.screen.tsx{showTerritoryDiscovery && discoveryDistricts.length > 0 ? (...) : null}, nada renderizado enquanto carregaNovo estado districtsStatus ('loading'/'ready'); enquanto carregando, renderiza um skeleton com a mesma altura/formato de uma chip row real (.search-chip-skeleton, reaproveitando a animação skeleton-shift já existente em globals.css) em vez de nada. CLS medido depois da correção: ~0.10–0.11 (4 medições repetidas). Não zerado — o painel de sugestões/buscas recentes carrega de forma assíncrona independente e ainda contribui um resíduo menor; ver Riscos abaixo.check-a11y-perf-invariants.mjs (search-discovery-missing-loading-skeleton)

Confirmado íntegro (sem correção necessária)

Verificado por leitura direta do código e/ou medição, não apenas pela spec:

  • Paginação: feed (useFeed, loadMore/hasNextPage), busca (useUniversalSearch, paginação por página) e toda lista de resultado já são paginadas — não há lista "carrega tudo de uma vez" em nenhuma superfície P0.
  • Query keys estáveis / sem request duplicado: useUniversalSearch usa um contador de sequência (requestSequence) para descartar respostas obsoletas de uma corrida de requests, e useFeed usa uma flag active por efeito para não aplicar setState de uma request cancelada — ambos os padrões já evitam o problema clássico de duplicação/race. Confirmado ao vivo: nenhuma URL de API chamada mais de uma vez ao carregar /search ou /teams/:slug. SPEC-ARCH-PERF-001 ("stable query keys") já está atendido; nenhuma mudança necessária. Deduplicação de item por id (mergeFeedItems) também já evita duplicar posts entre páginas.
  • Contraste de texto: text/textMuted contra background/surfaceElevated/surfaceAlt medem entre 5.4:1 e 18.9:1 — todos acima do mínimo AA (4.5:1) para texto normal, em toda combinação usada pelo tema claro (único tema em uso hoje — darkTheme não é referenciado por nenhuma tela).
  • Responsividade: nenhum overflow horizontal em 5 breakpoints (375/390/768/1280/1440px) × 3 rotas P0, confirmado por medição real (não apenas leitura de CSS/media query).
  • Identidade visual profissional: revisão visual da Public Team Page, busca e feed anônimo (screenshots em Chromium real, mobile e desktop) confirma composição editorial já estabelecida (tokens de cor terrosos, tipografia condensada em caixa alta para eyebrows, hierarquia clara) — nenhum padrão infantil ou de "bet" (sem cores neon, sem contadores/roletas, sem urgência artificial) encontrado em nenhuma das três telas revisadas.
  • Imagens já com aspect ratio em todo o resto do app: .field-gallery-grid img (4/3), .match-gallery-list img (16/10), .service-showcase-grid img (4/3), .sponsor-grid img (3/2), .player-portrait (4/5) já reservavam espaço corretamente antes desta pack — só .feed-card-media (achado #2 acima) estava sem.

Riscos e pendências

  • CLS residual de ~0.10–0.11 em /search (achado #4): o bloco de descoberta territorial foi corrigido, mas o painel de sugestões/buscas recentes (useSearchSuggestions, leitura de histórico do localStorage) segue carregando de forma assíncrona sem reserva de espaço equivalente. Não corrigido nesta pack — o histórico de busca é tipicamente vazio num navegador novo (não gera shift real na maioria das visitas) e as sugestões têm tamanho de conteúdo inerentemente variável (não dá para reservar uma altura precisa sem inventar um número arbitrário). Candidato a revisão futura se a métrica se mostrar pior com dados de uso reais.
  • Baseline medido em next dev, não no bundle de produção com API real: build de produção recusa mocks por design (environment === 'production' && useMocks lança erro, client-config.ts) e não há staging real acessível deste ambiente sandboxed — os números da tabela de baseline são um baseline relativo (para detectar regressão futura), não uma medição definitiva de produção. pnpm run build com as variáveis de produção (NODE_ENV=production, *_USE_MOCKS=false, *_API_BASE_URL=https://api-staging.raizfc.com.br) continua completando com sucesso (7/7 tasks) — a limitação é só de medição de performance ao vivo, não de build.
  • darkTheme (tema escuro) não é usado por nenhuma tela web hoje — a correção de contraste do foco (achado #1) foi replicada lá por consistência, mas não há verificação end-to-end de um modo escuro real porque ele não existe no produto ainda.
  • Border/divisor com baixo contraste (--border vs --background, ~1.5:1): não é um achado desta pack (WCAG 1.4.11 exige 3:1 para limites de componentes de UI cuja identificação depende só da cor — a maioria dos usos de --border aqui é decorativo/divisor entre seções, não o único indicador de um controle interativo). Registrado como observação de design system para revisão futura, não bloqueante.
  • Nenhum achado BLOCKER/HIGH em aberto — os dois HIGH encontrados (contraste do foco, Tab-trap das sheets) foram corrigidos e verificados (teste automatizado + verificação interativa em Chromium real) dentro desta mesma pack, antes desta aprovação.