Chess on the ATmosphere checkmate.blue
chess
18
fork

Configure Feed

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

Improve accessibility to WCAG 2.1 AA standards

- Add skip navigation link, nav aria-label, main landmark
- Add aria-labels to form inputs, board, move list, sound toggle
- Add role="dialog" and aria-modal to promotion modal
- Add role="alert" to game result, draw offer, check, and error notices
- Add aria-hidden to decorative status dot
- Add global :focus-visible outline and remove conflicting focus:outline-none
- Add prefers-reduced-motion media query to disable animations
- Add per-route page titles via svelte:head
- Add sr-only h1 to homepage for heading hierarchy
- Update text-secondary color to #8b9098 for AA contrast compliance
- Document accessibility standards in CLAUDE.md

authored by

Scott Hadfield and committed by tangled.org c8749268 fea45444

+79 -17
+11
CLAUDE.md
··· 70 70 71 71 When White discovers Black via Jetstream (`waitForOpponent`), White persists `{ black: blackDid, status: 'active' }` to their own record so the pairing survives page reload. 72 72 73 + ## Accessibility 74 + 75 + All UI changes must follow WCAG 2.1 AA standards: 76 + - Interactive elements need visible focus indicators (global `:focus-visible` rule in `layout.css`). 77 + - Dynamic content changes (game results, alerts, errors) use `role="alert"` or `aria-live`. 78 + - Form inputs need `aria-label` when there is no visible `<label>`. 79 + - Modals need `role="dialog"`, `aria-modal="true"`, and `aria-label`. 80 + - Don't rely on color alone to convey information -- pair with text or icons. 81 + - Respect `prefers-reduced-motion` (global rule in `layout.css`). 82 + - Each route should have a descriptive `<title>` via `<svelte:head>`. 83 + 73 84 ## Specs 74 85 75 86 Feature specs live in `specs/` and follow the `/spec` workflow. Treat specs as source of truth for implementation. If something isn't covered by a spec, ask rather than guessing.
+1 -1
src/lib/components/Board.svelte
··· 61 61 } 62 62 </script> 63 63 64 - <div class="board-wrap"> 64 + <div class="board-wrap" role="application" aria-label="Chess board"> 65 65 <div bind:this={boardEl} class="cg-wrap"></div> 66 66 </div> 67 67
+1 -1
src/lib/components/GameControls.svelte
··· 18 18 19 19 {#if !gameOver} 20 20 {#if drawOfferedByOpponent} 21 - <div class="w-full max-w-md rounded-lg border border-border bg-bg-secondary p-3 text-center"> 21 + <div class="w-full max-w-md rounded-lg border border-border bg-bg-secondary p-3 text-center" role="alert"> 22 22 <p class="text-sm font-semibold">Your opponent offers a draw</p> 23 23 <div class="mt-2 flex justify-center gap-2"> 24 24 <button
+2 -1
src/lib/components/LoginButton.svelte
··· 23 23 type="text" 24 24 bind:value={handleInput} 25 25 placeholder="your-handle.bsky.social" 26 - class="rounded-lg border border-border bg-bg-secondary px-4 py-2 text-text-primary placeholder:text-text-secondary focus:border-accent-blue focus:outline-none" 26 + aria-label="Bluesky handle" 27 + class="rounded-lg border border-border bg-bg-secondary px-4 py-2 text-text-primary placeholder:text-text-secondary focus:border-accent-blue focus:ring-2 focus:ring-accent-blue/50" 27 28 /> 28 29 <button 29 30 type="submit"
+1 -1
src/lib/components/MoveList.svelte
··· 7 7 const moves = $derived(chess.history()); 8 8 </script> 9 9 10 - <div class="max-h-48 overflow-y-auto rounded-lg bg-bg-secondary p-3 font-mono text-sm"> 10 + <div class="max-h-48 overflow-y-auto rounded-lg bg-bg-secondary p-3 font-mono text-sm" aria-label="Move list" role="log"> 11 11 {#if moves.length === 0} 12 12 <p class="text-text-secondary">No moves yet</p> 13 13 {:else}
+3 -3
src/lib/components/PromotionModal.svelte
··· 21 21 }; 22 22 </script> 23 23 24 - <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> 24 + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" role="dialog" aria-modal="true" aria-label="Choose promotion piece"> 25 25 <div class="flex gap-2 rounded-xl bg-bg-secondary p-4 shadow-lg"> 26 26 {#each pieces as { symbol, label }} 27 27 <button 28 28 onclick={() => onselect(symbol)} 29 - title={label} 30 - class="flex h-16 w-16 items-center justify-center rounded-lg text-4xl transition-colors hover:bg-bg-board" 29 + aria-label="Promote to {label}" 30 + class="flex h-16 w-16 items-center justify-center rounded-lg text-4xl transition-colors hover:bg-bg-board focus:outline-2 focus:outline-offset-2 focus:outline-accent-blue" 31 31 > 32 32 {unicodePieces[color][symbol]} 33 33 </button>
+21 -4
src/routes/+layout.svelte
··· 16 16 <link rel="icon" href="/favicon.svg" /> 17 17 </svelte:head> 18 18 19 - <div class="min-h-screen bg-bg-primary text-text-primary"> 19 + <main class="min-h-screen bg-bg-primary text-text-primary"> 20 20 {#if auth.isInitializing} 21 21 <div class="flex min-h-screen items-center justify-center"> 22 22 <p class="text-text-secondary">Loading...</p> 23 23 </div> 24 24 {:else} 25 - <nav class="border-b border-border px-4 py-4"> 25 + <a href="#main-content" class="skip-nav">Skip to content</a> 26 + <nav class="border-b border-border px-4 py-4" aria-label="Main navigation"> 26 27 <div class="mx-auto flex max-w-3xl items-center justify-between"> 27 28 <div> 28 29 <a href="/" class="logo text-2xl"> ··· 38 39 </div> 39 40 </div> 40 41 </nav> 41 - {@render children()} 42 + <div id="main-content"> 43 + {@render children()} 44 + </div> 42 45 {/if} 43 - </div> 46 + </main> 44 47 45 48 <style> 46 49 .logo { ··· 56 59 position: relative; 57 60 top: 1px; 58 61 margin-right: -0.04em; 62 + } 63 + .skip-nav { 64 + position: absolute; 65 + left: -9999px; 66 + top: 0; 67 + z-index: 100; 68 + padding: 0.5rem 1rem; 69 + background: var(--color-accent-blue); 70 + color: white; 71 + font-weight: 600; 72 + text-decoration: none; 73 + } 74 + .skip-nav:focus { 75 + left: 0; 59 76 } 60 77 </style>
+1
src/routes/+page.svelte
··· 115 115 </script> 116 116 117 117 <div class="mx-auto max-w-2xl px-4 py-8"> 118 + <h1 class="sr-only">checkmate.blue - Chess on the Atmosphere</h1> 118 119 <div class="mb-8 flex justify-center gap-3"> 119 120 {#if auth.isLoggedIn} 120 121 <a
+5
src/routes/challenge/[did]/[rkey]/+page.svelte
··· 8 8 import type { ChallengeRecord } from '$lib/types'; 9 9 10 10 const challengerDid = $derived($page.params.did!); 11 + 11 12 const rkey = $derived($page.params.rkey!); 12 13 13 14 let challenge: ChallengeRecord | null = $state(null); ··· 82 83 } 83 84 } 84 85 </script> 86 + 87 + <svelte:head> 88 + <title>Challenge | checkmate.blue</title> 89 + </svelte:head> 85 90 86 91 {#if loading} 87 92 <div class="flex min-h-screen items-center justify-center">
+10 -5
src/routes/game/[did]/[rkey]/+page.svelte
··· 672 672 ); 673 673 </script> 674 674 675 + <svelte:head> 676 + <title>Game | checkmate.blue</title> 677 + </svelte:head> 678 + 675 679 {#if loading} 676 680 <div class="flex min-h-screen items-center justify-center"> 677 681 <p class="text-text-secondary">Loading game...</p> ··· 702 706 type="text" 703 707 readonly 704 708 value={gameUrl()} 709 + aria-label="Game invite link" 705 710 class="flex-1 rounded-lg border border-border bg-bg-primary px-3 py-2 font-mono text-xs text-text-primary" 706 711 /> 707 712 <button ··· 794 799 agreement: 'Draw by agreement', 795 800 abandonment: 'Opponent inactive', 796 801 }} 797 - <div class="rounded-lg p-4 text-center {iWin ? 'bg-success/10 border border-success' : iLose ? 'bg-danger/10 border border-danger' : 'bg-bg-secondary border border-border'}"> 802 + <div role="alert" class="rounded-lg p-4 text-center {iWin ? 'bg-success/10 border border-success' : iLose ? 'bg-danger/10 border border-danger' : 'bg-bg-secondary border border-border'}"> 798 803 <p class="text-lg font-bold"> 799 804 {#if isSpectator} 800 805 {#if isDraw} ··· 855 860 {/if} 856 861 </div> 857 862 {:else if game.isInCheck && game.status === 'active'} 858 - <div class="rounded-lg border border-warning bg-warning/10 px-4 py-2 text-center text-sm font-semibold text-warning"> 863 + <div role="alert" class="rounded-lg border border-warning bg-warning/10 px-4 py-2 text-center text-sm font-semibold text-warning"> 859 864 Check! 860 865 </div> 861 866 {/if} 862 867 863 868 {#if moveError} 864 - <div class="w-full max-w-md rounded-lg border border-danger bg-danger/10 p-3 text-center text-sm text-danger"> 869 + <div role="alert" class="w-full max-w-md rounded-lg border border-danger bg-danger/10 p-3 text-center text-sm text-danger"> 865 870 {moveError} 866 871 </div> 867 872 {/if} ··· 892 897 <div class="flex items-center gap-3 text-xs text-text-secondary"> 893 898 {#if game.status !== 'completed'} 894 899 <div class="flex items-center gap-2"> 895 - <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 900 + <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected} aria-hidden="true"></span> 896 901 {connected ? 'Live' : 'Reconnecting...'} 897 902 </div> 898 903 {/if} 899 904 <button 900 905 onclick={() => sound.toggle()} 901 906 class="transition-colors hover:text-text-primary" 902 - title={sound.muted ? 'Unmute' : 'Mute'} 907 + aria-label={sound.muted ? 'Unmute sound' : 'Mute sound'} 903 908 > 904 909 {sound.muted ? 'Sound off' : 'Sound on'} 905 910 </button>
+13
src/routes/layout.css
··· 24 24 color: var(--color-text-primary); 25 25 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 26 26 } 27 + 28 + :focus-visible { 29 + outline: 2px solid var(--color-accent-blue); 30 + outline-offset: 2px; 31 + } 32 + 33 + @media (prefers-reduced-motion: reduce) { 34 + *, *::before, *::after { 35 + animation-duration: 0.01ms !important; 36 + animation-iteration-count: 1 !important; 37 + transition-duration: 0.01ms !important; 38 + } 39 + }
+6 -1
src/routes/play/+page.svelte
··· 67 67 } 68 68 </script> 69 69 70 + <svelte:head> 71 + <title>New Game | checkmate.blue</title> 72 + </svelte:head> 73 + 70 74 <div class="flex min-h-screen flex-col items-center justify-center gap-8 p-4"> 71 75 <div class="text-center"> 72 76 <h1 class="text-3xl font-bold">New Game</h1> ··· 82 86 type="text" 83 87 bind:value={opponentHandle} 84 88 placeholder="Opponent handle (optional)" 85 - class="rounded-lg border border-border bg-bg-secondary px-4 py-2 text-text-primary placeholder:text-text-secondary focus:border-accent-blue focus:outline-none" 89 + aria-label="Opponent handle (optional)" 90 + class="rounded-lg border border-border bg-bg-secondary px-4 py-2 text-text-primary placeholder:text-text-secondary focus:border-accent-blue focus:ring-2 focus:ring-accent-blue/50" 86 91 /> 87 92 88 93 <fieldset class="flex gap-2">
+4
src/routes/profile/[handle]/+page.svelte
··· 50 50 }); 51 51 </script> 52 52 53 + <svelte:head> 54 + <title>{handle} | checkmate.blue</title> 55 + </svelte:head> 56 + 53 57 {#if loading} 54 58 <div class="flex min-h-screen items-center justify-center"> 55 59 <p class="text-text-secondary">Loading profile...</p>