Skip to content

A11y/Motion: prefers-reduced-motion não alcança nenhuma animação de framer-motion — countdown em tela cheia e pista da corrida ignoram a preferência (§VII.4) #108

Description

@caioross

O problema

docs/UI-AAA-OVERHAUL.md §VII.4 trata prefers-reduced-motion como métrica binária de "AAA pronto":

  • prefers-reduced-motion honrado em cada efeito; sem flash > 3Hz.

Hoje o projeto tem um fallback global em globals.css:49-56:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
    ...
  }
}

Essa regra cobre só CSS (animation e transition). Framer Motion não passa por nenhum dos dois: ele roda um laço de requestAnimationFrame e escreve style.transform / style.opacity inline a cada quadro. Zerar transition-duration não tem efeito sobre um valor que já chega interpolado no estilo inline. Ou seja: o usuário liga "reduzir movimento" no sistema, o CSS obedece, e todo o movimento de framer-motion continua exatamente igual.

Por isso vários arquivos comentam confiando na regra global — ex. CodeEditor.tsx:85 ("sob prefers-reduced-motion a regra global zera a duração"). Para aquele caso o comentário está certo, porque o shake é uma animation CSS. Para os <motion.*> abaixo, não está.

Inventário (medido em origin/main @ 82f7817)

10 componentes importam framer-motion e não importam useReducedMotion:

Componente Movimento Gravidade
Countdown.tsx:11-18 dígito de 160–220px em tela cheia: scale 0.4→1 + rotate -8°→0, saída scale 1.6, 1× por segundo, 3×seguidas 🔴 pior caso
RaceTrack.tsx:142-156 2 springs por jogador (barra + racer) animando continuamente durante a corrida 🔴 área sagrada
Modal.tsx:30-41 · Toast.tsx:25-29 slide + scale de entrada/saída 🟠
HomeView.tsx (11) · Lobby.tsx (13) · RoomView · Chat · FloatingChat · Logo reveals de entrada 🟡

O Countdown é o mais sério e a spec já o marca nominalmente (§ linha 552): "O flash branco respeita reduced-motion (vira mudança de cor suave) — importante para fotossensibilidade". Hoje não respeita nada: é o maior elemento da tela pulsando 3× seguidas, sem escapatória, e é inevitável — passa em toda partida multiplayer.

O RaceTrack é o segundo porque não é um reveal que passa: são springs vivos ao lado da textarea durante a corrida inteira. Com o limite de 30 jogadores (room.ts:282, #69) são até 60 springs simultâneos.

Que o padrão certo já existe no repo é o que torna isto barato: Results, TypingCore, SpectatorView, AuroraBackground, ClickSpark, DecryptedText e o BannerCarousel novo (:100) já usam useReducedMotion() corretamente. Isto é propagar um padrão da casa, não inventá-lo.

Escopo desta fatia

Só os dois de gravidade 🔴 + a primitiva compartilhada. Os 8 restantes são mecânicos e viram fatia 2 (declarada abaixo) — a intenção é estabelecer o padrão com revisão cuidadosa antes de espalhá-lo por 8 arquivos.

Acceptance criteria

  • Existe uma primitiva única (sugestão: src/lib/motion.ts, que a §VII.1 Fase 0 já prevê) exportando os presets a serem reusados — sem repetir const reduced = useReducedMotion() + ternário solto em cada arquivo.
  • Countdown: com prefers-reduced-motion: reduce, o dígito não escala nem rotaciona. Troca permitida: cross-fade de opacidade, ou nenhuma transição. O key={n} e o AnimatePresence continuam trocando o número a cada segundo (a informação não pode sumir).
  • RaceTrack: com reduced-motion, barra e racer vão para a posição nova sem spring (transition={{ duration: 0 }} ou equivalente). Sem salto de layout e sem perder a posição correta.
  • Nenhum dos dois perde informação sob reduced-motion: o número do countdown continua legível a cada tick e o progresso de cada jogador continua correto.
  • O comentário enganoso de CodeEditor.tsx:85 fica; mas não adicione comentários novos afirmando que a regra global do CSS cobre framer-motion.
  • Gate: pnpm typecheck + pnpm build + vitest + validate-metrics (o RaceTrack é vizinho da área sagrada).

Verificação sugerida (leia antes de tentar screenshot)

.claude/skills/cr-fleet-ops §11 e a memória da frota registram que no Browser pane headless o rAF congela — framer-motion trava em opacity: 0 e o visual mente. Não tente provar isto por screenshot.

O que funciona: emular a media query e instrumentar a lógica — ler o valor que o componente passa em transition sob cada preferência, ou um teste de render (jsdom + matchMedia mockado) afirmando que transition.duration === 0 / ausência de scale no initial quando reduce está ligado. Um teste assim também protege contra regressão, que é o que a §VII.4 exige de uma métrica binária.

Fora de escopo (fatia 2 — abrir depois desta mergear)

Os 8 componentes 🟠/🟡 (Modal, Toast, HomeView, Lobby, RoomView, Chat, FloatingChat, Logo), reusando a primitiva criada aqui.

Não confundir com a #59

A #59 fatia 2 também toca o RaceTrack, mas por performance (tirar a pista do caminho do re-render + coalescing). Reduced-motion foi deixado explicitamente de fora dela em 28/07 e é o que esta issue cobre. Se as duas forem pegas juntas, atenção ao conflito no mesmo arquivo.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Prioridade normalarea:uiInterface, design system, animação, game feel

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions