O problema
docs/UI-AAA-OVERHAUL.md §VII.4 trata prefers-reduced-motion como métrica binária de "AAA pronto":
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
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.
O problema
docs/UI-AAA-OVERHAUL.md§VII.4 trataprefers-reduced-motioncomo métrica binária de "AAA pronto":Hoje o projeto tem um fallback global em
globals.css:49-56:Essa regra cobre só CSS (
animationetransition). Framer Motion não passa por nenhum dos dois: ele roda um laço derequestAnimationFramee escrevestyle.transform/style.opacityinline a cada quadro. Zerartransition-durationnã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 é umaanimationCSS. Para os<motion.*>abaixo, não está.Inventário (medido em
origin/main@82f7817)10 componentes importam
framer-motione não importamuseReducedMotion:Countdown.tsx:11-18scale 0.4→1+rotate -8°→0, saídascale 1.6, 1× por segundo, 3×seguidasRaceTrack.tsx:142-156Modal.tsx:30-41·Toast.tsx:25-29HomeView.tsx(11) ·Lobby.tsx(13) ·RoomView·Chat·FloatingChat·LogoO
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 datextareadurante 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,DecryptedTexte oBannerCarouselnovo (:100) já usamuseReducedMotion()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
src/lib/motion.ts, que a §VII.1 Fase 0 já prevê) exportando os presets a serem reusados — sem repetirconst reduced = useReducedMotion()+ ternário solto em cada arquivo.Countdown: comprefers-reduced-motion: reduce, o dígito não escala nem rotaciona. Troca permitida: cross-fade de opacidade, ou nenhuma transição. Okey={n}e oAnimatePresencecontinuam 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.CodeEditor.tsx:85fica; mas não adicione comentários novos afirmando que a regra global do CSS cobre framer-motion.pnpm typecheck+pnpm build+vitest+validate-metrics(oRaceTracké 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 emopacity: 0e 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
transitionsob cada preferência, ou um teste de render (jsdom+matchMediamockado) afirmando quetransition.duration === 0/ ausência descalenoinitialquandoreduceestá 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.