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.
0
fork

Configure Feed

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

Add tutorial popup, footer, rules page with interactive diagrams

- Add TutorialPopup component with multi-step wizard and SGF-based diagrams
- Add Footer with Claude logo and AI transparency modal
- Create Rules page with interactive move navigation
- Add SGF parser utility for loading game diagrams
- Make Board component reactive to gameState changes
- Add modal/footer CSS styling
- Include SGF files for tutorial examples (single capture, group capture, scoring)

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

+1534
+193
src/app.css
··· 260 260 button { 261 261 font-family: inherit; 262 262 } 263 + 264 + /* Modal styles */ 265 + .modal-overlay { 266 + position: fixed; 267 + top: 0; 268 + left: 0; 269 + width: 100%; 270 + height: 100%; 271 + background: rgba(90, 122, 144, 0.3); 272 + backdrop-filter: blur(4px); 273 + z-index: 1000; 274 + display: flex; 275 + align-items: center; 276 + justify-content: center; 277 + padding: 1rem; 278 + animation: fadeIn 0.2s ease-out; 279 + } 280 + 281 + .modal-content { 282 + max-width: 650px; 283 + width: 100%; 284 + max-height: 90vh; 285 + overflow: visible; 286 + animation: slideIn 0.3s ease-out; 287 + display: flex; 288 + flex-direction: column; 289 + } 290 + 291 + .modal-header { 292 + display: flex; 293 + justify-content: space-between; 294 + align-items: center; 295 + margin-bottom: 1.5rem; 296 + padding-bottom: 1rem; 297 + border-bottom: 1px solid var(--color-border); 298 + } 299 + 300 + .modal-header h2 { 301 + margin: 0; 302 + color: var(--color-text); 303 + font-size: 1.5rem; 304 + } 305 + 306 + .modal-close { 307 + background: none; 308 + border: none; 309 + font-size: 1.5rem; 310 + color: var(--color-text-muted); 311 + cursor: pointer; 312 + padding: 0.25rem 0.5rem; 313 + transition: color 0.2s; 314 + } 315 + 316 + .modal-close:hover { 317 + color: var(--color-text); 318 + } 319 + 320 + .modal-body { 321 + margin-bottom: 1.5rem; 322 + color: var(--color-text); 323 + line-height: 1.8; 324 + } 325 + 326 + .modal-footer { 327 + display: flex; 328 + justify-content: space-between; 329 + align-items: center; 330 + gap: 1rem; 331 + padding-top: 1.5rem; 332 + margin-top: 1.5rem; 333 + border-top: 1px solid var(--color-border); 334 + } 335 + 336 + .modal-footer button { 337 + padding: 0.75rem 1.5rem; 338 + border: none; 339 + border-radius: 0.5rem; 340 + cursor: pointer; 341 + font-size: 1rem; 342 + transition: all 0.2s; 343 + font-weight: 500; 344 + } 345 + 346 + .modal-footer .btn-primary { 347 + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%); 348 + color: white; 349 + box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); 350 + } 351 + 352 + .modal-footer .btn-primary:hover { 353 + transform: translateY(-1px); 354 + box-shadow: 0 4px 12px rgba(242, 197, 160, 0.4); 355 + } 356 + 357 + .modal-footer .btn-secondary { 358 + background: var(--color-bg-card); 359 + color: var(--color-text); 360 + border: 1px solid var(--color-border); 361 + } 362 + 363 + .modal-footer .btn-secondary:hover { 364 + background: var(--color-border); 365 + } 366 + 367 + .modal-progress { 368 + text-align: center; 369 + margin-bottom: 1rem; 370 + color: var(--color-text-muted); 371 + font-size: 0.875rem; 372 + } 373 + 374 + /* Footer styles */ 375 + .site-footer { 376 + position: relative; 377 + margin-top: 4rem; 378 + padding: 2rem 1rem; 379 + text-align: center; 380 + color: var(--color-text-muted); 381 + font-size: 0.9rem; 382 + z-index: 1; 383 + } 384 + 385 + .site-footer a { 386 + color: var(--color-link); 387 + text-decoration: none; 388 + transition: color 0.2s; 389 + } 390 + 391 + .site-footer a:hover { 392 + color: var(--color-link-hover); 393 + text-decoration: underline; 394 + } 395 + 396 + .footer-links { 397 + display: flex; 398 + justify-content: center; 399 + gap: 2rem; 400 + margin-bottom: 1rem; 401 + flex-wrap: wrap; 402 + } 403 + 404 + .footer-links button, 405 + .footer-links a { 406 + background: none; 407 + border: none; 408 + cursor: pointer; 409 + color: var(--color-link); 410 + font-size: 0.9rem; 411 + padding: 0.5rem 1rem; 412 + transition: all 0.2s; 413 + } 414 + 415 + .footer-links button:hover, 416 + .footer-links a:hover { 417 + color: var(--color-link-hover); 418 + } 419 + 420 + .footer-credit { 421 + margin-top: 0.5rem; 422 + } 423 + 424 + /* Tenuki board container */ 425 + .tenuki-board { 426 + margin: 1.5rem auto; 427 + display: flex; 428 + justify-content: center; 429 + } 430 + 431 + .tenuki-board canvas { 432 + border-radius: 0.5rem; 433 + box-shadow: 0 2px 8px rgba(90, 122, 144, 0.15); 434 + } 435 + 436 + /* Animations */ 437 + @keyframes fadeIn { 438 + from { 439 + opacity: 0; 440 + } 441 + to { 442 + opacity: 1; 443 + } 444 + } 445 + 446 + @keyframes slideIn { 447 + from { 448 + opacity: 0; 449 + transform: translateY(-20px); 450 + } 451 + to { 452 + opacity: 1; 453 + transform: translateY(0); 454 + } 455 + }
+63
src/lib/components/Board.svelte
··· 124 124 }); 125 125 } 126 126 127 + // Initialize board once 127 128 $effect(() => { 128 129 if (!boardElement) return; 129 130 ··· 244 245 return () => { 245 246 isReady = false; 246 247 }; 248 + }); 249 + 250 + // React to gameState changes without recreating the entire board 251 + $effect(() => { 252 + // Only react to gameState.moves changes 253 + const moves = gameState?.moves; 254 + 255 + untrack(() => { 256 + if (!board || !canvas || !isReady) return; 257 + 258 + // Clear all marks first 259 + if (lastMarkedCoord) { 260 + board.setMark(lastMarkedCoord, JGO.MARK.NONE); 261 + lastMarkedCoord = null; 262 + } 263 + 264 + // Clear the board 265 + for (let y = 0; y < board.height; y++) { 266 + for (let x = 0; x < board.width; x++) { 267 + const coord = new JGO.Coordinate(x, y); 268 + board.setType(coord, JGO.CLEAR); 269 + board.setMark(coord, JGO.MARK.NONE); 270 + } 271 + } 272 + 273 + // Reset ko 274 + ko = false; 275 + 276 + // Replay all moves from gameState 277 + if (moves && moves.length > 0) { 278 + moves.forEach((move: any, index: number) => { 279 + try { 280 + const coord = new JGO.Coordinate(move.x, move.y); 281 + const type = move.color === 'black' ? JGO.BLACK : JGO.WHITE; 282 + 283 + // Play the move through the game engine to handle captures 284 + const play = board.playMove(coord, type, ko); 285 + if (play.success) { 286 + board.setType(coord, type); 287 + 288 + // Remove captured stones 289 + if (play.captures.length > 0) { 290 + for (const capture of play.captures) { 291 + board.setType(capture, JGO.CLEAR); 292 + } 293 + } 294 + 295 + // Update ko point 296 + ko = play.ko; 297 + } 298 + 299 + // Mark the last move with a circle 300 + if (index === moves.length - 1) { 301 + board.setMark(coord, JGO.MARK.CIRCLE); 302 + lastMarkedCoord = coord; 303 + } 304 + } catch (err) { 305 + console.error('Error restoring move:', err); 306 + } 307 + }); 308 + } 309 + }); 247 310 }); 248 311 249 312 function handleCanvasClick(coord: any) {
+158
src/lib/components/Footer.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + onReplayTutorial?: () => void; 4 + } 5 + 6 + let { onReplayTutorial = () => {} }: Props = $props(); 7 + let showClaudeModal = $state(false); 8 + </script> 9 + 10 + <footer class="site-footer"> 11 + <div class="footer-links"> 12 + <button onclick={onReplayTutorial}> 13 + Replay Tutorial 14 + </button> 15 + <a href="/rules">Rules</a> 16 + </div> 17 + <div class="footer-credit"> 18 + Made with ❤️ by <a href="https://goose.business/" target="_blank" rel="noopener noreferrer">Business Goose</a> with help from 19 + <button class="claude-link" onclick={() => showClaudeModal = true}> 20 + <svg class="claude-logo" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"> 21 + <path d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z" fill="#D97757"/> 22 + </svg> 23 + <span style="color: #D97757">Claude</span> 24 + </button> 25 + </div> 26 + </footer> 27 + 28 + {#if showClaudeModal} 29 + <div class="modal-overlay" onclick={() => showClaudeModal = false} role="button" tabindex="0"> 30 + <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true"> 31 + <div class="modal-inner"> 32 + <div class="modal-header"> 33 + <h2>About AI in Cloud Go</h2> 34 + <button class="modal-close" onclick={() => showClaudeModal = false} aria-label="Close">×</button> 35 + </div> 36 + <div class="modal-body"> 37 + <p> 38 + I know that AI is a hot issue right now, and I completely understand where the skeptics are coming from. 39 + This is my first time experimenting with making apps this way and thought it was a cool thing I wanted to share. 40 + </p> 41 + <p> 42 + I understand if that's a dealbreaker for you and you want to avoid using it. 43 + </p> 44 + </div> 45 + <div class="modal-footer"> 46 + <button class="btn-primary" onclick={() => showClaudeModal = false}> 47 + Got it 48 + </button> 49 + </div> 50 + </div> 51 + </div> 52 + </div> 53 + {/if} 54 + 55 + <style> 56 + .footer-credit { 57 + display: flex; 58 + align-items: center; 59 + justify-content: center; 60 + gap: 0.25rem; 61 + flex-wrap: wrap; 62 + } 63 + 64 + .claude-link { 65 + background: none; 66 + border: none; 67 + padding: 0; 68 + cursor: pointer; 69 + color: var(--color-link); 70 + font-size: inherit; 71 + display: inline-flex; 72 + align-items: center; 73 + gap: 0.35rem; 74 + transition: all 0.2s; 75 + text-decoration: none; 76 + } 77 + 78 + .claude-link:hover { 79 + color: var(--color-link-hover); 80 + transform: translateY(-1px); 81 + } 82 + 83 + .claude-logo { 84 + width: 1.1em; 85 + height: 1.1em; 86 + display: inline-block; 87 + } 88 + 89 + .modal-inner { 90 + padding: 2rem; 91 + } 92 + 93 + .modal-header { 94 + display: flex; 95 + justify-content: space-between; 96 + align-items: center; 97 + margin-bottom: 1.5rem; 98 + } 99 + 100 + .modal-header h2 { 101 + margin: 0; 102 + color: var(--color-text); 103 + font-size: 1.5rem; 104 + } 105 + 106 + .modal-close { 107 + background: none; 108 + border: none; 109 + font-size: 1.5rem; 110 + color: var(--color-text-muted); 111 + cursor: pointer; 112 + padding: 0.25rem 0.5rem; 113 + transition: color 0.2s; 114 + } 115 + 116 + .modal-close:hover { 117 + color: var(--color-text); 118 + } 119 + 120 + .modal-body { 121 + margin-bottom: 1.5rem; 122 + color: var(--color-text); 123 + line-height: 1.8; 124 + } 125 + 126 + .modal-body p { 127 + margin-bottom: 1rem; 128 + } 129 + 130 + .modal-body p:last-child { 131 + margin-bottom: 0; 132 + } 133 + 134 + .modal-footer { 135 + display: flex; 136 + justify-content: flex-end; 137 + padding-top: 1rem; 138 + border-top: 1px solid var(--color-border); 139 + } 140 + 141 + .btn-primary { 142 + padding: 0.75rem 1.5rem; 143 + border: none; 144 + border-radius: 0.5rem; 145 + cursor: pointer; 146 + font-size: 1rem; 147 + transition: all 0.2s; 148 + font-weight: 500; 149 + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%); 150 + color: white; 151 + box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); 152 + } 153 + 154 + .btn-primary:hover { 155 + transform: translateY(-1px); 156 + box-shadow: 0 4px 12px rgba(242, 197, 160, 0.4); 157 + } 158 + </style>
+476
src/lib/components/TutorialPopup.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { browser } from '$app/environment'; 4 + import Board from './Board.svelte'; 5 + import { loadSgf, type SgfData } from '$lib/sgf-parser'; 6 + 7 + interface Props { 8 + isOpen?: boolean; 9 + onClose?: () => void; 10 + } 11 + 12 + let { isOpen = $bindable(false), onClose = () => {} }: Props = $props(); 13 + 14 + let currentStep = $state(0); 15 + let singleCaptureData: SgfData | null = $state(null); 16 + let groupCaptureData: SgfData | null = $state(null); 17 + let scoringDemoData: SgfData | null = $state(null); 18 + 19 + // Track current move index for each diagram 20 + let singleCaptureMoveIndex = $state(0); 21 + let groupCaptureMoveIndex = $state(0); 22 + let scoringDemoMoveIndex = $state(0); 23 + 24 + const STORAGE_KEY = 'cloudgo-tutorial-seen'; 25 + 26 + const steps = [ 27 + { 28 + title: 'Welcome to Cloud Go!', 29 + content: `<p>Go is an ancient game whose Chinese name "Weiqi" translates to "the Surrounding Game".</p> 30 + <ul> 31 + <li>Your goal is to surround as much territory as possible</li> 32 + <li>You can capture your opponent's stones by surrounding them</li> 33 + <li>The player who controls the most territory wins</li> 34 + </ul>`, 35 + sgfUrl: null 36 + }, 37 + { 38 + title: 'Capturing a Single Stone', 39 + content: `<ul> 40 + <li>A stone is captured when it's surrounded on all sides</li> 41 + <li>Stones connect along the lines (not diagonally)</li> 42 + <li>Use the buttons below to step through the example</li> 43 + </ul>`, 44 + sgfUrl: '/simplecapture.sgf' 45 + }, 46 + { 47 + title: 'Capturing Multiple Stones', 48 + content: `<ul> 49 + <li>Stones connected along lines form groups</li> 50 + <li>You must surround the entire group to capture it</li> 51 + <li>Step through to see a group capture in action</li> 52 + </ul>`, 53 + sgfUrl: '/3stonecapture.sgf' 54 + }, 55 + { 56 + title: 'Counting Territory', 57 + content: `<p>At the end of the game:</p> 58 + <ul> 59 + <li>Count the empty intersections you surround (your territory)</li> 60 + <li>Add bonus points for captured opponent stones</li> 61 + <li>The player with more points wins</li> 62 + </ul> 63 + <p>In this example, black controls more of the board and wins!</p>`, 64 + sgfUrl: '/scoringdemo.sgf' 65 + }, 66 + { 67 + title: 'How to Use Cloud Go', 68 + content: `<p><strong>Game Flow:</strong></p> 69 + <ul> 70 + <li>The initiating player always goes first</li> 71 + <li>You take turns playing - there are no time limits yet</li> 72 + <li>If your opponent is taking too long or the game is lost, you can resign</li> 73 + </ul> 74 + <p><strong>Navigation:</strong></p> 75 + <ul> 76 + <li>Use the move list at the bottom or arrow keys to navigate move history</li> 77 + <li>Click on a move to add comments with reaction emojis</li> 78 + </ul> 79 + <p><strong>Ending the Game:</strong></p> 80 + <ul> 81 + <li>Once both players pass, territories are auto-calculated</li> 82 + <li>The black player can modify or commit the final scores</li> 83 + </ul>`, 84 + sgfUrl: null 85 + } 86 + ]; 87 + 88 + async function loadStepDiagram() { 89 + const step = steps[currentStep]; 90 + if (!step.sgfUrl) return; 91 + 92 + try { 93 + const sgfData = await loadSgf(step.sgfUrl); 94 + 95 + if (step.sgfUrl === '/simplecapture.sgf') { 96 + singleCaptureData = sgfData; 97 + singleCaptureMoveIndex = 0; // Start from beginning 98 + } else if (step.sgfUrl === '/3stonecapture.sgf') { 99 + groupCaptureData = sgfData; 100 + groupCaptureMoveIndex = 0; // Start from beginning 101 + } else if (step.sgfUrl === '/scoringdemo.sgf') { 102 + scoringDemoData = sgfData; 103 + scoringDemoMoveIndex = sgfData.moves.length; // Start at end to show final position 104 + } 105 + } catch (error) { 106 + console.error('Failed to load SGF:', error); 107 + } 108 + } 109 + 110 + function prevMove(diagram: 'single' | 'group' | 'scoring') { 111 + if (diagram === 'single' && singleCaptureMoveIndex > 0) { 112 + singleCaptureMoveIndex--; 113 + } else if (diagram === 'group' && groupCaptureMoveIndex > 0) { 114 + groupCaptureMoveIndex--; 115 + } else if (diagram === 'scoring' && scoringDemoMoveIndex > 0) { 116 + scoringDemoMoveIndex--; 117 + } 118 + } 119 + 120 + function nextMove(diagram: 'single' | 'group' | 'scoring') { 121 + if (diagram === 'single' && singleCaptureData && singleCaptureMoveIndex < singleCaptureData.moves.length) { 122 + singleCaptureMoveIndex++; 123 + } else if (diagram === 'group' && groupCaptureData && groupCaptureMoveIndex < groupCaptureData.moves.length) { 124 + groupCaptureMoveIndex++; 125 + } else if (diagram === 'scoring' && scoringDemoData && scoringDemoMoveIndex < scoringDemoData.moves.length) { 126 + scoringDemoMoveIndex++; 127 + } 128 + } 129 + 130 + function resetMoves(diagram: 'single' | 'group' | 'scoring') { 131 + if (diagram === 'single') { 132 + singleCaptureMoveIndex = 0; 133 + } else if (diagram === 'group') { 134 + groupCaptureMoveIndex = 0; 135 + } else if (diagram === 'scoring') { 136 + scoringDemoMoveIndex = 0; 137 + } 138 + } 139 + 140 + onMount(() => { 141 + if (!browser) return; 142 + 143 + const seen = localStorage.getItem(STORAGE_KEY); 144 + if (!seen) { 145 + isOpen = true; 146 + } 147 + }); 148 + 149 + $effect(() => { 150 + if (isOpen && browser) { 151 + loadStepDiagram(); 152 + } 153 + }); 154 + 155 + function handleClose() { 156 + if (browser) { 157 + localStorage.setItem(STORAGE_KEY, 'true'); 158 + } 159 + isOpen = false; 160 + onClose(); 161 + } 162 + 163 + function nextStep() { 164 + if (currentStep < steps.length - 1) { 165 + currentStep++; 166 + } else { 167 + handleClose(); 168 + } 169 + } 170 + 171 + function prevStep() { 172 + if (currentStep > 0) { 173 + currentStep--; 174 + } 175 + } 176 + 177 + function handleKeydown(e: KeyboardEvent) { 178 + if (e.key === 'Escape') { 179 + handleClose(); 180 + } else if (e.key === 'ArrowRight') { 181 + nextStep(); 182 + } else if (e.key === 'ArrowLeft') { 183 + prevStep(); 184 + } 185 + } 186 + 187 + export function open() { 188 + isOpen = true; 189 + currentStep = 0; 190 + } 191 + </script> 192 + 193 + {#if isOpen} 194 + <div class="modal-overlay" onclick={handleClose} onkeydown={handleKeydown} role="button" tabindex="0"> 195 + <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true"> 196 + <div class="modal-inner"> 197 + <div class="modal-header"> 198 + <h2>{steps[currentStep].title}</h2> 199 + <button class="modal-close" onclick={handleClose} aria-label="Close tutorial">×</button> 200 + </div> 201 + 202 + <div class="modal-body"> 203 + {@html steps[currentStep].content} 204 + 205 + {#if steps[currentStep].sgfUrl === '/simplecapture.sgf' && singleCaptureData} 206 + <div class="board-container"> 207 + <Board 208 + boardSize={singleCaptureData.boardSize} 209 + gameState={{ moves: singleCaptureData.moves.slice(0, singleCaptureMoveIndex) }} 210 + interactive={false} 211 + /> 212 + </div> 213 + <div class="move-controls"> 214 + <button 215 + class="move-btn" 216 + onclick={() => resetMoves('single')} 217 + disabled={singleCaptureMoveIndex === 0} 218 + title="Reset to start" 219 + > 220 + 221 + </button> 222 + <button 223 + class="move-btn" 224 + onclick={() => prevMove('single')} 225 + disabled={singleCaptureMoveIndex === 0} 226 + title="Previous move" 227 + > 228 + 229 + </button> 230 + <span class="move-counter"> 231 + Move {singleCaptureMoveIndex} / {singleCaptureData.moves.length} 232 + </span> 233 + <button 234 + class="move-btn" 235 + onclick={() => nextMove('single')} 236 + disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 237 + title="Next move" 238 + > 239 + 240 + </button> 241 + <button 242 + class="move-btn" 243 + onclick={() => singleCaptureMoveIndex = singleCaptureData!.moves.length} 244 + disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 245 + title="Jump to end" 246 + > 247 + 248 + </button> 249 + </div> 250 + {/if} 251 + 252 + {#if steps[currentStep].sgfUrl === '/3stonecapture.sgf' && groupCaptureData} 253 + <div class="board-container"> 254 + <Board 255 + boardSize={groupCaptureData.boardSize} 256 + gameState={{ moves: groupCaptureData.moves.slice(0, groupCaptureMoveIndex) }} 257 + interactive={false} 258 + /> 259 + </div> 260 + <div class="move-controls"> 261 + <button 262 + class="move-btn" 263 + onclick={() => resetMoves('group')} 264 + disabled={groupCaptureMoveIndex === 0} 265 + title="Reset to start" 266 + > 267 + 268 + </button> 269 + <button 270 + class="move-btn" 271 + onclick={() => prevMove('group')} 272 + disabled={groupCaptureMoveIndex === 0} 273 + title="Previous move" 274 + > 275 + 276 + </button> 277 + <span class="move-counter"> 278 + Move {groupCaptureMoveIndex} / {groupCaptureData.moves.length} 279 + </span> 280 + <button 281 + class="move-btn" 282 + onclick={() => nextMove('group')} 283 + disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 284 + title="Next move" 285 + > 286 + 287 + </button> 288 + <button 289 + class="move-btn" 290 + onclick={() => groupCaptureMoveIndex = groupCaptureData!.moves.length} 291 + disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 292 + title="Jump to end" 293 + > 294 + 295 + </button> 296 + </div> 297 + {/if} 298 + 299 + {#if steps[currentStep].sgfUrl === '/scoringdemo.sgf' && scoringDemoData} 300 + <div class="board-container"> 301 + <Board 302 + boardSize={scoringDemoData.boardSize} 303 + gameState={{ moves: scoringDemoData.moves.slice(0, scoringDemoMoveIndex) }} 304 + interactive={false} 305 + /> 306 + </div> 307 + <div class="move-controls"> 308 + <button 309 + class="move-btn" 310 + onclick={() => resetMoves('scoring')} 311 + disabled={scoringDemoMoveIndex === 0} 312 + title="Reset to start" 313 + > 314 + 315 + </button> 316 + <button 317 + class="move-btn" 318 + onclick={() => prevMove('scoring')} 319 + disabled={scoringDemoMoveIndex === 0} 320 + title="Previous move" 321 + > 322 + 323 + </button> 324 + <span class="move-counter"> 325 + Move {scoringDemoMoveIndex} / {scoringDemoData.moves.length} 326 + </span> 327 + <button 328 + class="move-btn" 329 + onclick={() => nextMove('scoring')} 330 + disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 331 + title="Next move" 332 + > 333 + 334 + </button> 335 + <button 336 + class="move-btn" 337 + onclick={() => scoringDemoMoveIndex = scoringDemoData!.moves.length} 338 + disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 339 + title="Jump to end" 340 + > 341 + 342 + </button> 343 + </div> 344 + {/if} 345 + </div> 346 + 347 + <div class="modal-footer"> 348 + <button 349 + class="btn-secondary" 350 + onclick={prevStep} 351 + disabled={currentStep === 0} 352 + > 353 + Previous 354 + </button> 355 + <span class="step-counter"> 356 + Step {currentStep + 1} of {steps.length} 357 + </span> 358 + <button class="btn-primary" onclick={nextStep}> 359 + {currentStep === steps.length - 1 ? "Get Started!" : "Next"} 360 + </button> 361 + </div> 362 + </div> 363 + </div> 364 + </div> 365 + {/if} 366 + 367 + <style> 368 + .modal-inner { 369 + padding: 2rem; 370 + max-height: 85vh; 371 + overflow-y: auto; 372 + } 373 + 374 + .modal-body { 375 + padding: 0 0.5rem; 376 + } 377 + 378 + .modal-body ul { 379 + margin-left: 1.5rem; 380 + margin-top: 1rem; 381 + margin-bottom: 1.5rem; 382 + line-height: 1.8; 383 + } 384 + 385 + .modal-body li { 386 + margin-bottom: 0.75rem; 387 + } 388 + 389 + .modal-body p { 390 + margin-bottom: 1rem; 391 + line-height: 1.8; 392 + } 393 + 394 + .step-counter { 395 + color: var(--color-text-muted); 396 + font-size: 0.9rem; 397 + padding: 0 1rem; 398 + white-space: nowrap; 399 + } 400 + 401 + .board-container { 402 + display: flex; 403 + justify-content: center; 404 + margin: 1.5rem 0 0.5rem 0; 405 + max-width: 100%; 406 + } 407 + 408 + .board-container :global(> div) { 409 + max-width: 400px; 410 + width: 100%; 411 + } 412 + 413 + .move-controls { 414 + display: flex; 415 + justify-content: center; 416 + align-items: center; 417 + gap: 0.5rem; 418 + margin-bottom: 1.5rem; 419 + flex-wrap: wrap; 420 + } 421 + 422 + .move-btn { 423 + background: linear-gradient(135deg, var(--color-bg-card) 0%, var(--color-border) 100%); 424 + border: 1px solid var(--color-border); 425 + border-radius: 0.5rem; 426 + padding: 0.5rem 0.75rem; 427 + cursor: pointer; 428 + font-size: 1rem; 429 + transition: all 0.2s; 430 + color: var(--color-text); 431 + } 432 + 433 + .move-btn:hover:not(:disabled) { 434 + background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); 435 + transform: translateY(-1px); 436 + box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); 437 + } 438 + 439 + .move-btn:active:not(:disabled) { 440 + transform: translateY(0); 441 + } 442 + 443 + .move-counter { 444 + padding: 0.5rem 1rem; 445 + color: var(--color-text-muted); 446 + font-size: 0.9rem; 447 + min-width: 120px; 448 + text-align: center; 449 + } 450 + 451 + button:disabled { 452 + opacity: 0.5; 453 + cursor: not-allowed; 454 + } 455 + 456 + @media (max-width: 768px) { 457 + .modal-inner { 458 + padding: 1.5rem; 459 + } 460 + 461 + .board-container :global(> div) { 462 + max-width: 300px; 463 + } 464 + 465 + .move-btn { 466 + padding: 0.4rem 0.6rem; 467 + font-size: 0.9rem; 468 + } 469 + 470 + .move-counter { 471 + font-size: 0.85rem; 472 + min-width: 100px; 473 + padding: 0.4rem 0.8rem; 474 + } 475 + } 476 + </style>
+59
src/lib/sgf-parser.ts
··· 1 + /** 2 + * Simple SGF parser for extracting moves from SGF files 3 + */ 4 + 5 + export interface SgfMove { 6 + color: 'black' | 'white'; 7 + x: number; 8 + y: number; 9 + } 10 + 11 + export interface SgfData { 12 + boardSize: number; 13 + moves: SgfMove[]; 14 + } 15 + 16 + /** 17 + * Parse SGF content and extract board size and moves 18 + */ 19 + export function parseSgf(sgfContent: string): SgfData { 20 + const moves: SgfMove[] = []; 21 + let boardSize = 19; // default 22 + 23 + // Extract board size 24 + const sizeMatch = sgfContent.match(/SZ\[(\d+)\]/); 25 + if (sizeMatch) { 26 + boardSize = parseInt(sizeMatch[1], 10); 27 + } 28 + 29 + // Extract moves - pattern like ;B[dd] or ;W[ed] 30 + const movePattern = /;([BW])\[([a-z]{0,2})\]/g; 31 + let match; 32 + 33 + while ((match = movePattern.exec(sgfContent)) !== null) { 34 + const color = match[1] === 'B' ? 'black' : 'white'; 35 + const coords = match[2]; 36 + 37 + // Empty brackets mean pass 38 + if (!coords) { 39 + continue; 40 + } 41 + 42 + // Convert SGF coordinates (aa = top-left) to 0-based x,y 43 + const x = coords.charCodeAt(0) - 97; // 'a' = 0 44 + const y = coords.charCodeAt(1) - 97; 45 + 46 + moves.push({ color, x, y }); 47 + } 48 + 49 + return { boardSize, moves }; 50 + } 51 + 52 + /** 53 + * Load and parse an SGF file from a URL 54 + */ 55 + export async function loadSgf(url: string): Promise<SgfData> { 56 + const response = await fetch(url); 57 + const content = await response.text(); 58 + return parseSgf(content); 59 + }
+12
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 + import TutorialPopup from '$lib/components/TutorialPopup.svelte'; 4 + import Footer from '$lib/components/Footer.svelte'; 3 5 4 6 let { children } = $props(); 7 + 8 + let tutorialPopup: TutorialPopup | null = $state(null); 9 + 10 + function handleReplayTutorial() { 11 + tutorialPopup?.open(); 12 + } 5 13 </script> 6 14 7 15 <div class="clouds-container"> ··· 15 23 <div class="page-content"> 16 24 {@render children()} 17 25 </div> 26 + 27 + <Footer onReplayTutorial={handleReplayTutorial} /> 28 + 29 + <TutorialPopup bind:this={tutorialPopup} />
+529
src/routes/rules/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { browser } from '$app/environment'; 4 + import Board from '$lib/components/Board.svelte'; 5 + import { loadSgf, type SgfData } from '$lib/sgf-parser'; 6 + 7 + let singleCaptureData: SgfData | null = $state(null); 8 + let groupCaptureData: SgfData | null = $state(null); 9 + let scoringDemoData: SgfData | null = $state(null); 10 + 11 + let singleCaptureMoveIndex = $state(0); 12 + let groupCaptureMoveIndex = $state(0); 13 + let scoringDemoMoveIndex = $state(0); 14 + 15 + async function loadDiagrams() { 16 + if (!browser) return; 17 + 18 + try { 19 + const [single, group, scoring] = await Promise.all([ 20 + loadSgf('/simplecapture.sgf'), 21 + loadSgf('/3stonecapture.sgf'), 22 + loadSgf('/scoringdemo.sgf') 23 + ]); 24 + 25 + singleCaptureData = single; 26 + singleCaptureMoveIndex = single.moves.length; // Show final position 27 + 28 + groupCaptureData = group; 29 + groupCaptureMoveIndex = group.moves.length; // Show final position 30 + 31 + scoringDemoData = scoring; 32 + scoringDemoMoveIndex = scoring.moves.length; // Show final position 33 + } catch (error) { 34 + console.error('Failed to load SGF files:', error); 35 + } 36 + } 37 + 38 + function prevMove(diagram: 'single' | 'group' | 'scoring') { 39 + if (diagram === 'single' && singleCaptureMoveIndex > 0) { 40 + singleCaptureMoveIndex--; 41 + } else if (diagram === 'group' && groupCaptureMoveIndex > 0) { 42 + groupCaptureMoveIndex--; 43 + } else if (diagram === 'scoring' && scoringDemoMoveIndex > 0) { 44 + scoringDemoMoveIndex--; 45 + } 46 + } 47 + 48 + function nextMove(diagram: 'single' | 'group' | 'scoring') { 49 + if (diagram === 'single' && singleCaptureData && singleCaptureMoveIndex < singleCaptureData.moves.length) { 50 + singleCaptureMoveIndex++; 51 + } else if (diagram === 'group' && groupCaptureData && groupCaptureMoveIndex < groupCaptureData.moves.length) { 52 + groupCaptureMoveIndex++; 53 + } else if (diagram === 'scoring' && scoringDemoData && scoringDemoMoveIndex < scoringDemoData.moves.length) { 54 + scoringDemoMoveIndex++; 55 + } 56 + } 57 + 58 + function resetMoves(diagram: 'single' | 'group' | 'scoring') { 59 + if (diagram === 'single') { 60 + singleCaptureMoveIndex = 0; 61 + } else if (diagram === 'group') { 62 + groupCaptureMoveIndex = 0; 63 + } else if (diagram === 'scoring') { 64 + scoringDemoMoveIndex = 0; 65 + } 66 + } 67 + 68 + onMount(() => { 69 + loadDiagrams(); 70 + }); 71 + </script> 72 + 73 + <svelte:head> 74 + <title>Go Rules - Cloud Go</title> 75 + </svelte:head> 76 + 77 + <div class="rules-container"> 78 + <div class="cloud-card"> 79 + <h1>Go Rules Reference</h1> 80 + 81 + <section> 82 + <h2>What is Go?</h2> 83 + <p> 84 + Go (also known as Weiqi in Chinese, Baduk in Korean) is an ancient board game that originated in China over 2,500 years ago. 85 + The name "Weiqi" translates to "the Surrounding Game," which perfectly describes the core objective. 86 + </p> 87 + <p> 88 + The game is played on a board with a grid of lines (typically 19×19, but 9×9 and 13×13 are also common). 89 + Two players take turns placing stones on the intersections of the lines, with the goal of controlling more territory than their opponent. 90 + </p> 91 + </section> 92 + 93 + <section> 94 + <h2>Capturing Stones</h2> 95 + 96 + <h3>Single Stone Capture</h3> 97 + <p> 98 + A stone is captured when it has no liberties left. Liberties are the empty adjacent intersections 99 + along the lines of the board (not diagonally). When you surround an opponent's stone on all sides, it is captured and removed from the board. 100 + </p> 101 + 102 + {#if singleCaptureData} 103 + <div class="board-container"> 104 + <Board 105 + boardSize={singleCaptureData.boardSize} 106 + gameState={{ moves: singleCaptureData.moves.slice(0, singleCaptureMoveIndex) }} 107 + interactive={false} 108 + /> 109 + </div> 110 + <div class="move-controls"> 111 + <button 112 + class="move-btn" 113 + onclick={() => resetMoves('single')} 114 + disabled={singleCaptureMoveIndex === 0} 115 + title="Reset to start" 116 + > 117 + 118 + </button> 119 + <button 120 + class="move-btn" 121 + onclick={() => prevMove('single')} 122 + disabled={singleCaptureMoveIndex === 0} 123 + title="Previous move" 124 + > 125 + 126 + </button> 127 + <span class="move-counter"> 128 + Move {singleCaptureMoveIndex} / {singleCaptureData.moves.length} 129 + </span> 130 + <button 131 + class="move-btn" 132 + onclick={() => nextMove('single')} 133 + disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 134 + title="Next move" 135 + > 136 + 137 + </button> 138 + <button 139 + class="move-btn" 140 + onclick={() => singleCaptureMoveIndex = singleCaptureData!.moves.length} 141 + disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 142 + title="Jump to end" 143 + > 144 + 145 + </button> 146 + </div> 147 + <p class="diagram-caption"> 148 + Step through to see how the white stone gets surrounded and captured by black 149 + </p> 150 + {/if} 151 + 152 + <h3>Group Capture</h3> 153 + <p> 154 + Stones that are connected orthogonally (along the lines, not diagonally) form a group. Groups share their liberties, 155 + meaning you must surround the entire group to capture it. This makes groups stronger than isolated stones. 156 + </p> 157 + 158 + {#if groupCaptureData} 159 + <div class="board-container"> 160 + <Board 161 + boardSize={groupCaptureData.boardSize} 162 + gameState={{ moves: groupCaptureData.moves.slice(0, groupCaptureMoveIndex) }} 163 + interactive={false} 164 + /> 165 + </div> 166 + <div class="move-controls"> 167 + <button 168 + class="move-btn" 169 + onclick={() => resetMoves('group')} 170 + disabled={groupCaptureMoveIndex === 0} 171 + title="Reset to start" 172 + > 173 + 174 + </button> 175 + <button 176 + class="move-btn" 177 + onclick={() => prevMove('group')} 178 + disabled={groupCaptureMoveIndex === 0} 179 + title="Previous move" 180 + > 181 + 182 + </button> 183 + <span class="move-counter"> 184 + Move {groupCaptureMoveIndex} / {groupCaptureData.moves.length} 185 + </span> 186 + <button 187 + class="move-btn" 188 + onclick={() => nextMove('group')} 189 + disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 190 + title="Next move" 191 + > 192 + 193 + </button> 194 + <button 195 + class="move-btn" 196 + onclick={() => groupCaptureMoveIndex = groupCaptureData!.moves.length} 197 + disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 198 + title="Jump to end" 199 + > 200 + 201 + </button> 202 + </div> 203 + <p class="diagram-caption"> 204 + Watch multiple white stones form a group that gets surrounded and captured together 205 + </p> 206 + {/if} 207 + </section> 208 + 209 + <section> 210 + <h2>Territory and Scoring</h2> 211 + <p> 212 + At the end of the game, your score is determined by: 213 + </p> 214 + <ul> 215 + <li>The number of empty intersections you surround (your territory)</li> 216 + <li>The number of opponent stones you captured during the game</li> 217 + </ul> 218 + <p> 219 + Territory is counted as the empty intersections that are surrounded by your stones. 220 + The player with the higher total score wins. 221 + </p> 222 + 223 + {#if scoringDemoData} 224 + <div class="board-container"> 225 + <Board 226 + boardSize={scoringDemoData.boardSize} 227 + gameState={{ moves: scoringDemoData.moves.slice(0, scoringDemoMoveIndex) }} 228 + interactive={false} 229 + /> 230 + </div> 231 + <div class="move-controls"> 232 + <button 233 + class="move-btn" 234 + onclick={() => resetMoves('scoring')} 235 + disabled={scoringDemoMoveIndex === 0} 236 + title="Reset to start" 237 + > 238 + 239 + </button> 240 + <button 241 + class="move-btn" 242 + onclick={() => prevMove('scoring')} 243 + disabled={scoringDemoMoveIndex === 0} 244 + title="Previous move" 245 + > 246 + 247 + </button> 248 + <span class="move-counter"> 249 + Move {scoringDemoMoveIndex} / {scoringDemoData.moves.length} 250 + </span> 251 + <button 252 + class="move-btn" 253 + onclick={() => nextMove('scoring')} 254 + disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 255 + title="Next move" 256 + > 257 + 258 + </button> 259 + <button 260 + class="move-btn" 261 + onclick={() => scoringDemoMoveIndex = scoringDemoData!.moves.length} 262 + disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 263 + title="Jump to end" 264 + > 265 + 266 + </button> 267 + </div> 268 + <p class="diagram-caption"> 269 + In this example, black controls more territory on the left than white controls on the right 270 + </p> 271 + {/if} 272 + </section> 273 + 274 + <section> 275 + <h2>The Ko Rule</h2> 276 + <p> 277 + The ko rule prevents infinite loops in the game. It states that you cannot immediately recapture a stone 278 + if doing so would return the board to its previous position. You must make at least one move elsewhere before 279 + recapturing. 280 + </p> 281 + <p> 282 + This rule ensures that games always progress toward a conclusion and prevents repetitive capture-and-recapture situations. 283 + </p> 284 + </section> 285 + 286 + <section> 287 + <h2>Passing and Game End</h2> 288 + <p> 289 + Instead of placing a stone, a player may pass their turn. When both players pass consecutively, 290 + the game ends and territories are counted. 291 + </p> 292 + <p> 293 + Players typically pass when they believe there are no more profitable moves to make, 294 + and all territories have been clearly established. 295 + </p> 296 + </section> 297 + 298 + <section> 299 + <h2>Additional Rules</h2> 300 + <ul> 301 + <li><strong>No Suicide:</strong> You cannot place a stone that would have no liberties unless it captures opponent stones</li> 302 + <li><strong>Komi:</strong> White typically receives bonus points (usually 6.5 or 7.5) to compensate for black's first-move advantage</li> 303 + <li><strong>Handicap Stones:</strong> Weaker players can place multiple stones on the board before the game starts to balance skill differences</li> 304 + </ul> 305 + </section> 306 + 307 + <section> 308 + <h2>External References</h2> 309 + <p> 310 + Want to learn more? Here are some helpful resources to deepen your understanding of Go: 311 + </p> 312 + <ul class="external-links"> 313 + <li> 314 + <a href="https://www.britgo.org/files/rules/GoQuickRef.pdf" target="_blank" rel="noopener noreferrer"> 315 + Go Quick Reference (PDF) 316 + </a> 317 + <span class="link-description">- British Go Association's concise rules reference</span> 318 + </li> 319 + <li> 320 + <a href="https://www.nordicgodojo.eu/post/212/a-simple-beginners-guide-to-go" target="_blank" rel="noopener noreferrer"> 321 + A Simple Beginner's Guide to Go 322 + </a> 323 + <span class="link-description">- Nordic Go Dojo's comprehensive beginner tutorial</span> 324 + </li> 325 + <li> 326 + <a href="https://en.wikipedia.org/wiki/Rules_of_Go" target="_blank" rel="noopener noreferrer"> 327 + Rules of Go (Wikipedia) 328 + </a> 329 + <span class="link-description">- Detailed explanation of various rulesets</span> 330 + </li> 331 + <li> 332 + <a href="https://senseis.xmp.net/?RulesOfGoIntroductory" target="_blank" rel="noopener noreferrer"> 333 + Sensei's Library: Rules of Go 334 + </a> 335 + <span class="link-description">- Comprehensive wiki resource for Go players</span> 336 + </li> 337 + </ul> 338 + </section> 339 + 340 + <div class="back-link"> 341 + <a href="/">← Back to Home</a> 342 + </div> 343 + </div> 344 + </div> 345 + 346 + <style> 347 + .rules-container { 348 + max-width: 800px; 349 + margin: 2rem auto; 350 + padding: 0 1rem; 351 + } 352 + 353 + .cloud-card { 354 + padding: 2.5rem; 355 + } 356 + 357 + h1 { 358 + font-size: 2rem; 359 + margin-bottom: 2rem; 360 + color: var(--color-text); 361 + text-align: center; 362 + } 363 + 364 + h2 { 365 + font-size: 1.5rem; 366 + color: var(--color-primary); 367 + margin-bottom: 1rem; 368 + margin-top: 2rem; 369 + } 370 + 371 + section:first-of-type h2 { 372 + margin-top: 0; 373 + } 374 + 375 + h3 { 376 + font-size: 1.25rem; 377 + color: var(--color-accent); 378 + margin-top: 2rem; 379 + margin-bottom: 0.75rem; 380 + } 381 + 382 + p { 383 + margin-bottom: 1rem; 384 + line-height: 1.8; 385 + } 386 + 387 + ul { 388 + margin-left: 1.5rem; 389 + margin-bottom: 1rem; 390 + } 391 + 392 + ul li { 393 + margin-bottom: 0.5rem; 394 + line-height: 1.8; 395 + } 396 + 397 + section { 398 + margin-bottom: 2.5rem; 399 + } 400 + 401 + .board-container { 402 + display: flex; 403 + justify-content: center; 404 + margin: 2rem 0 0.75rem 0; 405 + max-width: 100%; 406 + } 407 + 408 + .board-container :global(> div) { 409 + max-width: 450px; 410 + width: 100%; 411 + } 412 + 413 + .move-controls { 414 + display: flex; 415 + justify-content: center; 416 + align-items: center; 417 + gap: 0.5rem; 418 + margin-bottom: 0.75rem; 419 + flex-wrap: wrap; 420 + } 421 + 422 + .move-btn { 423 + background: linear-gradient(135deg, var(--color-bg-card) 0%, var(--color-border) 100%); 424 + border: 1px solid var(--color-border); 425 + border-radius: 0.5rem; 426 + padding: 0.5rem 0.75rem; 427 + cursor: pointer; 428 + font-size: 1rem; 429 + transition: all 0.2s; 430 + color: var(--color-text); 431 + } 432 + 433 + .move-btn:hover:not(:disabled) { 434 + background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); 435 + transform: translateY(-1px); 436 + box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); 437 + } 438 + 439 + .move-btn:active:not(:disabled) { 440 + transform: translateY(0); 441 + } 442 + 443 + .move-btn:disabled { 444 + opacity: 0.5; 445 + cursor: not-allowed; 446 + } 447 + 448 + .move-counter { 449 + padding: 0.5rem 1rem; 450 + color: var(--color-text-muted); 451 + font-size: 0.9rem; 452 + min-width: 120px; 453 + text-align: center; 454 + } 455 + 456 + .diagram-caption { 457 + text-align: center; 458 + color: var(--color-text-muted); 459 + font-style: italic; 460 + font-size: 0.9rem; 461 + margin-bottom: 2rem; 462 + } 463 + 464 + .back-link { 465 + text-align: center; 466 + margin-top: 3rem; 467 + padding-top: 2rem; 468 + border-top: 1px solid var(--color-border); 469 + } 470 + 471 + .back-link a { 472 + color: var(--color-link); 473 + text-decoration: none; 474 + transition: color 0.2s; 475 + } 476 + 477 + .back-link a:hover { 478 + color: var(--color-link-hover); 479 + text-decoration: underline; 480 + } 481 + 482 + .external-links { 483 + list-style: none; 484 + margin-left: 0; 485 + } 486 + 487 + .external-links li { 488 + margin-bottom: 1rem; 489 + } 490 + 491 + .external-links a { 492 + color: var(--color-link); 493 + text-decoration: none; 494 + font-weight: 500; 495 + transition: color 0.2s; 496 + } 497 + 498 + .external-links a:hover { 499 + color: var(--color-link-hover); 500 + text-decoration: underline; 501 + } 502 + 503 + .link-description { 504 + color: var(--color-text-muted); 505 + font-size: 0.9rem; 506 + font-weight: normal; 507 + } 508 + 509 + @media (max-width: 768px) { 510 + .cloud-card { 511 + padding: 1.5rem; 512 + } 513 + 514 + .board-container :global(> div) { 515 + max-width: 350px; 516 + } 517 + 518 + .move-btn { 519 + padding: 0.4rem 0.6rem; 520 + font-size: 0.9rem; 521 + } 522 + 523 + .move-counter { 524 + font-size: 0.85rem; 525 + min-width: 100px; 526 + padding: 0.4rem 0.8rem; 527 + } 528 + } 529 + </style>
+16
static/3stonecapture.sgf
··· 1 + (;FF[4]GM[1]CA[UTF-8]AP[besogo:0.0.2-alpha]SZ[7]ST[0] 2 + 3 + ;B[ce] 4 + ;W[be] 5 + ;B[de] 6 + ;W[cf] 7 + ;B[dd] 8 + ;W[df] 9 + ;B[] 10 + ;W[ee] 11 + ;B[] 12 + ;W[ed] 13 + ;B[] 14 + ;W[dc] 15 + ;B[] 16 + ;W[cd])
+18
static/scoringdemo.sgf
··· 1 + (;FF[4]GM[1]CA[UTF-8]AP[besogo:0.0.2-alpha]SZ[7]ST[0] 2 + 3 + ;B[dg] 4 + ;W[ea] 5 + ;B[df] 6 + ;W[eb] 7 + ;B[de] 8 + ;W[ec] 9 + ;B[dd] 10 + ;W[ed] 11 + ;B[dc] 12 + ;W[ee] 13 + ;B[db] 14 + ;W[ef] 15 + ;B[da] 16 + ;W[eg] 17 + ;B[] 18 + ;W[]SL[aa][ab][ac][ad][ae][af][ag][ba][bb][bc][bd][be][bf][bg][ca][cb][cc][cd][ce][cf][cg])
+10
static/simplecapture.sgf
··· 1 + (;FF[4]GM[1]CA[UTF-8]AP[besogo:0.0.2-alpha]SZ[7]ST[0] 2 + 3 + ;B[dd] 4 + ;W[ed] 5 + ;B[] 6 + ;W[dc] 7 + ;B[] 8 + ;W[de] 9 + ;B[] 10 + ;W[cd])