You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Crescimento: tela de Resultado não tem "compartilhar" — transformar cada corrida terminada em convite (loop viral) #22
A persona O Iniciante (Feedback #14) confirmou que o encantamento nº1 é ver o próprio nome no ranking global logo após terminar — "sensação de progresso pura, me deu vontade de jogar de novo". A home inteira já vende "manda o link pros amigos", e existe copiar-link no lobby (Lobby.tsx:41-44) e no RoomView.tsx:247-250. Só falta o elo mais óbvio do loop viral: compartilhar o RESULTADO.
Hoje a tela pós-corrida (Results.tsx, área de ações linhas 137-160) tem só "Jogar de novo" e "ver ranking global" — nenhum botão de compartilhar. Cada corrida terminada é um convite desperdiçado.
O que fazer
Botão "Compartilhar resultado" na área de ações do Results.tsx (ao lado de "Jogar de novo"), que vira o WPM/precisão/colocação do jogador (me já disponível em Results.tsx:33, com place/wpm/accuracy) num texto curto + link do site (SITE.url em src/lib/site.ts).
Acceptance criteria (verificáveis)
Botão de compartilhar visível na área de ações (Results.tsx:137-160), focável por teclado e com aria-label.
Ao clicar em desktop: copia p/ a área de transferência um texto tipo Fiz 85 WPM (98%) e terminei em 1º no CodeRacer 🏁 me alcança: https://code-racer-three.vercel.app + toast de confirmação (reusar o padrão de clipboard já existente, não reinventar).
Se navigator.share existir (mobile): usa a Web Share API (sheet nativo do SO) com fallback automático p/ clipboard quando indisponível ou cancelado.
Comportamento definido p/ quem não terminou (me.place ausente): texto sem "terminei em Xº" (ex.: só WPM/precisão) OU botão oculto — escolha uma e documente no PR.
prefers-reduced-motion respeitado (nenhuma animação nova obrigatória).
Zero impacto na área sagrada (input da corrida): Results é tela pós-corrida; nada compete com a textarea.
Dica de abordagem
Helper puro buildShareText(me) (testável) + handler onShare que tenta navigator.share({ text, url }) e no catch/ausência cai no navigator.clipboard.writeText — mesmo par try/catch de RoomView.tsx:247-250. Nada de dado sensível no texto (só nick/WPM/precisão/colocação, todos já públicos no ranking).
Veredito: APROVADA PARA EXECUÇÃO (com 3 ajustes ao AC — ver UX e Engenharia)
Revisado contra origin/main (7236d47). Correção de referência: a área de ações do Results.tsx é hoje l.134-156 (não 137-160) — o botão entra ao lado do motion.button "Jogar de novo" (l.136-144), acima do link "ver ranking global" (l.150-155).
Engenharia:me vem de Results.tsx:41 (ranked.find(p => p.id === meId)) e é Player | undefined — renderize a ação dentro de um me && (...), como a headline já faz em l.64. Helper puro buildShareText(me: Player, url: string): string em src/lib/share.ts (arquivo novo, não utils.ts) + teste Vitest no padrão de src/lib/room.test.ts — a fundação de testes já está de pé (Testes: instalar Vitest + primeiro suite na fronteira anti-cheat (sanitizeResults) e rodar no CI #31/Engine/Testes: extrair métricas puras de Race.tsx para src/lib/metrics.ts + testes de honestidade (WPM/precisão) #32/Testes/Persistência: extrair mapeamento puro de persistMatch (results→matches/scores) e cobrir com Vitest #50), e um helper puro sem teste agora seria regressão de convenção. Armadilha (URL errada no texto compartilhado):SITE.url é process.env.NEXT_PUBLIC_SITE_URL com fallback https://coderacer.app (src/lib/site.ts:8,14) — um domínio que não é o de produção (code-racer-three.vercel.app). Se a env não estiver setada no build da Vercel, todo share aponta para o lugar errado. Faça como o RoomCodePill já faz: window.location.origin no handler (RoomView.tsx:57), com SITE.url só como default do helper puro (que não tem window). Clipboard: copie o par exato de RoomView.tsx:56-62 — navigator.clipboard.writeText(txt).then(() => toast.push({kind:"success",...})).catch(() => toast.push({kind:"error",...})). Não reinvente.
Segurança/Anti-cheat: vetor nulo — nick/WPM/precisão/colocação já são públicos no /leaderboard. Duas regras: (1) não inclua o código da sala nem /room/<code> no texto — a sala é efêmera e o link dá entrada a qualquer um; o AC pede o link do site, mantenha assim. (2) O nick vem do jogador e é interpolado em string — React escapa na tela, mas o texto vai para clipboard/navigator.share; sanitizeResults já limita nome a MAX_NAME_LEN (src/lib/room.ts:102) na persistência, e aqui basta não injetar HTML/markdown. Zero dependência nova (navigator.share é nativo) — nada que encoste em §7.1.
UX/Game-feel:ajuste 1 — cancelar não é falhar.navigator.share rejeita com AbortError quando o usuário fecha o sheet; cair no clipboard nesse caso é hostil (ele disse não). Fallback só quando navigator.share é indisponível ouerr.name !== "AbortError". Documente essa escolha no PR (o AC diz "cancelado" — este parecer é a justificativa para divergir). Ajuste 2 — quem não terminou vê o botão. O AC dá a opção "ocultar"; escolha texto sem colocação, nunca ocultar: quem não terminou é justamente quem precisa de um caminho de volta, e Results.tsx:66-69 já tem as duas microcopy (// você terminou em Xº / // você não terminou esta corrida) — espelhe o tom. Ajuste 3 — hierarquia visual. Use btn-secondary/btn-ghost, não btn-primary: "Jogar de novo" (l.140) tem de continuar sendo a ação primária; o share é o convite, não o CTA. aria-label explícito + whileHover/whileTap idênticos a l.137-138 (o useReducedMotion de l.30 já está disponível se precisar suprimir). Nenhuma animação nova obrigatória. Nota de tooling:useToast() funciona dentro de Results porque a árvore está sob <ToastProvider> em src/app/room/[id]/page.tsx:19 — mas o provider não está em src/app/layout.tsx. Se a tela for reusada fora dessa rota (ex.: /practice, PR feat(practice): rota /practice com Treino Livre solo, sem sala (#25) #40), useToast cai no fallback silencioso (Toast.tsx:48-52) e o AC "toast de confirmação" falha sem erro. Confira antes de declarar verde.
Performance: zero na área sagrada — Results é tela pós-corrida, nada compete com a textarea. Sem lib nova, sem re-render extra relevante (um useState de "copiado" no máximo).
src/lib/share.ts — buildShareText(me, url = SITE.url): com me.place → Fiz ${Math.round(me.wpm)} WPM (${Math.round(me.accuracy)}%) e terminei em ${me.place}º no CodeRacer 🏁 me alcança: ${url}; sem place → mesma frase sem o trecho de colocação.
src/lib/share.test.ts — casos: com/sem place, arredondamento de wpm/accuracy fracionários, url custom, nick com caracteres estranhos.
Results.tsx:134-156 — dentro de me && (...), botão btn-secondary com aria-label="Compartilhar meu resultado" ao lado de l.136-144.
Handler: const text = buildShareText(me, window.location.origin); tenta navigator.share({ text, url }); no catch(err) só cai para navigator.clipboard.writeText se err?.name !== "AbortError"; sucesso/erro do clipboard → toast.push (padrão RoomView.tsx:56-62).
Helper puro buildShareText em src/lib/share.ts + 9 casos Vitest, e ShareButton secundário na área de ações do Results.tsx (Web Share API → fallback clipboard + toast). Segui o Parecer do Conselho incluindo os 3 ajustes: AbortError não cai no clipboard (cancelar não é falhar), quem não terminou vê o botão com texto sem colocação, e btn-secondary para não disputar com "Jogar de novo". Uso window.location.origin no handler — SITE.url cai num domínio que não é o de produção quando a env falta.
Gate verde: typecheck · build · 43 testes · validate-persistence (49) · validate-metrics (37). Verificação em browser não foi possível — a tela só existe após corrida completa, o ponto cego que a #37 resolve; declarado no PR.
Activity
caioross commentedon Jul 22, 2026
🏛️ Parecer do Conselho · 2026-07-22
Veredito: APROVADA PARA EXECUÇÃO (com 3 ajustes ao AC — ver UX e Engenharia)
mevem deResults.tsx:41(ranked.find(p => p.id === meId)) e éPlayer | undefined— renderize a ação dentro de umme && (...), como a headline já faz em l.64. Helper purobuildShareText(me: Player, url: string): stringemsrc/lib/share.ts(arquivo novo, nãoutils.ts) + teste Vitest no padrão desrc/lib/room.test.ts— a fundação de testes já está de pé (Testes: instalar Vitest + primeiro suite na fronteira anti-cheat (sanitizeResults) e rodar no CI #31/Engine/Testes: extrair métricas puras de Race.tsx para src/lib/metrics.ts + testes de honestidade (WPM/precisão) #32/Testes/Persistência: extrair mapeamento puro de persistMatch (results→matches/scores) e cobrir com Vitest #50), e um helper puro sem teste agora seria regressão de convenção.Armadilha (URL errada no texto compartilhado):
SITE.urléprocess.env.NEXT_PUBLIC_SITE_URLcom fallbackhttps://coderacer.app(src/lib/site.ts:8,14) — um domínio que não é o de produção (code-racer-three.vercel.app). Se a env não estiver setada no build da Vercel, todo share aponta para o lugar errado. Faça como oRoomCodePilljá faz:window.location.originno handler (RoomView.tsx:57), comSITE.urlsó como default do helper puro (que não temwindow).Clipboard: copie o par exato de
RoomView.tsx:56-62—navigator.clipboard.writeText(txt).then(() => toast.push({kind:"success",...})).catch(() => toast.push({kind:"error",...})). Não reinvente./leaderboard. Duas regras: (1) não inclua o código da sala nem/room/<code>no texto — a sala é efêmera e o link dá entrada a qualquer um; o AC pede o link do site, mantenha assim. (2) O nick vem do jogador e é interpolado em string — React escapa na tela, mas o texto vai para clipboard/navigator.share;sanitizeResultsjá limita nome aMAX_NAME_LEN(src/lib/room.ts:102) na persistência, e aqui basta não injetar HTML/markdown. Zero dependência nova (navigator.shareé nativo) — nada que encoste em §7.1.navigator.sharerejeita comAbortErrorquando o usuário fecha o sheet; cair no clipboard nesse caso é hostil (ele disse não). Fallback só quandonavigator.shareé indisponível ouerr.name !== "AbortError". Documente essa escolha no PR (o AC diz "cancelado" — este parecer é a justificativa para divergir).Ajuste 2 — quem não terminou vê o botão. O AC dá a opção "ocultar"; escolha texto sem colocação, nunca ocultar: quem não terminou é justamente quem precisa de um caminho de volta, e
Results.tsx:66-69já tem as duas microcopy (// você terminou em Xº/// você não terminou esta corrida) — espelhe o tom.Ajuste 3 — hierarquia visual. Use
btn-secondary/btn-ghost, nãobtn-primary: "Jogar de novo" (l.140) tem de continuar sendo a ação primária; o share é o convite, não o CTA.aria-labelexplícito +whileHover/whileTapidênticos a l.137-138 (ouseReducedMotionde l.30 já está disponível se precisar suprimir). Nenhuma animação nova obrigatória.Nota de tooling:
useToast()funciona dentro deResultsporque a árvore está sob<ToastProvider>emsrc/app/room/[id]/page.tsx:19— mas o provider não está emsrc/app/layout.tsx. Se a tela for reusada fora dessa rota (ex.:/practice, PR feat(practice): rota /practice com Treino Livre solo, sem sala (#25) #40),useToastcai no fallback silencioso (Toast.tsx:48-52) e o AC "toast de confirmação" falha sem erro. Confira antes de declarar verde.Resultsé tela pós-corrida, nada compete com atextarea. Sem lib nova, sem re-render extra relevante (umuseStatede "copiado" no máximo).Plano sugerido:
src/lib/share.ts—buildShareText(me, url = SITE.url): comme.place→Fiz ${Math.round(me.wpm)} WPM (${Math.round(me.accuracy)}%) e terminei em ${me.place}º no CodeRacer 🏁 me alcança: ${url}; semplace→ mesma frase sem o trecho de colocação.src/lib/share.test.ts— casos: com/semplace, arredondamento dewpm/accuracyfracionários,urlcustom, nick com caracteres estranhos.Results.tsx:134-156— dentro deme && (...), botãobtn-secondarycomaria-label="Compartilhar meu resultado"ao lado de l.136-144.const text = buildShareText(me, window.location.origin); tentanavigator.share({ text, url }); nocatch(err)só cai paranavigator.clipboard.writeTextseerr?.name !== "AbortError"; sucesso/erro do clipboard →toast.push(padrãoRoomView.tsx:56-62).pnpm typecheck·pnpm build·pnpm test(com o novoshare.test.ts) · verificação em browser (desktop copy+toast; mobile 375 com sheet nativo, ou o harness da Infra/UI: harness só-de-dev para verificar telas pós-corrida (Results/pódio) sem jogar #37 se ela já estiver namain).caioross commentedon Jul 22, 2026
Resolvido em PR #57 (
auto/issue-22-share-result).Helper puro
buildShareTextemsrc/lib/share.ts+ 9 casos Vitest, eShareButtonsecundário na área de ações doResults.tsx(Web Share API → fallback clipboard + toast). Segui o Parecer do Conselho incluindo os 3 ajustes:AbortErrornão cai no clipboard (cancelar não é falhar), quem não terminou vê o botão com texto sem colocação, ebtn-secondarypara não disputar com "Jogar de novo". Usowindow.location.originno handler —SITE.urlcai num domínio que não é o de produção quando a env falta.Gate verde: typecheck · build · 43 testes · validate-persistence (49) · validate-metrics (37). Verificação em browser não foi possível — a tela só existe após corrida completa, o ponto cego que a #37 resolve; declarado no PR.
feat(results): botão "Compartilhar" fecha o loop viral pós-corrida (#22…