Skip to content

Performance/Área sagrada: a barra de progresso do TypingCore anima width num spring ao lado da textarea — mesmo defeito da #59 no ponto mais sensível (TypingCore.tsx:251-256) #114

Open

Description

@caioross

Dívida repassada pelo 🏁 PR Doctor no Diário #4 em 2026-07-30 ("TypingCore.tsx:251-256 anima width num spring a cada tecla... mesmo defeito da #59 no ponto mais sensível da área sagrada, sem issue mapeada"). Conferido linha a linha contra origin/main @ 098372e.

O problema

A barra de progresso do card "suas estatísticas" vive dentro do TypingCore — o mesmo componente que hospeda a textarea da corrida — e anima width:

// src/components/TypingCore.tsx:251-256
<div className="mt-3 relative h-1.5 rounded-full bg-bg-soft overflow-hidden">
  <motion.div
    className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-neon-green to-neon-cyan"
    animate={{ width: `${Math.round(progress * 100)}%` }}
    transition={{ type: "spring", stiffness: 100, damping: 20 }}
  />
</div>

progress é recalculado por tecla (computeProgress(typed.length, code.length)). É exatamente o defeito que a #59 acabou de corrigir na pista (#113, merge 098372e: width/left → scaleX/translateX com origin-left) — só que aqui ele está no componente que possui o campo de digitação, ou seja, no ponto mais sensível da área sagrada (HANDBOOK §2).

Por que width custa: framer-motion escreve style.width inline a cada quadro do spring. Trocar a largura da caixa força layout + repaint, e como o preenchimento é um linear-gradient, o gradiente é re-rasterizado a cada quadro (o gradiente é função da largura da caixa). scaleX não passa por nenhum dos dois — é composição pura, e é o padrão que a #113 já estabeleceu na casa.

Quanto tempo o spring fica vivo: o alvo é arredondado (Math.round(progress * 100)), então ele só é re-disparado quando o percentual inteiro muda — a cada ~3 teclas num snippet de 300 chars, a cada tecla num de 100. Mas com stiffness: 100 / damping: 20 cada acomodação leva algumas centenas de ms; digitando de forma sustentada o spring é re-alvejado antes de assentar e, na prática, roda de ponta a ponta da corrida.

Honestidade sobre o tamanho: isto é menor que a #59. Lá eram até 60 springs simultâneos, proporcionais ao nº de jogadores; aqui é um elemento pequeno (h-1.5), constante. O que sustenta a issue não é o volume, é o lugar (mesmo commit/paint da textarea, acontece em toda corrida, inclusive solo no /practice) e o custo de resolver: a correção já está escrita e revisada na #113 — é aplicá-la a 5 linhas.

Brinde na mesma edição: reduced-motion

const reduced = useReducedMotion() já existe em TypingCore.tsx:60 — mas é usado só no IdleWarning. Esta barra ignora a preferência.

⚠️ Isso corrige o inventário da #108, que lista TypingCore entre os componentes que "já usam useReducedMotion() corretamente". Usa — no aviso de inatividade; não na barra. Como quem resolver esta issue estará editando exatamente essas linhas, o guard entra aqui e a #108 (fatia 2) não precisa voltar ao arquivo.

Acceptance criteria

  • A barra é animada por transform (scaleX + origin-left, ou equivalente), não por width — nenhum width animado sobra em TypingCore.tsx.
  • Geometria idêntica à atual em 0 / 25 / 50 / 75 / 100% de progresso (mesma prova que a perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2) #113 usou), incluindo os cantos rounded-full e o overflow-hidden do trilho — sem vazar em 100%, sem barra visível em 0%.
  • Sob prefers-reduced-motion: reduce, a barra vai para a posição nova sem spring (duration: 0 ou equivalente), reusando o reduced da linha 60. O progresso continua correto e legível — nenhuma informação some.
  • Nenhuma fórmula de métrica muda (computeProgress/computeWpm/computeAccuracy intocadas) e nenhum trabalho novo por keystroke é introduzido.
  • Medição registrada na PR: perfil de main thread durante digitação sustentada, antes e depois — como a perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2) #113 fez. Se o ganho for pequeno, diga o número honesto; a mudança se justifica pela classe do defeito, não por um número inflado.
  • Gate: pnpm typecheck · pnpm build · vitest · node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs (área sagrada).

Dica de abordagem

Copie a solução da PR #113 em RaceTrack.tsx — inclusive as duas armadilhas que o PR Doctor já confirmou na fonte do framer-motion@11.18.2 e registrou no diário de 2026-07-30:

  1. transformOrigin inline só é emitido por chaves origin* — a classe utilitária origin-left continua valendo;
  2. o wrapper precisa ficar full-width com o trilho em overflow-hidden (já é o caso aqui, linha 251).

Escopo é um arquivo, ~5 linhas. Não expanda para os outros <motion.*> do arquivo (o IdleWarning já está correto).

Activity

  1. added
    P2Prioridade normal
    area:engineMotor de digitação, WPM, precisão, anti-cheat
    area:uiInterface, design system, animação, game feel
    on Jul 31, 2026
  2. caioross commented on Aug 5, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-08-05

    Veredito: APROVADA PARA EXECUÇÃO — §7.3 (um arquivo, ~6 linhas, nenhuma fórmula de métrica encostada). P2 correta: o que sustenta a issue é o lugar e o custo ~zero de corrigir, não o volume — a própria issue já diz isso, e o PR deve repetir essa honestidade.

    Premissas reconferidas em origin/main@d8ee488: TypingCore.tsx:251-256 segue animando width num spring · useReducedMotion() existe em :60 e é usado só pelo IdleWarning (:195, :282-284) · esse motion.div de :252 é o único width animado do arquivo · computeProgress já satura em 1 (metrics.ts:37-39), então não há estouro em 100% · o padrão-alvo está pronto e comentado em RaceTrack.tsx:142-156.

    • Engenharia: copiar a perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2) #113 é a decisão certa, com três detalhes que decidem se o PR passa. (1) w-full é obrigatório. Hoje o preenchimento não tem largura própria (absolute inset-y-0 left-0, sem w-*): a largura é a propriedade animada. Trocar para scaleX sem w-full escala um elemento de largura zero → barra invisível a 100%. RaceTrack.tsx:148 (h-full w-full origin-left) é o gabarito. (2) Preserve a cadência do alvo. Hoje o spring só é re-alvejado quando o percentual inteiro muda (Math.round(progress * 100), :254). Se o novo animate receber progress cru, ele passa a ser re-alvejado a cada tecla — um "fix" de performance que aumenta o trabalho por keystroke na área sagrada é exatamente a classe de amplificação que já vetou PR nesta frota. Use Math.round(progress * 100) / 100. (3) transformOrigin inline só sai por chaves origin*, então a classe utilitária origin-left continua valendo (armadilha já confirmada na fonte do framer em 30/07).
    • Segurança/Anti-cheat: superfície zero — sem entrada, sem rota, sem persistência; computeProgress/computeWpm/computeAccuracy (:101-103) não são tocadas e a barra não alimenta nada além de pixels. Nada a defender e nada a forjar.
    • UX/Game-feel: o brinde do reduced é o ganho de UX real da edição — transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 100, damping: 20 }}, reusando :60, sem novo hook. Sobre a AC "geometria idêntica": ela é atingível no comprimento e no degradê (o linear-gradient vive no espaço local do elemento e acompanha a escala — é o mesmo argumento registrado em RaceTrack.tsx:145-146), mas não nos cantos: scaleX deforma o rounded-full do preenchimento (cap direito vira elipse). É o mesmo trade que a perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2) #113 aceitou, e o trilho tem rounded-full + overflow-hidden (:251) que já esconde o lado esquerdo. Declare o desvio no PR em vez de escrever "idêntica" — over-claim é o que a lente de Domínio caça.
    • Performance: o ganho é layout + repaint por quadro do spring (e re-rasterização do gradiente, que é função da largura da caixa) trocado por composição pura. O ganho não é re-render: o TypingCore já re-renderiza por tecla e ainda tem relógio próprio de 5 Hz (:84-88, setInterval(setNow, 200)), e isso continua igual depois do fix. Se o PR disser "menos re-render", está errado. Sobre a AC de medição: o Browser pane headless congela o rAF (registro da frota) — o framer estaciona no valor inicial e qualquer número tirado dali é ficção. Se não der para perfilar main thread num navegador real, diga isso e sustente pela classe do defeito + prova determinística de geometria; número inventado derruba o PR mais rápido que a ausência dele.
    • Produto: atinge 100% das corridas, inclusive o solo do /practice (PracticeView.tsx:207), e paga a dívida repassada pelo PR Doctor em 30/07 com ~6 linhas. De quebra corrige o inventário da 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 (que lista TypingCore como "já correto" em reduced-motion — usa, mas só no aviso de inatividade): cite explicitamente a 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 no corpo do PR para a fatia 2 não voltar a este arquivo.

    Plano sugerido:

    1. TypingCore.tsx:252-256 vira: className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-neon-green to-neon-cyan", initial={{ scaleX: 0 }}, animate={{ scaleX: Math.round(progress * 100) / 100 }}, transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 100, damping: 20 }}. Trilho de :251 intocado.
    2. Comentar a linha como a perf(racetrack): pista fora do layout — scaleX/translateX no lugar de width/left (#59, fatia 2) #113 comentou (RaceTrack.tsx:142-146): por que transform, e que a cadência do alvo é arredondada de propósito.
    3. Prova de geometria em 0 / 25 / 50 / 75 / 100%: mesma extensão visível, sem vazar do trilho em 100%, sem barra em 0% — e registre o desvio dos cantos.
    4. Prova de reduced-motion: com prefers-reduced-motion: reduce, a barra salta para a posição nova sem spring e o número de Progresso (:237-242) continua correto.
    5. Não expandir para os outros <motion.*> do arquivo (o IdleWarning já trata reduced, :282-284).
    6. Gate: pnpm typecheck · pnpm build · pnpm test · node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs (área sagrada) · validate-persistence na dúvida.

    Vetores de veto previstos:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Prioridade normalarea:engineMotor de digitação, WPM, precisão, anti-cheatarea:uiInterface, design system, animação, game feel

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions