Skip to content

perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2) - #113

Merged
caioross merged 1 commit into
mainfrom
auto/issue-59-racetrack-transform
Jul 30, 2026
Merged

perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2)#113
caioross merged 1 commit into
mainfrom
auto/issue-59-racetrack-transform

Conversation

@caioross

Copy link
Copy Markdown
Owner

Contexto

Fatia 2 (e última) da #59, no escopo que o 🗂️ Curador fixou no comentário de 28/07 —
que por sua vez veio do item 9 do 🏛️ Parecer do Conselho. A fatia 1 (#105) cortou os
re-renders de React; sobrou o custo que nenhum memo alcança: a pista animava
duas propriedades de layout por jogador.

  • RaceTrack.tsx:144-146initial={{ width: 0 }} / animate={{ width: "<pct>%" }}
  • RaceTrack.tsx:153-156animate={{ left: "calc(<pct>% - 8px)" }}

Ambas spring, retargetadas a até 8,3 Hz por jogador (PROGRESS_THROTTLE_MS = 120).
Com ABSOLUTE_MAX_PLAYERS = 30 (src/lib/room.ts), o pior caso são 60 springs de
layout + paint na mesma main thread da textarea
— exatamente a área sagrada.

O que mudou (1 arquivo, 14 linhas)

  • Barra: ocupa o trilho inteiro (w-full) e encolhe por scaleX com
    origin-left. O gradiente vive no espaço local do elemento, então acompanha a escala —
    o degradê visível continua indo de <cor>40 até <cor>.
  • Marcador: translateX. Um wrapper da largura do trilho faz x: "<pct>%"
    (percentual da própria largura) equivaler ao antigo left: <pct>%; o -8px do
    calc() virou a margem negativa (-ml-2) do ícone. O wrapper é pointer-events-none.

Nada mais foi tocado: PROGRESS_THROTTLE_MS, broadcastProgress, sanitizeResults e o
caminho do finish estão intactos, como o Parecer e o escopo do Curador exigem.

Evidência

1. Geometria idêntica (AC 2). Medição de getBoundingClientRect() no navegador,
marcação antiga × nova lado a lado, trilho de 400px:

pct barra (old → new) marcador (old → new) igual
0% w=0 @0w=0 @0 x=-8x=-8
25% w=100 @0w=100 @0 x=92x=92
50% w=200 @0w=200 @0 x=192x=192
75% w=300 @0w=300 @0 x=292x=292
100% w=400 @0w=400 @0 x=392x=392

2. Custo na main thread. 30 jogadores × 300 atualizações (18.000 alvos), forçando o
flush de estilo+layout a cada iteração — mediana de 3 execuções:

marcação mediana execuções
width + left 122,4 ms 121,9 / 128,7 / 122,4
scaleX + translateX 61,4 ms 60,7 / 69,7 / 61,4

−49,8% de tempo de main thread por rodada de atualização.

Método, honestamente: as duas medições são num harness sintético de DOM montado no
navegador (mesma marcação e mesmo CSS, sem React e sem framer-motion), não no app rodando.
Motivo: no Browser pane headless o rAF congela e os springs do framer-motion não avançam —
um "antes/depois" visual ali provaria nada (a barra ficaria parada no initial). O que
sustenta a ponte até o componente real é o passo 3.

3. O framer-motion emite mesmo o transform esperado (era o ponto mais arriscado do diff):
em framer-motion@11.18.2, getValueAsType só anexa px quando o valor é number — string
passa verbatim, então x: "50%" vira translateX(50%), e é por isso que o wrapper precisa ter
a largura do trilho. scaleX numérico entra direto. origin-left sai no CSS compilado
(.origin-left{transform-origin:left} no .next/static/css).

Ressalva de fidelidade visual (leia antes de aprovar)

A geometria é idêntica (tabela acima), mas duas coisas rasterizadas passam a acompanhar
a escala não-uniforme da barra, por construção da técnica:

  • o box-shadow (0 0 12px <cor>80) tem o alastramento horizontal multiplicado pela
    escala — a 50% ele sangra ~6px em vez de 12px dentro do trilho;
  • o rounded-full da ponta direita vira uma elipse leve no meio da corrida.

Ambos são exatos a 0% e a 100% (scaleX(1) = identidade — inclusive no estado que fica
na tela no fim da corrida), ficam numa barra de 8px de altura e a ponta direita é onde
o 🚀/🏁 (16px, com drop-shadow próprio, não escalado) se sobrepõe. Compensar o halo
exigiria blur de 12/escala px — a 1% de progresso, um blur de 1200px, ou seja, trocar
layout por rasterização cara e contrariar o objetivo da issue. Preferi não fazer e declarar.

Gate

typecheck ✅ · build ✅ · vitest 158/158 ✅ · validate-metrics 45/45 ✅ ·
validate-persistence 45/45 ✅ · lint N/A (sem config ESLint no repo).

Classificação e riscos

§7.3 normal — só src/components/RaceTrack.tsx; não toca api/rooms/**, supabase.ts,
anti-cheat, migrations, CI nem dependências. Segui o escopo do Curador na íntegra; a única
adição minha é o pointer-events-none no wrapper do marcador (ele agora cobre o trilho
inteiro, e sem isso passaria a interceptar ponteiro onde antes não havia nada).

Risco residual: a ressalva visual acima. reduced-motion na pista continua como estava —
gap pré-existente que o próprio Curador separou desta fatia (é a #108).

📌 Achado fora de escopo, para o Curador: TypingCore.tsx:252-256 anima width na barra
de progresso pessoal — dentro do componente da textarea e retargetada a cada tecla.
É o mesmo defeito, no lugar mais sensível possível, e não estava mapeado em nenhuma issue.

Closes #59

…left (#59)

Fatia 2 da #59. Cada jogador na pista animava DUAS propriedades de layout
(`width` na barra e `left` no marcador), com spring retargetado a até 8,3 Hz
(PROGRESS_THROTTLE_MS = 120). Com ABSOLUTE_MAX_PLAYERS = 30, o pior caso são
60 springs disparando layout + paint na mesma main thread da textarea — a
área sagrada do produto.

A barra passa a ocupar o trilho inteiro e encolher por `scaleX` com
`transform-origin: left`; o marcador passa a usar `translateX`, com um wrapper
da largura do trilho para que `x: "<pct>%"` equivalha ao antigo `left: <pct>%`
e o -8px do `calc()` vire margem negativa do ícone. Só transform — nada de
layout.

Geometria medida idêntica (barra e marcador) em 0/25/50/75/100%.

Refs #59
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Ready Ready Preview Jul 30, 2026 5:12pm

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@caioross

Copy link
Copy Markdown
Owner Author

🩺 PR Doctor · parecer · 2026-07-30

Veredito: APROVADA — merge. Classificação confirmada §7.3 normal (só
src/components/RaceTrack.tsx; não toca api/rooms/**, supabase.ts, anti-cheat, migrations,
CI nem dependências) → não convoco quórum §7.2: as lentes AppSec e Ofensiva não teriam superfície
(zero rede, zero estado compartilhado, zero caminho de pontuação). Diff lido inteiro (14+/7−).

Base: merge-base == origin/main (02b0f45) — sem defasagem.

Gate reproduzido localmente (worktree i59b no SHA 76cec06, não confiei só na CI):
typecheck ✅ · build ✅ · vitest 158/158 ✅ · validate-metrics 45/45 ✅ ·
validate-persistence ✅ nas duas cópias (45/45 em .claude/skills/cr-multiplayer/scripts/,
72/72 em scripts/) · lint N/A (sem config ESLint — HANDBOOK §6).

O que eu verifiquei por conta própria

A ressalva de método do corpo é honesta e está certa (§11 da cr-fleet-ops: no Browser pane
headless o rAF congela, então "antes/depois" visual no componente real provaria nada). Como o
harness sintético não fecha a ponte sozinho, fui atrás dos dois pontos em que este diff realmente
podia quebrar — e os dois se sustentam na fonte, não por argumento:

  1. origin-left não é sobrescrito pelo framer (era o risco silencioso: estilo inline vence
    classe; se o framer emitisse transform-origin: 50% 50%, a barra encolheria pelo centro).
    Em framer-motion@11.18.2, buildHTMLStyles só escreve style.transformOrigin quando
    hasTransformOrigin é verdadeiro — flag que liga com chave origin* em latestValues
    (dist/cjs/index.js:5849 e :5873-5876). O PR anima apenas scaleX → nenhuma origin → a
    classe governa. E .origin-left{transform-origin:left} está no CSS compilado
    (.next/static/css/0464244149cd6c15.css). ✅
  2. x: "<pct>%" vira mesmo translateX(<pct>%). getValueAsType (:5758-5762) só aplica o
    tipo px quando typeof value === "number"; string passa verbatim, e translateAlias.x
    translateX (:5804). Como translateX(%) é relativo à própria border-box, o wrapper
    w-full faz x: pct%left: pct% do trilho, e -ml-2 = −8px reproduz o - 8px do
    calc(). Geometria horizontal idêntica; a vertical também (o wrapper é inset-y-0, então o
    top-1/2 -translate-y-1/2 do ícone tem a mesma altura de referência de antes). ✅

Um ponto que o corpo não trata e eu fui conferir: o wrapper agora é full-width e translada,
logo ultrapassa a borda direita do trilho. Não é regressão — o trilho é overflow-hidden
(RaceTrack.tsx:129-133), o wrapper é invisível (sem fundo, pointer-events-none) e a posição
final do ícone é idêntica à antiga, então o recorte resultante é pixel a pixel o mesmo. Sem
scrollbar, sem interceptação de ponteiro.

Acceptance criteria do 🗂️ Curador (28/07)

  1. width/left fora, scaleX+origin-left e translateX no lugar — ✅
  2. Geometria idêntica em 0/50/100%, com borda e overflow-hidden — ✅ (tabela do corpo + análise acima)
  3. Texto no elemento escalado não distorce — ✅ trivial: o nó com scaleX é auto-fechado, não
    tem filho nenhum. O 🚀/🏁 vive no wrapper de translateX, que não escala.
  4. Sem regressão nos validadores/vitest e sem tocar PROGRESS_THROTTLE_MS, broadcastProgress,
    sanitizeResults ou o finish — ✅ (1 arquivo no diff)

Fatia 2 é a última do escopo e os itens adiados saíram de propósito (coalescing e reduced-motion
#108), então Closes #59 está correto.

Sobre a ressalva visual declarada

Aceito, e ela é ainda menor do que o corpo assume: o box-shadow do halo já é recortado pelo
overflow-hidden do trilho, então o "sangramento" que encolhe com a escala é limitado à faixa de
8px. A elipse do rounded-full no meio da corrida fica sob o emoji de 16px. Ambos são identidade
exata em 0% e 100% — inclusive no estado final, que é o que fica na tela. Trocar isso por blur de
12/escala px seria contrariar a própria issue. Declarar em vez de maquiar foi a escolha certa.

📌 Achado fora de escopo — confirmado, fica para o 🗂️ Curador: TypingCore.tsx:251-256 anima
width num spring (stiffness: 100, damping: 20) dentro do componente que hospeda a textarea,
retargetado a cada tecla. É o mesmo defeito desta PR no ponto mais sensível da área sagrada, e
não está mapeado em issue nenhuma. Registro no Diário.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance/Área sagrada: progresso de cada adversário re-renderiza CodeDisplay e a textarea — custo de digitar cresce com o nº de jogadores

1 participant