extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.

Add mini board previews using tenuki on games list

- Created MiniBoard.svelte component using tenuki for small board previews
- Added tenuki TypeScript declarations (tenuki.d.ts)
- Integrated mini boards into current games list (both logged-in and spectating views)
- Fetches moves for active games and displays current board state
- Fixed og-image to use correct tenuki API (intersectionAt instead of intersections)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+243 -14
+92
src/lib/components/MiniBoard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import tenuki from 'tenuki'; 4 + 5 + interface Props { 6 + boardSize?: number; 7 + moves?: Array<{ x: number; y: number; color: 'black' | 'white' }>; 8 + size?: number; 9 + } 10 + 11 + let { 12 + boardSize = 19, 13 + moves = [], 14 + size = 70 15 + }: Props = $props(); 16 + 17 + let boardElement: HTMLDivElement; 18 + let game: any = null; 19 + 20 + onMount(() => { 21 + if (!boardElement) return; 22 + 23 + // Add flat styling class for cleaner mini view 24 + boardElement.classList.add('tenuki-board-flat'); 25 + 26 + // Create a tenuki game for display only 27 + game = new tenuki.Game({ 28 + element: boardElement, 29 + boardSize, 30 + }); 31 + 32 + // Replay all moves to show current board state 33 + for (const move of moves) { 34 + // tenuki uses (y, x) coordinates 35 + game.playAt(move.y, move.x); 36 + } 37 + 38 + return () => { 39 + // Cleanup 40 + if (boardElement) { 41 + boardElement.innerHTML = ''; 42 + } 43 + game = null; 44 + }; 45 + }); 46 + </script> 47 + 48 + <div class="mini-board-container" style="width: {size}px; height: {size}px;"> 49 + <div bind:this={boardElement} class="mini-board"></div> 50 + </div> 51 + 52 + <style> 53 + .mini-board-container { 54 + flex-shrink: 0; 55 + overflow: hidden; 56 + border-radius: 4px; 57 + background: #DCB35C; 58 + } 59 + 60 + .mini-board { 61 + width: 100%; 62 + height: 100%; 63 + pointer-events: none; 64 + } 65 + 66 + /* Tenuki sizing overrides for mini display */ 67 + .mini-board :global(.tenuki-inner-container) { 68 + width: 100% !important; 69 + height: 100% !important; 70 + } 71 + 72 + .mini-board :global(.tenuki-zoom-container) { 73 + width: 100% !important; 74 + height: 100% !important; 75 + display: flex; 76 + align-items: center; 77 + justify-content: center; 78 + } 79 + 80 + .mini-board :global(svg) { 81 + max-width: 100% !important; 82 + max-height: 100% !important; 83 + width: auto !important; 84 + height: auto !important; 85 + } 86 + 87 + /* Hide zoom UI elements */ 88 + .mini-board :global(.cancel-zoom), 89 + .mini-board :global(.cancel-zoom-backdrop) { 90 + display: none !important; 91 + } 92 + </style>
+52
src/lib/tenuki.d.ts
··· 1 + declare module 'tenuki' { 2 + interface GameOptions { 3 + element?: HTMLElement; 4 + boardSize?: number; 5 + handicapStones?: number; 6 + freeHandicapPlacement?: boolean; 7 + fuzzyStonePlacement?: boolean; 8 + scoring?: 'area' | 'territory' | 'equivalence'; 9 + komi?: number; 10 + koRule?: 'simple' | 'positional-superko' | 'situational-superko' | 'natural-situational-superko'; 11 + renderer?: 'svg' | 'dom'; 12 + } 13 + 14 + interface Intersection { 15 + value: 'empty' | 'black' | 'white'; 16 + y: number; 17 + x: number; 18 + } 19 + 20 + interface GameState { 21 + pass: boolean; 22 + color: 'black' | 'white'; 23 + playedPoint: { y: number; x: number } | null; 24 + } 25 + 26 + interface Score { 27 + black: number; 28 + white: number; 29 + } 30 + 31 + class Game { 32 + constructor(options?: GameOptions); 33 + playAt(y: number, x: number): boolean; 34 + pass(): void; 35 + undo(): void; 36 + currentPlayer(): 'black' | 'white'; 37 + intersectionAt(y: number, x: number): Intersection; 38 + isOver(): boolean; 39 + currentState(): GameState; 40 + score(): Score; 41 + markDeadAt(y: number, x: number): void; 42 + unmarkDeadAt(y: number, x: number): void; 43 + toggleDeadAt(y: number, x: number): void; 44 + } 45 + 46 + const tenuki: { 47 + Game: typeof Game; 48 + }; 49 + 50 + export default tenuki; 51 + export { Game }; 52 + }
+89 -1
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 3 import { onMount } from 'svelte'; 4 - import { resolveDidToHandle, fetchMoveCount } from '$lib/atproto-client'; 4 + import { resolveDidToHandle, fetchMoveCount, fetchGameActionsFromPds } from '$lib/atproto-client'; 5 + import MiniBoard from '$lib/components/MiniBoard.svelte'; 6 + import type { MoveRecord } from '$lib/types'; 5 7 6 8 let { data }: { data: PageData } = $props(); 7 9 ··· 17 19 let showMyTurnOnly = $state(false); 18 20 let archivePage = $state(1); 19 21 const ARCHIVE_PAGE_SIZE = 6; 22 + 23 + // Store moves for mini board previews 24 + let gameMoves = $state<Record<string, Array<{ x: number; y: number; color: 'black' | 'white' }>>>({}); 25 + let loadingMoves = $state<Record<string, boolean>>({}); 20 26 21 27 // Helper to determine whose turn it is in a game 22 28 function getWhoseTurn(game: typeof data.games[0]): 'black' | 'white' { ··· 105 111 moveCounts = { ...moveCounts, [game.id]: count }; 106 112 }); 107 113 } 114 + 115 + // Fetch moves for active games (for mini board previews) 116 + const activeGames = data.games.filter(g => g.status === 'active'); 117 + for (const game of activeGames) { 118 + fetchMovesForGame(game); 119 + } 108 120 } 109 121 }); 110 122 ··· 177 189 alert('Failed to join game. Please try again.'); 178 190 } 179 191 } 192 + 193 + // Fetch moves for a game to display on mini board 194 + async function fetchMovesForGame(game: typeof data.games[0]) { 195 + if (loadingMoves[game.id] || gameMoves[game.id]) return; 196 + 197 + loadingMoves = { ...loadingMoves, [game.id]: true }; 198 + try { 199 + const { moves } = await fetchGameActionsFromPds( 200 + game.player_one, 201 + game.player_two, 202 + game.id 203 + ); 204 + // Convert to simple format for MiniBoard 205 + const simpleMoves = moves.map((m: MoveRecord) => ({ 206 + x: m.x, 207 + y: m.y, 208 + color: m.color as 'black' | 'white' 209 + })); 210 + gameMoves = { ...gameMoves, [game.id]: simpleMoves }; 211 + } catch (err) { 212 + console.error('Failed to fetch moves for game:', game.id, err); 213 + } finally { 214 + loadingMoves = { ...loadingMoves, [game.id]: false }; 215 + } 216 + } 180 217 </script> 181 218 182 219 <svelte:head> ··· 225 262 <div class="games-list"> 226 263 {#each currentGames as game} 227 264 <div class="game-item"> 265 + {#if gameMoves[game.id]} 266 + <div class="mini-board-wrapper"> 267 + <MiniBoard boardSize={game.board_size} moves={gameMoves[game.id]} /> 268 + </div> 269 + {:else} 270 + <div class="mini-board-placeholder"> 271 + <span class="mini-board-loading">...</span> 272 + </div> 273 + {/if} 228 274 <div class="game-info"> 229 275 <div class="game-title">{game.title}</div> 230 276 <div> ··· 382 428 {@const myTurn = isMyTurn(game)} 383 429 {@const playing = isMyGame(game)} 384 430 <div class="game-item" class:my-turn={myTurn}> 431 + {#if gameMoves[game.id]} 432 + <div class="mini-board-wrapper"> 433 + <MiniBoard boardSize={game.board_size} moves={gameMoves[game.id]} /> 434 + </div> 435 + {:else} 436 + <div class="mini-board-placeholder"> 437 + <span class="mini-board-loading">...</span> 438 + </div> 439 + {/if} 385 440 <div class="game-info"> 386 441 <div class="game-title-row"> 387 442 <span class="game-title">{game.title}</span> ··· 772 827 .game-item.my-turn { 773 828 border-color: var(--sky-apricot); 774 829 background: linear-gradient(135deg, var(--sky-apricot-light) 0%, var(--sky-white) 100%); 830 + } 831 + 832 + .mini-board-wrapper { 833 + flex-shrink: 0; 834 + width: 70px; 835 + height: 70px; 836 + border-radius: 6px; 837 + overflow: hidden; 838 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 839 + border: 1px solid var(--sky-blue-pale); 840 + } 841 + 842 + .mini-board-placeholder { 843 + flex-shrink: 0; 844 + width: 70px; 845 + height: 70px; 846 + border-radius: 6px; 847 + background: linear-gradient(135deg, #DEB887 0%, #C4A067 100%); 848 + display: flex; 849 + align-items: center; 850 + justify-content: center; 851 + border: 1px solid var(--sky-blue-pale); 852 + } 853 + 854 + .mini-board-loading { 855 + color: rgba(92, 72, 48, 0.5); 856 + font-size: 0.75rem; 857 + animation: pulse 1.5s ease-in-out infinite; 858 + } 859 + 860 + @keyframes pulse { 861 + 0%, 100% { opacity: 0.5; } 862 + 50% { opacity: 1; } 775 863 } 776 864 777 865 .game-info {
+10 -13
src/routes/og-image/[id]/+server.ts
··· 62 62 } 63 63 } 64 64 65 - // Extract board state from tenuki 66 - // Note: tenuki returns a FLAT array of intersections with {x, y, value} properties 67 - const state = tenukiGame.currentState(); 68 - console.log(`OG image: Board state intersections:`, state.intersections?.length); 65 + // Extract board state from tenuki using intersectionAt 66 + // Initialize empty 2D board 67 + boardState = Array.from({ length: boardSize }, () => 68 + Array.from({ length: boardSize }, () => null as 'black' | 'white' | null) 69 + ); 69 70 70 - if (state.intersections) { 71 - // Initialize empty 2D board 72 - boardState = Array.from({ length: boardSize }, () => 73 - Array.from({ length: boardSize }, () => null as 'black' | 'white' | null) 74 - ); 75 - 76 - // Fill in the stones from the flat array 77 - for (const intersection of state.intersections) { 71 + // Read each intersection 72 + for (let y = 0; y < boardSize; y++) { 73 + for (let x = 0; x < boardSize; x++) { 74 + const intersection = tenukiGame.intersectionAt(y, x); 78 75 if (intersection.value === 'black' || intersection.value === 'white') { 79 - boardState[intersection.y][intersection.x] = intersection.value; 76 + boardState[y][x] = intersection.value; 80 77 } 81 78 } 82 79 }