https://checkmate.social
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Design pass: classic chess club theme + fix SpacetimeDB sync bug

- Replace pure-black neutral palette with dark walnut (wood-*) background,
casino felt green (felt-*) accents, and warm brass gold (gold-*) for branding
- Add Playfair Display (headings) + DM Sans (UI) via Google Fonts
- Subtle repeating-conic-gradient checkerboard on body for chess texture
- LobbyScreen: replace 2x2 button grid with refined vertical menu list
with decorative chess piece icons (knight/bishop/rook/queen per speed)
- Header: gold Playfair Display wordmark with king logo mark
- LoginScreen: large serif title, form in warm dark panel
- GameStatus modal: Playfair Display heading, felt-green CTA button
- PlayerBar/MoveList/ClockDisplay: neutral-* replaced with wood-* tokens
- Fix LoginScreen test: Loading... to proper ellipsis character

Fix: republish SpacetimeDB module to resolve schema/client mismatch.
Server had old 10-field game schema; client bindings expected 13 fields
(timeControlSecs, whiteTimeMs, blackTimeMs added in prior commit but never
published), causing RangeError on every WebSocket message. Document the
three-step sync procedure in CLAUDE.md so it does not recur.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

jcalabro 9a5356ba 0b754936

+257 -148
+26
CLAUDE.md
··· 82 82 - `useTable(tables.foo)` returns `[rows, isLoading]` tuple 83 83 - Never edit files in `client/src/module_bindings/` — they are auto-generated 84 84 85 + ### Keeping server and client in sync 86 + 87 + **Any change to `server/src/index.ts` requires three steps before the client will work:** 88 + 89 + 1. **Republish the module** — the running SpacetimeDB instance must be updated: 90 + ```bash 91 + spacetime publish checkmate --module-path ./server --server local --delete-data -y 92 + ``` 93 + The `--delete-data` flag is required when table schemas change (SpacetimeDB does not 94 + automatically migrate existing rows to a new schema). Skipping republish leaves the 95 + live server running the old schema while the client bindings expect the new one, 96 + producing binary deserialization `RangeError`s on every WebSocket message. 97 + 98 + 2. **Regenerate client bindings** — only needed if the schema actually changed (new/removed 99 + tables or fields, changed reducer signatures): 100 + ```bash 101 + spacetime generate --lang typescript --out-dir client/src/module_bindings --module-path server 102 + ``` 103 + 104 + 3. **Hard-reload the browser** (`Cmd+Shift+R`) — Vite caches pre-bundled modules; a 105 + normal reload can serve stale compiled bindings even after the source files change. 106 + 107 + The symptom of a missed republish or stale browser cache is always the same: 108 + `RangeError: Tried to read N byte(s) at relative offset M, but only K byte(s) remain` 109 + in `spacetimedb.js` on the `parseRowList_fn` / `queryRowsToTableUpdates_fn` call path. 110 + 85 111 ## Domain 86 112 87 113 Production: `checkmate.social`
+77 -28
client/src/app.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap'); 1 2 @import "tailwindcss"; 2 3 3 4 /* 4 - * Checkmate global styles 5 + * Checkmate design system 5 6 * 6 - * We use Tailwind v4 for utility classes. This file holds a small number 7 - * of custom CSS properties and base resets that don't fit neatly into 8 - * utility classes. 7 + * Aesthetic: classic chess club — dark walnut, casino felt green, warm gold. 8 + * Typography: Playfair Display (headings) + DM Sans (UI). 9 9 */ 10 10 11 + @theme { 12 + /* ── Felt green — casino / poker table ─────────────────────────── */ 13 + --color-felt-900: #0e2b1c; 14 + --color-felt-800: #162f20; 15 + --color-felt-700: #1e4530; 16 + --color-felt-600: #2d6242; 17 + --color-felt-500: #3d7a54; 18 + --color-felt-400: #5aad7e; 19 + --color-felt-300: #7dc99f; 20 + 21 + /* ── Gold — brass / warm accent ────────────────────────────────── */ 22 + --color-gold-700: #7a5e1a; 23 + --color-gold-600: #a87c2a; 24 + --color-gold-500: #c9a84c; 25 + --color-gold-400: #dfc06a; 26 + --color-gold-300: #f0dca0; 27 + 28 + /* ── Wood — warm dark background palette ───────────────────────── */ 29 + --color-wood-950: #080604; 30 + --color-wood-900: #0d0a06; 31 + --color-wood-800: #1a1510; 32 + --color-wood-700: #221c15; 33 + --color-wood-600: #2c2418; 34 + --color-wood-500: #4a4035; 35 + --color-wood-400: #7a6858; 36 + --color-wood-300: #a08870; 37 + --color-wood-200: #c4b09a; 38 + --color-wood-100: #f0ebe0; 39 + 40 + /* ── Typography ─────────────────────────────────────────────────── */ 41 + /* Overrides Tailwind's font-serif to use Playfair Display */ 42 + --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; 43 + /* Overrides Tailwind's font-sans to use DM Sans */ 44 + --font-sans: 'DM Sans', system-ui, -apple-system, sans-serif; 45 + } 46 + 47 + /* ── Base ──────────────────────────────────────────────────────────── */ 48 + 49 + html, 50 + body { 51 + /* 52 + * Very subtle checkerboard pattern — references chess without being 53 + * distracting. The two shades are only ~5 lightness points apart so 54 + * it reads as texture, not a visible grid. 55 + */ 56 + background-color: #0d0a06; 57 + background-image: repeating-conic-gradient( 58 + #0d0a06 0% 25%, 59 + #131008 0% 50% 60 + ); 61 + background-size: 48px 48px; 62 + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; 63 + color: #f0ebe0; 64 + } 65 + 66 + /* ── Board colors — warm wood tones ───────────────────────────────── */ 67 + 11 68 :root { 12 - /* Board colors — warm wood tones */ 13 69 --board-light: #f0d9b5; 14 - --board-dark: #b58863; 70 + --board-dark: #b58863; 15 71 16 - /* Accent */ 17 - --accent: #7c3aed; 18 - --accent-hover: #6d28d9; 72 + /* Accent forwarded to SpacetimeDB highlight helpers */ 73 + --accent: #2d6242; 74 + --accent-hover: #1e4530; 19 75 20 - /* Game status highlights */ 21 - --highlight-move: rgba(255, 255, 0, 0.35); 22 - --highlight-check: rgba(255, 0, 0, 0.45); 23 - --highlight-legal: rgba(0, 0, 0, 0.1); 24 - --highlight-legal-capture: rgba(0, 0, 0, 0.1); 76 + /* In-game square highlights */ 77 + --highlight-move: rgba(255, 255, 0, 0.35); 78 + --highlight-check: rgba(255, 0, 0, 0.45); 79 + --highlight-legal: rgba( 0, 0, 0, 0.10); 80 + --highlight-legal-capture: rgba( 0, 0, 0, 0.10); 25 81 } 26 82 27 - /* Smooth scrolling on the move list */ 83 + /* ── Move list ─────────────────────────────────────────────────────── */ 84 + 28 85 .move-list { 29 86 scroll-behavior: smooth; 30 87 } 31 88 32 - /* Pulse animation for "searching for opponent" */ 89 + /* ── Searching indicator ───────────────────────────────────────────── */ 90 + 33 91 @keyframes pulse-ring { 34 - 0% { 35 - transform: scale(0.9); 36 - opacity: 0.7; 37 - } 38 - 50% { 39 - transform: scale(1.1); 40 - opacity: 0.3; 41 - } 42 - 100% { 43 - transform: scale(0.9); 44 - opacity: 0.7; 45 - } 92 + 0% { transform: scale(0.9); opacity: 0.7; } 93 + 50% { transform: scale(1.1); opacity: 0.3; } 94 + 100% { transform: scale(0.9); opacity: 0.7; } 46 95 } 47 96 48 97 .animate-pulse-ring {
+45 -41
client/src/components/auth/LoginScreen.tsx
··· 29 29 if (isLoading) { 30 30 return ( 31 31 <div className="flex min-h-screen items-center justify-center"> 32 - <div className="text-neutral-400">Loading...</div> 32 + <div className="text-wood-400">Loading…</div> 33 33 </div> 34 34 ); 35 35 } 36 36 37 37 return ( 38 38 <div className="flex min-h-screen flex-col items-center justify-center px-4"> 39 - <div className="w-full max-w-sm space-y-8"> 39 + <div className="w-full max-w-sm"> 40 + 40 41 {/* Logo / Title */} 41 - <div className="text-center"> 42 - <h1 className="text-4xl font-bold tracking-tight text-white"> 42 + <div className="mb-10 text-center"> 43 + <div className="mb-3 select-none text-6xl text-gold-500">♔</div> 44 + <h1 className="font-serif text-5xl font-bold tracking-tight text-gold-400"> 43 45 Checkmate 44 46 </h1> 45 - <p className="mt-2 text-sm text-neutral-400"> 47 + <p className="mt-3 text-sm tracking-wide text-wood-300"> 46 48 Real-time chess on the AT Protocol 47 49 </p> 48 50 </div> 49 51 50 52 {/* Login Form */} 51 - <form onSubmit={handleSubmit} className="space-y-4"> 52 - <div> 53 - <label 54 - htmlFor="handle" 55 - className="block text-sm font-medium text-neutral-300" 56 - > 57 - Bluesky Handle 58 - </label> 59 - <input 60 - id="handle" 61 - type="text" 62 - value={handle} 63 - onChange={(e) => setHandle(e.target.value)} 64 - placeholder="alice.bsky.social" 65 - autoComplete="username" 66 - autoFocus 67 - disabled={isSubmitting} 68 - className="mt-1 block w-full rounded-lg border border-neutral-700 bg-neutral-900 px-4 py-3 text-white placeholder-neutral-500 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50" 69 - /> 70 - </div> 71 - 72 - {error && ( 73 - <div className="rounded-lg bg-red-900/50 px-4 py-3 text-sm text-red-300"> 74 - {error} 53 + <div className="rounded-xl border border-wood-600 bg-wood-800 p-7 shadow-2xl"> 54 + <form onSubmit={handleSubmit} className="space-y-4"> 55 + <div> 56 + <label 57 + htmlFor="handle" 58 + className="mb-1.5 block text-sm font-medium text-wood-200" 59 + > 60 + Bluesky Handle 61 + </label> 62 + <input 63 + id="handle" 64 + type="text" 65 + value={handle} 66 + onChange={(e) => setHandle(e.target.value)} 67 + placeholder="alice.bsky.social" 68 + autoComplete="username" 69 + autoFocus 70 + disabled={isSubmitting} 71 + className="block w-full rounded-lg border border-wood-600 bg-wood-900 px-4 py-3 text-wood-100 placeholder-wood-500 transition-colors focus:border-felt-500 focus:outline-none focus:ring-1 focus:ring-felt-500 disabled:opacity-50" 72 + /> 75 73 </div> 76 - )} 74 + 75 + {error && ( 76 + <div className="rounded-lg border border-red-800/50 bg-red-900/40 px-4 py-3 text-sm text-red-300"> 77 + {error} 78 + </div> 79 + )} 77 80 78 - <button 79 - type="submit" 80 - disabled={!handle.trim() || isSubmitting} 81 - className="w-full rounded-lg bg-violet-600 px-4 py-3 font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50" 82 - > 83 - {isSubmitting ? 'Redirecting...' : 'Sign in with Bluesky'} 84 - </button> 85 - </form> 81 + <button 82 + type="submit" 83 + disabled={!handle.trim() || isSubmitting} 84 + className="mt-1 w-full rounded-lg bg-felt-600 px-4 py-3 font-medium text-white transition-colors hover:bg-felt-700 focus:outline-none focus:ring-2 focus:ring-felt-500 focus:ring-offset-2 focus:ring-offset-wood-800 disabled:cursor-not-allowed disabled:opacity-50" 85 + > 86 + {isSubmitting ? 'Redirecting…' : 'Sign in with Bluesky'} 87 + </button> 88 + </form> 89 + </div> 86 90 87 - <p className="text-center text-xs text-neutral-500"> 91 + <p className="mt-6 text-center text-xs text-wood-500"> 88 92 Powered by{' '} 89 - <span className="text-neutral-400">SpacetimeDB</span> 93 + <span className="text-wood-400">SpacetimeDB</span> 90 94 {' + '} 91 - <span className="text-neutral-400">AT Protocol</span> 95 + <span className="text-wood-400">AT Protocol</span> 92 96 </p> 93 97 </div> 94 98 </div>
+1 -1
client/src/components/auth/__tests__/LoginScreen.test.tsx
··· 41 41 42 42 it('shows loading state during init', () => { 43 43 renderWithAuth({ isLoading: true }); 44 - expect(screen.getByText('Loading...')).toBeInTheDocument(); 44 + expect(screen.getByText('Loading…')).toBeInTheDocument(); 45 45 }); 46 46 47 47 it('disables button when handle is empty', () => {
+5 -5
client/src/components/game/ClockDisplay.tsx
··· 76 76 return ( 77 77 <div 78 78 className={[ 79 - 'font-mono font-bold tabular-nums text-2xl px-3 py-0.5 rounded transition-colors', 79 + 'rounded px-3 py-0.5 font-mono text-2xl font-bold tabular-nums transition-colors', 80 80 !isActive 81 - ? 'text-neutral-600' 81 + ? 'text-wood-500' 82 82 : isCritical 83 - ? 'text-red-400 animate-pulse' 83 + ? 'animate-pulse text-red-400' 84 84 : isLow 85 85 ? 'text-yellow-400' 86 86 : isRunning 87 - ? 'text-white' 88 - : 'text-neutral-400', 87 + ? 'text-wood-100' 88 + : 'text-wood-400', 89 89 ].join(' ')} 90 90 aria-label={`${isRunning ? 'Running' : 'Paused'}: ${formatClockMs(displayMs)}`} 91 91 >
+9 -9
client/src/components/game/GameScreen.tsx
··· 177 177 ) : undefined; 178 178 179 179 return ( 180 - <div className="flex flex-1 flex-col lg:flex-row items-center justify-center gap-4 p-4"> 180 + <div className="flex flex-1 flex-col items-center justify-center gap-4 p-4 lg:flex-row"> 181 181 {/* Board + player bars column */} 182 - <div className="flex flex-col gap-2 w-full max-w-[600px] relative"> 182 + <div className="relative flex w-full max-w-[600px] flex-col gap-2"> 183 183 {/* Opponent (top) */} 184 184 <PlayerBar 185 185 displayName={isSolo ? (topColor === 'white' ? 'White' : 'Black') : opponentName} ··· 223 223 224 224 {/* Resign button — multiplayer only; resigning against yourself makes no sense */} 225 225 {activeGame.status === 'active' && !isSolo && ( 226 - <div className="flex justify-center mt-2"> 226 + <div className="mt-1 flex justify-center"> 227 227 {showResignConfirm ? ( 228 228 <div className="flex items-center gap-2"> 229 - <span className="text-sm text-neutral-400">Resign?</span> 229 + <span className="text-sm text-wood-400">Resign?</span> 230 230 <button 231 231 onClick={handleResign} 232 - className="rounded px-3 py-1 text-sm bg-red-600 text-white hover:bg-red-700" 232 + className="rounded px-3 py-1 text-sm bg-red-700 text-white transition-colors hover:bg-red-800" 233 233 > 234 234 Yes 235 235 </button> 236 236 <button 237 237 onClick={() => setShowResignConfirm(false)} 238 - className="rounded px-3 py-1 text-sm border border-neutral-600 text-neutral-300 hover:border-neutral-400" 238 + className="rounded border border-wood-600 px-3 py-1 text-sm text-wood-300 transition-colors hover:border-wood-500 hover:text-wood-100" 239 239 > 240 240 No 241 241 </button> ··· 243 243 ) : ( 244 244 <button 245 245 onClick={() => setShowResignConfirm(true)} 246 - className="rounded px-4 py-1.5 text-sm text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors" 246 + className="rounded px-4 py-1.5 text-sm text-wood-500 transition-colors hover:bg-wood-700 hover:text-wood-300" 247 247 > 248 248 Resign 249 249 </button> ··· 253 253 </div> 254 254 255 255 {/* Move list sidebar */} 256 - <div className="w-full lg:w-56 lg:h-[500px] h-32 rounded-lg bg-neutral-900 border border-neutral-800 p-3 overflow-hidden"> 257 - <h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2"> 256 + <div className="h-32 w-full overflow-hidden rounded-xl border border-wood-600 bg-wood-800 p-3 shadow-lg lg:h-[500px] lg:w-56"> 257 + <h3 className="mb-2 text-xs font-medium uppercase tracking-widest text-wood-500"> 258 258 Moves 259 259 </h3> 260 260 <div className="h-[calc(100%-2rem)]">
+8 -6
client/src/components/game/GameStatus.tsx
··· 86 86 } 87 87 88 88 return ( 89 - <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 backdrop-blur-sm"> 90 - <div className="mx-4 w-full max-w-xs rounded-xl bg-neutral-900 border border-neutral-700 p-6 text-center shadow-2xl"> 91 - <div className="text-4xl mb-3">{emoji}</div> 92 - <h2 className="text-xl font-bold text-white">{title}</h2> 93 - <p className="mt-1 text-sm text-neutral-400">{subtitle}</p> 89 + <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm"> 90 + <div className="mx-4 w-full max-w-xs rounded-xl border border-wood-600 bg-wood-800 p-7 text-center shadow-2xl"> 91 + <div className="mb-4 select-none text-5xl">{emoji}</div> 92 + <h2 className="font-serif text-2xl font-bold text-wood-100">{title}</h2> 93 + {subtitle && ( 94 + <p className="mt-2 text-sm text-wood-400">{subtitle}</p> 95 + )} 94 96 <button 95 97 onClick={onBackToLobby} 96 - className="mt-5 w-full rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-violet-700" 98 + className="mt-6 w-full rounded-lg bg-felt-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-felt-700 focus:outline-none focus:ring-2 focus:ring-felt-500 focus:ring-offset-2 focus:ring-offset-wood-800" 97 99 > 98 100 Back to Lobby 99 101 </button>
+5 -5
client/src/components/game/MoveList.tsx
··· 28 28 29 29 if (moves.length === 0) { 30 30 return ( 31 - <div className="flex h-full items-center justify-center text-sm text-neutral-500"> 31 + <div className="flex h-full items-center justify-center text-sm text-wood-500"> 32 32 No moves yet 33 33 </div> 34 34 ); ··· 50 50 {pairs.map((pair) => ( 51 51 <div 52 52 key={pair.number} 53 - className="flex items-center gap-1 text-sm font-mono" 53 + className="flex items-center gap-1 font-mono text-sm" 54 54 > 55 - <span className="w-8 text-right text-neutral-500"> 55 + <span className="w-8 text-right text-wood-500"> 56 56 {pair.number}. 57 57 </span> 58 - <span className="w-16 text-center text-neutral-200"> 58 + <span className="w-16 text-center text-wood-200"> 59 59 {pair.white} 60 60 </span> 61 - <span className="w-16 text-center text-neutral-200"> 61 + <span className="w-16 text-center text-wood-200"> 62 62 {pair.black ?? ''} 63 63 </span> 64 64 </div>
+9 -9
client/src/components/game/PlayerBar.tsx
··· 29 29 <div 30 30 className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors ${ 31 31 isActive 32 - ? 'bg-neutral-800 border border-violet-500/50' 33 - : 'bg-neutral-900 border border-transparent' 32 + ? 'border border-felt-600/60 bg-wood-700' 33 + : 'border border-transparent bg-wood-800' 34 34 }`} 35 35 > 36 - {/* Avatar or color indicator */} 36 + {/* Avatar or color-coded piece indicator */} 37 37 {avatarUrl ? ( 38 38 <img 39 39 src={avatarUrl} 40 40 alt={displayName} 41 - className="h-8 w-8 rounded-full" 41 + className="h-8 w-8 rounded-full ring-1 ring-wood-600" 42 42 /> 43 43 ) : ( 44 44 <div 45 45 className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ${ 46 46 color === 'white' 47 - ? 'bg-neutral-200 text-neutral-800' 48 - : 'bg-neutral-700 text-neutral-200' 47 + ? 'bg-wood-200 text-wood-800' 48 + : 'bg-wood-700 text-wood-200' 49 49 }`} 50 50 > 51 51 {color === 'white' ? '♔' : '♚'} ··· 53 53 )} 54 54 55 55 <div className="min-w-0 flex-1"> 56 - <div className="truncate text-sm font-medium text-white"> 56 + <div className="truncate text-sm font-medium text-wood-100"> 57 57 {displayName} 58 58 </div> 59 59 {handle && handle !== displayName && ( 60 - <div className="truncate text-xs text-neutral-500">@{handle}</div> 60 + <div className="truncate text-xs text-wood-500">@{handle}</div> 61 61 )} 62 62 </div> 63 63 ··· 66 66 67 67 {/* Turn indicator */} 68 68 {isActive && ( 69 - <div className="h-2.5 w-2.5 rounded-full bg-green-500 animate-pulse" /> 69 + <div className="h-2 w-2 animate-pulse rounded-full bg-felt-400" /> 70 70 )} 71 71 </div> 72 72 );
+11 -8
client/src/components/layout/Header.tsx
··· 1 1 /** 2 - * Header — top navigation bar with user info and connection status. 2 + * Header — top navigation bar with branding, user info, and connection status. 3 3 */ 4 4 5 5 import { useAuth } from '../../hooks/useAuth'; ··· 10 10 const { connected } = useGame(); 11 11 12 12 return ( 13 - <header className="flex items-center justify-between border-b border-neutral-800 px-4 py-3"> 14 - <div className="flex items-center gap-3"> 15 - <h1 className="text-lg font-bold tracking-tight text-white"> 13 + <header className="flex items-center justify-between border-b border-wood-700 bg-wood-900/90 px-5 py-3 backdrop-blur-sm"> 14 + {/* Branding */} 15 + <div className="flex items-center gap-2.5"> 16 + <span className="select-none text-lg text-gold-500">♔</span> 17 + <h1 className="font-serif text-xl font-bold tracking-tight text-gold-400"> 16 18 Checkmate 17 19 </h1> 18 20 <div 19 - className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} 21 + className={`ml-0.5 h-1.5 w-1.5 rounded-full transition-colors ${connected ? 'bg-felt-500' : 'bg-red-500'}`} 20 22 title={connected ? 'Connected' : 'Disconnected'} 21 23 /> 22 24 </div> 23 25 26 + {/* User controls */} 24 27 {handle && ( 25 28 <div className="flex items-center gap-3"> 26 29 <div className="flex items-center gap-2"> ··· 28 31 <img 29 32 src={avatarUrl} 30 33 alt={displayName ?? handle} 31 - className="h-7 w-7 rounded-full" 34 + className="h-7 w-7 rounded-full ring-1 ring-wood-600" 32 35 /> 33 36 )} 34 - <span className="text-sm text-neutral-300"> 37 + <span className="text-sm text-wood-300"> 35 38 {displayName ?? handle} 36 39 </span> 37 40 </div> 38 41 <button 39 42 onClick={logout} 40 - className="rounded px-2 py-1 text-xs text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200" 43 + className="rounded px-2 py-1 text-xs text-wood-500 transition-colors hover:bg-wood-700 hover:text-wood-200" 41 44 > 42 45 Sign out 43 46 </button>
+61 -36
client/src/components/lobby/LobbyScreen.tsx
··· 1 1 /** 2 2 * LobbyScreen — matchmaking interface. 3 3 * 4 - * Shows four time-control buttons (1', 3', 5', 10') for real matchmaking, 5 - * each with its own queue pool, and a "Play vs Self" button for untimed solo 4 + * Shows four time-control options (1', 3', 5', 10') for real matchmaking, 5 + * each with its own queue pool, and a "Play vs Self" option for untimed solo 6 6 * play. While searching, displays an animated indicator with the selected 7 7 * time control and a cancel option. 8 8 * ··· 12 12 13 13 import { useGame } from '../../hooks/useGame'; 14 14 15 - /** Available time controls in seconds per side */ 15 + /** 16 + * Available time controls. 17 + * 18 + * The chess piece is decorative — each speed category gets a piece that 19 + * loosely maps to its character: the agile knight for bullet, the sharp 20 + * bishop for blitz, the commanding queen for rapid. 21 + */ 16 22 const TIME_CONTROLS = [ 17 - { secs: 60, label: '1 min', sublabel: 'Bullet' }, 18 - { secs: 180, label: '3 min', sublabel: 'Blitz' }, 19 - { secs: 300, label: '5 min', sublabel: 'Blitz' }, 20 - { secs: 600, label: '10 min', sublabel: 'Rapid' }, 23 + { secs: 60, label: '1 min', category: 'Bullet', piece: '♞' }, 24 + { secs: 180, label: '3 min', category: 'Blitz', piece: '♝' }, 25 + { secs: 300, label: '5 min', category: 'Blitz', piece: '♜' }, 26 + { secs: 600, label: '10 min', category: 'Rapid', piece: '♛' }, 21 27 ] as const; 22 28 23 29 function formatTimeControl(secs: number): string { 24 - return `${secs / 60} min`; 30 + return secs < 60 ? `${secs}s` : `${secs / 60} min`; 25 31 } 26 32 27 33 export function LobbyScreen() { ··· 32 38 <div className="flex flex-1 flex-col items-center justify-center gap-8"> 33 39 {/* Animated searching indicator */} 34 40 <div className="relative flex items-center justify-center"> 35 - <div className="animate-pulse-ring h-32 w-32 rounded-full border-2 border-violet-500/30" /> 36 - <div className="absolute text-4xl">♟</div> 41 + <div className="animate-pulse-ring h-28 w-28 rounded-full border-2 border-felt-500/50" /> 42 + <div className="absolute select-none text-4xl text-wood-200">♛</div> 37 43 </div> 38 44 39 45 <div className="text-center"> 40 - <p className="text-lg font-medium text-white"> 41 - Searching for opponent... 46 + <p className="font-serif text-xl font-semibold text-wood-100"> 47 + Searching for opponent… 42 48 </p> 43 - <p className="mt-1 text-sm text-neutral-400"> 49 + <p className="mt-1.5 text-sm text-wood-400"> 44 50 {formatTimeControl(queuedTimeControlSecs)} per side 45 51 </p> 46 52 </div> 47 53 48 54 <button 49 55 onClick={leaveQueue} 50 - className="rounded-lg border border-neutral-700 px-6 py-2 text-sm text-neutral-300 transition-colors hover:border-neutral-500 hover:text-white" 56 + className="rounded-xl border border-wood-600 px-7 py-2 text-sm text-wood-300 transition-colors hover:border-wood-500 hover:text-wood-100" 51 57 > 52 58 Cancel 53 59 </button> ··· 56 62 } 57 63 58 64 return ( 59 - <div className="flex flex-1 flex-col items-center justify-center gap-8"> 60 - <div className="text-center"> 61 - <div className="text-6xl mb-4">♟</div> 62 - <h2 className="text-2xl font-bold text-white">Ready to play?</h2> 63 - <p className="mt-2 text-neutral-400"> 64 - Choose a time control to find an opponent 65 - </p> 66 - </div> 65 + <div className="flex flex-1 flex-col items-center justify-center px-4"> 66 + <div className="w-full max-w-xs"> 67 + 68 + {/* Heading */} 69 + <div className="mb-7 text-center"> 70 + <h2 className="font-serif text-3xl font-bold text-wood-100"> 71 + Ready to play? 72 + </h2> 73 + <p className="mt-2 text-sm text-wood-400"> 74 + Choose a time control to find an opponent 75 + </p> 76 + </div> 67 77 68 - {/* Time control grid */} 69 - <div className="flex flex-col gap-3 w-full max-w-xs"> 70 - <div className="grid grid-cols-2 gap-3"> 71 - {TIME_CONTROLS.map(({ secs, label, sublabel }) => ( 78 + {/* Time control list — menu-style, not big tiles */} 79 + <div className="overflow-hidden rounded-xl border border-wood-600 bg-wood-800 shadow-xl"> 80 + {TIME_CONTROLS.map(({ secs, label, category, piece }, i) => ( 72 81 <button 73 82 key={secs} 74 83 onClick={() => joinQueue({ timeControlSecs: secs })} 75 84 disabled={!connected} 76 - className="flex flex-col items-center rounded-lg bg-violet-600 px-4 py-3 font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50" 85 + className={[ 86 + 'group flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors', 87 + 'hover:bg-felt-700/25 focus:bg-felt-700/20 focus:outline-none', 88 + 'disabled:cursor-not-allowed disabled:opacity-40', 89 + i < TIME_CONTROLS.length - 1 ? 'border-b border-wood-700' : '', 90 + ].join(' ')} 77 91 > 78 - <span className="text-lg">{label}</span> 79 - <span className="text-xs text-violet-300">{sublabel}</span> 92 + {/* Chess piece icon — changes color on hover */} 93 + <span className="w-6 select-none text-center text-lg text-wood-500 transition-colors group-hover:text-felt-400"> 94 + {piece} 95 + </span> 96 + 97 + {/* Category name */} 98 + <span className="flex-1 text-sm font-medium text-wood-100"> 99 + {category} 100 + </span> 101 + 102 + {/* Time */} 103 + <span className="font-mono text-sm text-wood-400">{label}</span> 80 104 </button> 81 105 ))} 82 106 </div> 83 107 108 + {/* Play vs Self — secondary option */} 84 109 <button 85 110 onClick={createSoloGame} 86 111 disabled={!connected} 87 - className="rounded-lg border border-neutral-700 px-8 py-3 text-sm font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" 112 + className="mt-3 w-full rounded-xl border border-wood-600 bg-wood-800 px-5 py-3 text-sm text-wood-300 shadow-xl transition-colors hover:border-wood-500 hover:bg-wood-700 hover:text-wood-100 disabled:cursor-not-allowed disabled:opacity-40" 88 113 > 89 114 Play vs Self 90 115 </button> 91 - </div> 92 116 93 - {!connected && ( 94 - <p className="text-sm text-yellow-500"> 95 - Connecting to server... 96 - </p> 97 - )} 117 + {!connected && ( 118 + <p className="mt-4 text-center text-xs text-gold-600"> 119 + Connecting to server… 120 + </p> 121 + )} 122 + </div> 98 123 </div> 99 124 ); 100 125 }