From 9058aa32dee2c34154fb0d323cd838b761252020 Mon Sep 17 00:00:00 2001 From: ashtonanderson Date: Sat, 7 Nov 2026 07:05:56 +0000 Subject: [PATCH 1/2] feat(header): a "Sign in with Lichess" button instead of grey "Sign in" text The header's sign-in was small grey text ("Sign in" on a desktop, at the bottom of the phone menu), easy to miss, and didn't say it was Lichess. It's now a bordered "Sign in with Lichess" button with Lichess's logo (LichessSignInButton, shared), in the desktop header and the phone menu. Co-Authored-By: Claude Opus 5.5 --- src/components/Common/Header.tsx | 20 +++++-------- src/components/Common/LichessSignInButton.tsx | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 src/components/Common/LichessSignInButton.tsx diff --git a/src/components/Common/Header.tsx b/src/components/Common/Header.tsx index 286c8013..2f7168c1 100644 --- a/src/components/Common/Header.tsx +++ b/src/components/Common/Header.tsx @@ -1,6 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import Link from 'next/link' +import { LichessSignInButton } from 'src/components/Common/LichessSignInButton' import Image from 'next/image' import { PlayType } from 'src/types' import { useRouter } from 'next/router' @@ -43,7 +44,7 @@ export const Header: React.FC = () => { const [showProfileDropdown, setShowProfileDropdown] = useState(false) const { isMobile, width } = useContext(WindowSizeContext) - const { user, connectLichess, logout } = useContext(AuthContext) + const { user, logout } = useContext(AuthContext) // Get leaderboard status for the logged in user const { status: leaderboardStatus, loading: leaderboardLoading } = @@ -171,12 +172,7 @@ export const Header: React.FC = () => { > settings - + ) @@ -526,12 +522,10 @@ export const Header: React.FC = () => { ) : ( - + )} diff --git a/src/components/Common/LichessSignInButton.tsx b/src/components/Common/LichessSignInButton.tsx new file mode 100644 index 00000000..ee181b03 --- /dev/null +++ b/src/components/Common/LichessSignInButton.tsx @@ -0,0 +1,30 @@ +import { useContext } from 'react' +import { AuthContext } from 'src/contexts/AuthContext' +import { SignInSource } from 'src/lib/analytics' + +interface Props { + source: SignInSource + // A path on this site to come back to; the current page without it. + returnTo?: string + className?: string +} + +/** "Sign in with Lichess", with Lichess's logo. */ +export const LichessSignInButton = ({ source, returnTo, className }: Props) => { + const { connectLichess } = useContext(AuthContext) + return ( + + ) +} From cfb2a785653aadb94c819f29505bc2788ca504dc Mon Sep 17 00:00:00 2001 From: ashtonanderson Date: Sat, 7 Nov 2026 07:05:56 +0000 Subject: [PATCH 2/2] feat(play): after a game, ask guests to sign in with Lichess When a game against Maia ends, a guest now sees, under Analyze game and Play again: "Sign in to keep your rating and games on any device, and to appear on the leaderboard." with a Sign in with Lichess button (source game_over). Each claim is what the backend does: a first Lichess sign-in keeps the guest's account (rating and games), the account is then the same on any device, and the leaderboard lists only Lichess accounts. The sign-in comes back to /play rather than the game's page, which without its game would start a new one. connectLichess takes that optional path. Signed-in players, and pages before the account has loaded, don't see the prompt. Co-Authored-By: Claude Opus 5.5 --- src/components/Play/PlayControls.test.tsx | 39 ++++++++++++++++++++++ src/components/Play/PlayControls.tsx | 22 ++++++++++++- src/contexts/AuthContext.tsx | 40 ++++++++++++++--------- src/lib/analytics.ts | 1 + 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/components/Play/PlayControls.test.tsx b/src/components/Play/PlayControls.test.tsx index ce7bc41a..c53381fc 100644 --- a/src/components/Play/PlayControls.test.tsx +++ b/src/components/Play/PlayControls.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import { Chess } from 'chess.ts' import { GameTree } from 'src/types' import { PlayControls } from './PlayControls' +import { AuthContext } from 'src/contexts/AuthContext' // The components index pulls in modules Jest can't parse (react-markdown); // only the resign dialog is used here. @@ -64,3 +65,41 @@ describe('PlayControls practice buttons', () => { expect(takeBack).not.toHaveBeenCalled() }) }) + +describe('PlayControls sign-in prompt after a game', () => { + const connectLichess = jest.fn() + const renderOver = ( + user: { clientId: string; displayName: string; lichessId?: string } | null, + gameOver = true, + ) => + render( + + + , + ) + + beforeEach(() => connectLichess.mockClear()) + + it('asks a guest to sign in, coming back to /play', () => { + renderOver({ clientId: 'c', displayName: '' }) + fireEvent.click(screen.getByText('Sign in with Lichess')) + expect(connectLichess).toHaveBeenCalledWith('game_over', '/play') + }) + + it('does not ask a signed-in player', () => { + renderOver({ clientId: 'c', displayName: 'alice', lichessId: 'alice' }) + expect(screen.queryByText('Sign in with Lichess')).toBeNull() + }) + + it('does not ask before the account has loaded, or during a game', () => { + renderOver(null) + expect(screen.queryByText('Sign in with Lichess')).toBeNull() + renderOver({ clientId: 'c', displayName: '' }, false) + expect(screen.queryByText('Sign in with Lichess')).toBeNull() + }) +}) diff --git a/src/components/Play/PlayControls.tsx b/src/components/Play/PlayControls.tsx index d30239f5..e09cf738 100644 --- a/src/components/Play/PlayControls.tsx +++ b/src/components/Play/PlayControls.tsx @@ -1,7 +1,9 @@ -import { useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { PlayedGame } from 'src/types' import { ResignationConfirmModal } from 'src/components' +import { AuthContext } from 'src/contexts/AuthContext' +import { LichessSignInButton } from 'src/components/Common/LichessSignInButton' type PracticeAction = 'takeBack' | 'addTime' @@ -94,6 +96,7 @@ export const PlayControls: React.FC = ({ takeBack, addTime, }: Props) => { + const { user } = useContext(AuthContext) const [showResignConfirm, setShowResignConfirm] = useState(false) // Practice actions ask first, so a stray tap doesn't undo a move: the // first tap turns the button into a confirm button, the second acts. @@ -144,6 +147,23 @@ export const PlayControls: React.FC = ({ {returnToLabel} ) : null} + {/* Guests only (`user` is null until the account has loaded). A + first sign-in keeps the guest's rating and games, and only + signed-in players are on the leaderboard. It comes back to + /play: this page, without its game, would start a new one. */} + {user && !user.lichessId ? ( +
+

+ Sign in to keep your rating and games on any device, and to + appear on the leaderboard. +

+ +
+ ) : null} ) : ( <> diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4ee0313c..1786cf47 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -11,7 +11,11 @@ import { registerProperties } from 'src/lib/posthogClient' interface IAuthContext { user: User | null - connectLichess: (source: SignInSource) => void + /** + * Goes to Lichess's sign-in, then back to `returnTo` (a path on this site) + * or, without it, the current page. + */ + connectLichess: (source: SignInSource, returnTo?: string) => void logout: () => Promise } @@ -69,22 +73,26 @@ export const AuthContextProvider: React.FC<{ children: ReactNode }> = ({ getAndSetAccount() }, []) - const connectLichess = useCallback((source: SignInSource) => { - trackLichessConnectionInitiated(source) - try { - window.sessionStorage.setItem( - SIGN_IN_KEY, - JSON.stringify({ source, at: Date.now() }), + const connectLichess = useCallback( + (source: SignInSource, returnTo?: string) => { + trackLichessConnectionInitiated(source) + try { + window.sessionStorage.setItem( + SIGN_IN_KEY, + JSON.stringify({ source, at: Date.now() }), + ) + } catch { + // Not measured, but the sign-in still works. + } + const back = returnTo + ? new URL(returnTo, window.location.origin).toString() + : window.location.toString() + window.location.assign( + connectLichessUrl + '?redirect_url=' + encodeURIComponent(back), ) - } catch { - // Not measured, but the sign-in still works. - } - window.location.assign( - connectLichessUrl + - '?redirect_url=' + - encodeURIComponent(window.location.toString()), - ) - }, []) + }, + [], + ) const logout = useCallback(async () => { trackLoggedOut() diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 5e8cb537..46159b8b 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -587,6 +587,7 @@ export type SignInSource = | 'header' | 'header_menu' | 'error_page' + | 'game_over' | 'auth_prompt' export const trackLichessConnectionInitiated = (source: SignInSource) => {