https://checkmate.social
0
fork

Configure Feed

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

Add implementation plan: mobile-responsive design pass

Eight tasks: TDD MoveDrawer (Task 1), wire it into GameScreen with the
md-breakpoint switch (Task 2), Header tap-targets+truncate (Task 3),
PlayerBar tweaks (Task 4), GameStatus tap-target (Task 5), verify
Lobby/Login (Task 6), CLAUDE.md (Task 7), final acceptance (Task 8).

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

jcalabro 8131583a 68948789

+1068
+1068
docs/superpowers/plans/2026-04-15-mobile-responsive-design.md
··· 1 + # Mobile-Responsive Design Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Make all pages render well on phones down to iPhone SE 2/3 (375×600 effective viewport, portrait), with all touch targets ≥44×44 px. 6 + 7 + **Architecture:** Single Tailwind breakpoint (`md` at 768px) divides mobile from desktop. One new component (`MoveDrawer`) replaces the current "stacked-below" move list on mobile with a bottom-sheet drawer. All other changes are responsive class additions, padding adjustments, and tap-target bumps. No state, routing, or game-logic changes. 8 + 9 + **Tech Stack:** React 19 + Vite + Tailwind v4, vitest + @testing-library/react for tests, Chrome via the `mcp__claude-in-chrome__*` tools for visual verification. 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-15-mobile-responsive-design.md` 12 + 13 + ## Working notes 14 + 15 + - The dev server should be running at `http://127.0.0.1:5173/` (run `cd client && bun run dev` if not). Verify before starting. 16 + - The user's Chrome window is in mobile-emulation mode at ~400×717. Use `mcp__claude-in-chrome__tabs_context_mcp` then `mcp__claude-in-chrome__javascript_tool` to inspect the page after each change. Hard-reload via `location.reload()` after edits since Vite's HMR doesn't always pick up Tailwind class additions cleanly. 17 + - Run tests with `cd client && bun run test:ci` (single-run, CI mode). 18 + - Run typecheck with `cd client && npx tsc --noEmit`. 19 + - Each task ends with a commit. 20 + 21 + --- 22 + 23 + ## Task 1: Create the `MoveDrawer` component (TDD) 24 + 25 + **Files:** 26 + - Create: `client/src/components/game/MoveDrawer.tsx` 27 + - Create: `client/src/components/game/__tests__/MoveDrawer.test.tsx` 28 + 29 + This task introduces the only new logic in the change. The drawer is two stateless pieces: a trigger button and a panel. State (open/closed) lives in the consumer (`GameScreen`). 30 + 31 + - [ ] **Step 1.1: Write the failing tests** 32 + 33 + Create `client/src/components/game/__tests__/MoveDrawer.test.tsx`: 34 + 35 + ```tsx 36 + /** 37 + * MoveDrawer component tests. 38 + * 39 + * The drawer is two stateless pieces — a trigger and a panel. State lives 40 + * in the consumer; tests pass `open` / `onClose` / `onOpen` directly. 41 + */ 42 + 43 + import { describe, it, expect, vi } from 'vitest'; 44 + import { render, screen, fireEvent } from '@testing-library/react'; 45 + import { MoveDrawerTrigger, MoveDrawerPanel } from '../MoveDrawer'; 46 + 47 + describe('MoveDrawerTrigger', () => { 48 + it('renders a Moves button', () => { 49 + render(<MoveDrawerTrigger onOpen={vi.fn()} count={0} />); 50 + const btn = screen.getByRole('button', { name: /open move list/i }); 51 + expect(btn).toBeInTheDocument(); 52 + expect(btn).toHaveTextContent(/moves/i); 53 + }); 54 + 55 + it('calls onOpen when clicked', () => { 56 + const onOpen = vi.fn(); 57 + render(<MoveDrawerTrigger onOpen={onOpen} count={3} />); 58 + fireEvent.click(screen.getByRole('button')); 59 + expect(onOpen).toHaveBeenCalledOnce(); 60 + }); 61 + }); 62 + 63 + describe('MoveDrawerPanel', () => { 64 + it('renders nothing when closed', () => { 65 + const { container } = render( 66 + <MoveDrawerPanel open={false} onClose={vi.fn()} moves={[]} /> 67 + ); 68 + expect(container.firstChild).toBeNull(); 69 + }); 70 + 71 + it('renders a dialog when open', () => { 72 + render(<MoveDrawerPanel open={true} onClose={vi.fn()} moves={[]} />); 73 + expect(screen.getByRole('dialog')).toBeInTheDocument(); 74 + }); 75 + 76 + it('shows the move list inside the panel', () => { 77 + render( 78 + <MoveDrawerPanel 79 + open={true} 80 + onClose={vi.fn()} 81 + moves={[ 82 + { moveNumber: 1, san: 'e4' }, 83 + { moveNumber: 2, san: 'e5' }, 84 + ]} 85 + /> 86 + ); 87 + expect(screen.getByText('e4')).toBeInTheDocument(); 88 + expect(screen.getByText('e5')).toBeInTheDocument(); 89 + }); 90 + 91 + it('shows empty-state via underlying MoveList when moves is empty', () => { 92 + render(<MoveDrawerPanel open={true} onClose={vi.fn()} moves={[]} />); 93 + expect(screen.getByText('No moves yet')).toBeInTheDocument(); 94 + }); 95 + 96 + it('calls onClose when the close button is clicked', () => { 97 + const onClose = vi.fn(); 98 + render(<MoveDrawerPanel open={true} onClose={onClose} moves={[]} />); 99 + fireEvent.click(screen.getByLabelText(/close move list/i)); 100 + expect(onClose).toHaveBeenCalledOnce(); 101 + }); 102 + 103 + it('calls onClose when the backdrop is clicked', () => { 104 + const onClose = vi.fn(); 105 + render(<MoveDrawerPanel open={true} onClose={onClose} moves={[]} />); 106 + // Backdrop is the element with aria-hidden, which is the overlay div 107 + fireEvent.click(screen.getByTestId('move-drawer-backdrop')); 108 + expect(onClose).toHaveBeenCalledOnce(); 109 + }); 110 + 111 + it('calls onClose when Escape is pressed while open', () => { 112 + const onClose = vi.fn(); 113 + render(<MoveDrawerPanel open={true} onClose={onClose} moves={[]} />); 114 + fireEvent.keyDown(document, { key: 'Escape' }); 115 + expect(onClose).toHaveBeenCalledOnce(); 116 + }); 117 + 118 + it('does not respond to Escape when closed', () => { 119 + const onClose = vi.fn(); 120 + render(<MoveDrawerPanel open={false} onClose={onClose} moves={[]} />); 121 + fireEvent.keyDown(document, { key: 'Escape' }); 122 + expect(onClose).not.toHaveBeenCalled(); 123 + }); 124 + }); 125 + ``` 126 + 127 + - [ ] **Step 1.2: Run the tests and verify they fail** 128 + 129 + ```bash 130 + cd client && bun run test:ci -- MoveDrawer 131 + ``` 132 + 133 + Expected: All tests fail because `MoveDrawer.tsx` does not exist yet (module-resolution errors). 134 + 135 + - [ ] **Step 1.3: Implement `MoveDrawer.tsx`** 136 + 137 + Create `client/src/components/game/MoveDrawer.tsx`: 138 + 139 + ```tsx 140 + /** 141 + * MoveDrawer — bottom-sheet drawer that displays the move history on mobile. 142 + * 143 + * Composed of two stateless pieces so the trigger can be slotted into the 144 + * `PlayerBar`'s action slot while the panel sits at the screen level: 145 + * 146 + * - `MoveDrawerTrigger` — small "Moves" button shown only on mobile (md:hidden) 147 + * - `MoveDrawerPanel` — backdrop + slide-up sheet wrapping <MoveList /> 148 + * 149 + * State (open/closed) lives in the consumer (GameScreen) because the trigger 150 + * and panel are visually independent and the consumer is responsible for 151 + * closing the drawer when, e.g., the active game changes. 152 + * 153 + * The panel is dismissible via: 154 + * - Tapping the backdrop 155 + * - Tapping the X button 156 + * - Pressing Escape 157 + * 158 + * Both pieces are gated on `md:hidden` — the desktop sidebar MoveList in 159 + * GameScreen is the desktop treatment. 160 + */ 161 + 162 + import { useEffect } from 'react'; 163 + import { MoveList } from './MoveList'; 164 + 165 + interface Move { 166 + moveNumber: number; 167 + san: string; 168 + } 169 + 170 + // ─── Trigger ───────────────────────────────────────────────────────────────── 171 + 172 + interface MoveDrawerTriggerProps { 173 + onOpen: () => void; 174 + /** Used in the aria-label so screen readers know how many moves exist */ 175 + count: number; 176 + } 177 + 178 + export function MoveDrawerTrigger({ onOpen, count }: MoveDrawerTriggerProps) { 179 + return ( 180 + <button 181 + onClick={onOpen} 182 + className="flex min-h-[44px] items-center gap-1 rounded-md border border-wood-600 bg-wood-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-wood-200 shadow-sm transition-colors hover:border-wood-400 hover:text-wood-100 md:hidden" 183 + aria-label={`Open move list (${count} ${count === 1 ? 'move' : 'moves'})`} 184 + > 185 + Moves 186 + <span aria-hidden="true" className="text-wood-500">▴</span> 187 + </button> 188 + ); 189 + } 190 + 191 + // ─── Panel ─────────────────────────────────────────────────────────────────── 192 + 193 + interface MoveDrawerPanelProps { 194 + open: boolean; 195 + onClose: () => void; 196 + moves: Move[]; 197 + } 198 + 199 + export function MoveDrawerPanel({ open, onClose, moves }: MoveDrawerPanelProps) { 200 + // Close on Escape — only attach the listener while open so the closed 201 + // panel doesn't intercept keystrokes meant for other parts of the page. 202 + useEffect(() => { 203 + if (!open) return; 204 + const onKey = (e: KeyboardEvent) => { 205 + if (e.key === 'Escape') onClose(); 206 + }; 207 + document.addEventListener('keydown', onKey); 208 + return () => document.removeEventListener('keydown', onKey); 209 + }, [open, onClose]); 210 + 211 + if (!open) return null; 212 + 213 + return ( 214 + <div className="md:hidden" role="dialog" aria-modal="true" aria-label="Move history"> 215 + {/* Backdrop — fixed full-screen, dismisses on tap */} 216 + <div 217 + data-testid="move-drawer-backdrop" 218 + className="fixed inset-0 z-20 bg-black/60" 219 + onClick={onClose} 220 + aria-hidden="true" 221 + /> 222 + 223 + {/* Sheet — pinned to bottom of viewport, ≤70vh tall */} 224 + <div 225 + className="fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-wood-600 bg-wood-800 shadow-2xl" 226 + // env() pads around iOS Safari's home-indicator strip so the close 227 + // button isn't occluded when the bottom toolbar collapses. 228 + style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} 229 + > 230 + {/* Grab handle (visual affordance only — swipe-to-dismiss is a 231 + future enhancement, not in this scope) */} 232 + <div aria-hidden="true" className="mx-auto mt-2 h-1 w-10 rounded-full bg-wood-500" /> 233 + 234 + {/* Header row */} 235 + <div className="flex items-center justify-between px-4 py-2"> 236 + <h3 className="text-xs font-medium uppercase tracking-widest text-wood-300"> 237 + Moves 238 + </h3> 239 + <button 240 + onClick={onClose} 241 + className="flex min-h-[44px] min-w-[44px] items-center justify-center text-lg text-wood-400 transition-colors hover:text-wood-100" 242 + aria-label="Close move list" 243 + > 244 + 245 + </button> 246 + </div> 247 + 248 + {/* Move list — fills remaining height inside the sheet */} 249 + <div className="min-h-0 flex-1 overflow-hidden px-4 pb-4"> 250 + <MoveList moves={moves} /> 251 + </div> 252 + </div> 253 + </div> 254 + ); 255 + } 256 + ``` 257 + 258 + - [ ] **Step 1.4: Run the tests and verify they pass** 259 + 260 + ```bash 261 + cd client && bun run test:ci -- MoveDrawer 262 + ``` 263 + 264 + Expected: All 9 tests pass. 265 + 266 + - [ ] **Step 1.5: Run typecheck** 267 + 268 + ```bash 269 + cd client && npx tsc --noEmit 270 + ``` 271 + 272 + Expected: No errors. 273 + 274 + - [ ] **Step 1.6: Commit** 275 + 276 + ```bash 277 + git add client/src/components/game/MoveDrawer.tsx client/src/components/game/__tests__/MoveDrawer.test.tsx 278 + git commit -m "$(cat <<'EOF' 279 + Add MoveDrawer component for mobile move history 280 + 281 + Bottom-sheet drawer that wraps the existing MoveList. Two stateless 282 + pieces (trigger + panel) so the trigger can be slotted into PlayerBar 283 + while the panel sits at screen level. Dismissible via backdrop tap, 284 + close button, or Escape key. Mobile-only (md:hidden); desktop continues 285 + to use the sidebar MoveList. 286 + 287 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 288 + EOF 289 + )" 290 + ``` 291 + 292 + --- 293 + 294 + ## Task 2: Wire `MoveDrawer` into `GameScreen` and switch breakpoint to `md` 295 + 296 + **Files:** 297 + - Modify: `client/src/components/game/GameScreen.tsx` 298 + 299 + This task changes the responsive layout from "stack on mobile, side-by-side on `lg`" to "drawer on mobile, sidebar on `md`+". It also reduces `p-4`→`p-2` on mobile to give the board ~16px more width on small phones. 300 + 301 + - [ ] **Step 2.1: Confirm the dev server is running** 302 + 303 + ```bash 304 + curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5173/ --max-time 3 305 + ``` 306 + 307 + Expected: `200`. If not, in another terminal: `cd client && bun run dev`. 308 + 309 + - [ ] **Step 2.2: Modify `GameScreen.tsx`** 310 + 311 + In `client/src/components/game/GameScreen.tsx`: 312 + 313 + **Add the import** (alongside the existing imports near the top): 314 + 315 + ```tsx 316 + import { MoveDrawerPanel, MoveDrawerTrigger } from './MoveDrawer'; 317 + ``` 318 + 319 + **Add drawer state** inside the `GameScreen` component, near the existing `useState` for `showResignConfirm`: 320 + 321 + ```tsx 322 + const [moveDrawerOpen, setMoveDrawerOpen] = useState(false); 323 + 324 + // Auto-close the drawer when the active game changes so a stale "open" 325 + // state doesn't carry over into the next game. 326 + useEffect(() => { 327 + setMoveDrawerOpen(false); 328 + }, [activeGame?.id]); 329 + ``` 330 + 331 + **Build a combined bottom-bar action** that includes both the moves trigger (mobile) and the resign control (current). Replace the existing `resignAction` definition with this block: 332 + 333 + ```tsx 334 + const resignAction = activeGame.status === 'active' ? ( 335 + showResignConfirm ? ( 336 + <div className="flex items-center gap-1.5"> 337 + <span className="hidden text-xs font-medium text-wood-300 sm:inline">Resign?</span> 338 + <button 339 + onClick={handleResign} 340 + className="min-h-[44px] rounded-md bg-red-700 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-white shadow-sm transition-colors hover:bg-red-600" 341 + aria-label="Confirm resign" 342 + > 343 + Yes 344 + </button> 345 + <button 346 + onClick={() => setShowResignConfirm(false)} 347 + className="min-h-[44px] rounded-md border border-wood-500 bg-wood-800 px-2.5 py-1 text-xs font-medium text-wood-200 transition-colors hover:border-wood-300 hover:text-wood-100" 348 + aria-label="Cancel resign" 349 + > 350 + No 351 + </button> 352 + </div> 353 + ) : ( 354 + <button 355 + onClick={() => setShowResignConfirm(true)} 356 + className="min-h-[44px] rounded-md border border-red-900/70 bg-wood-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-red-300 shadow-sm transition-colors hover:border-red-600 hover:bg-red-900/50 hover:text-red-100" 357 + > 358 + Resign 359 + </button> 360 + ) 361 + ) : undefined; 362 + 363 + // Bottom-bar action: moves trigger (mobile-only via md:hidden in MoveDrawerTrigger) 364 + // followed by the resign control. Wrapped in a flex group so they align nicely. 365 + const bottomBarAction = activeGame.status === 'active' ? ( 366 + <div className="flex items-center gap-2"> 367 + <MoveDrawerTrigger 368 + onOpen={() => setMoveDrawerOpen(true)} 369 + count={moves.length} 370 + /> 371 + {resignAction} 372 + </div> 373 + ) : undefined; 374 + ``` 375 + 376 + **Update the bottom `<PlayerBar>` to use the new combined action:** 377 + 378 + ```tsx 379 + <PlayerBar 380 + displayName={isSolo ? (bottomColor === 'white' ? 'White' : 'Black') : (displayName ?? 'You')} 381 + handle={isSolo ? undefined : (handle ?? undefined)} 382 + avatarUrl={isSolo ? undefined : (avatarUrl ?? undefined)} 383 + isActive={bottomIsActive} 384 + color={bottomColor} 385 + clock={bottomClock} 386 + action={bottomBarAction} 387 + /> 388 + ``` 389 + 390 + **Change the outer container's responsive classes** so the divider becomes `md` instead of `lg`, and reduce padding/gap on mobile. Replace: 391 + 392 + ```tsx 393 + <div className="flex flex-1 flex-col items-center justify-center gap-4 p-4 lg:flex-row"> 394 + ``` 395 + 396 + with: 397 + 398 + ```tsx 399 + <div className="flex flex-1 flex-col items-center justify-center gap-0 p-2 md:flex-row md:gap-4 md:p-4"> 400 + ``` 401 + 402 + **Replace the move list sidebar `<div>`** so it's hidden on mobile and uses the desktop layout at `md`+. Replace: 403 + 404 + ```tsx 405 + {/* Move list sidebar */} 406 + <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"> 407 + <h3 className="mb-2 text-xs font-medium uppercase tracking-widest text-wood-500"> 408 + Moves 409 + </h3> 410 + <div className="h-[calc(100%-2rem)]"> 411 + <MoveList moves={moves} /> 412 + </div> 413 + </div> 414 + ``` 415 + 416 + with: 417 + 418 + ```tsx 419 + {/* Move list sidebar — desktop only; mobile uses MoveDrawerPanel below */} 420 + <div className="hidden h-[500px] w-56 overflow-hidden rounded-xl border border-wood-600 bg-wood-800 p-3 shadow-lg md:block"> 421 + <h3 className="mb-2 text-xs font-medium uppercase tracking-widest text-wood-500"> 422 + Moves 423 + </h3> 424 + <div className="h-[calc(100%-2rem)]"> 425 + <MoveList moves={moves} /> 426 + </div> 427 + </div> 428 + 429 + {/* Move drawer — mobile only; renders nothing when closed */} 430 + <MoveDrawerPanel 431 + open={moveDrawerOpen} 432 + onClose={() => setMoveDrawerOpen(false)} 433 + moves={moves} 434 + /> 435 + ``` 436 + 437 + - [ ] **Step 2.3: Run typecheck** 438 + 439 + ```bash 440 + cd client && npx tsc --noEmit 441 + ``` 442 + 443 + Expected: No errors. 444 + 445 + - [ ] **Step 2.4: Run all tests** 446 + 447 + ```bash 448 + cd client && bun run test:ci 449 + ``` 450 + 451 + Expected: All tests pass (the `GameScreen` is not directly unit-tested, so no regression there; the `MoveDrawer` tests from Task 1 should still pass). 452 + 453 + - [ ] **Step 2.5: Visually verify in the browser at mobile size** 454 + 455 + Reload the page in the user's mobile-emulated tab and measure: 456 + 457 + ```js 458 + // Run via mcp__claude-in-chrome__javascript_tool on the user's tab 459 + location.reload(); 460 + // Wait a moment, then: 461 + const measure = (sel) => { 462 + const el = document.querySelector(sel); 463 + if (!el) return null; 464 + const r = el.getBoundingClientRect(); 465 + return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), bottom: Math.round(r.bottom) }; 466 + }; 467 + JSON.stringify({ 468 + viewport: { w: window.innerWidth, h: window.innerHeight, scroll: document.documentElement.scrollHeight }, 469 + header: measure('header'), 470 + topBar: measure('main > div > div:first-child > div:nth-child(1)'), 471 + boardBox: measure('main > div > div:first-child > div:nth-child(2)'), 472 + bottomBar: measure('main > div > div:first-child > div:nth-child(3)'), 473 + // Sidebar should NOT exist on mobile (display:none from md:block) 474 + sidebarVisible: !!document.querySelector('main > div > div.hidden:not(.md\\:block)'), 475 + }, null, 2); 476 + ``` 477 + 478 + Expected at 400×~717 viewport: 479 + - `viewport.scroll === viewport.h` (no scroll) 480 + - `boardBox.w` ≥ ~380 (wider than the previous 368, because `p-2` saved 16px of horizontal space) 481 + - All elements visible above the fold 482 + 483 + If the user has DevTools to switch to a 375×600 preset (iPhone SE), have them switch and re-run the script — board should still render at 359×359 with no scroll. 484 + 485 + - [ ] **Step 2.6: Verify the drawer opens and closes correctly in the browser** 486 + 487 + Tap the "Moves" button in the bottom bar: 488 + 489 + ```js 490 + // Find and click the moves trigger 491 + const trigger = Array.from(document.querySelectorAll('button')).find( 492 + b => /open move list/i.test(b.getAttribute('aria-label') || '') 493 + ); 494 + trigger.click(); 495 + // Verify dialog appears 496 + JSON.stringify({ 497 + dialogPresent: !!document.querySelector('[role="dialog"]'), 498 + backdropPresent: !!document.querySelector('[data-testid="move-drawer-backdrop"]'), 499 + }); 500 + ``` 501 + 502 + Expected: `{ "dialogPresent": true, "backdropPresent": true }`. 503 + 504 + Then click the close button: 505 + 506 + ```js 507 + document.querySelector('[aria-label="Close move list"]').click(); 508 + JSON.stringify({ 509 + dialogPresent: !!document.querySelector('[role="dialog"]'), 510 + }); 511 + ``` 512 + 513 + Expected: `{ "dialogPresent": false }`. 514 + 515 + - [ ] **Step 2.7: Commit** 516 + 517 + ```bash 518 + git add client/src/components/game/GameScreen.tsx 519 + git commit -m "$(cat <<'EOF' 520 + Wire MoveDrawer into GameScreen and switch breakpoint to md 521 + 522 + Mobile (<768px) now uses the MoveDrawerPanel triggered from the bottom 523 + PlayerBar; desktop (>=768px) keeps the side-by-side sidebar layout. 524 + Outer padding reduced to p-2 on mobile so the board reclaims ~16px of 525 + horizontal space on small phones. Drawer auto-closes when activeGame.id 526 + changes to avoid a stale-open state across games. 527 + 528 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 529 + EOF 530 + )" 531 + ``` 532 + 533 + --- 534 + 535 + ## Task 3: Bump tap targets and add truncation in `Header` 536 + 537 + **Files:** 538 + - Modify: `client/src/components/layout/Header.tsx` 539 + 540 + - [ ] **Step 3.1: Modify `Header.tsx`** 541 + 542 + Replace the entire return body in `client/src/components/layout/Header.tsx` (the `<header>` JSX) with: 543 + 544 + ```tsx 545 + return ( 546 + <header className="flex items-center justify-between border-b border-wood-700 bg-wood-900/90 px-4 py-3 backdrop-blur-sm md:px-5"> 547 + {/* Branding */} 548 + <div className="flex min-w-0 items-center gap-2.5"> 549 + <span className="select-none text-lg text-gold-500">♔</span> 550 + <h1 className="font-serif text-xl font-bold tracking-tight text-gold-400"> 551 + Checkmate 552 + </h1> 553 + <div 554 + className={`ml-0.5 h-1.5 w-1.5 shrink-0 rounded-full transition-colors ${connected ? 'bg-felt-500' : 'bg-red-500'}`} 555 + title={connected ? 'Connected' : 'Disconnected'} 556 + /> 557 + </div> 558 + 559 + {/* User controls */} 560 + {handle && ( 561 + <div className="flex min-w-0 items-center gap-3"> 562 + <div className="flex min-w-0 items-center gap-2"> 563 + {avatarUrl && ( 564 + <img 565 + src={avatarUrl} 566 + alt={displayName ?? handle} 567 + className="h-7 w-7 shrink-0 rounded-full ring-1 ring-wood-600" 568 + /> 569 + )} 570 + <span className="min-w-0 truncate text-sm text-wood-300"> 571 + {displayName ?? handle} 572 + </span> 573 + </div> 574 + <button 575 + onClick={logout} 576 + className="flex min-h-[44px] shrink-0 items-center rounded px-3 text-xs text-wood-500 transition-colors hover:bg-wood-700 hover:text-wood-200" 577 + > 578 + Sign out 579 + </button> 580 + </div> 581 + )} 582 + </header> 583 + ); 584 + ``` 585 + 586 + The key changes: 587 + - `min-w-0` on flex children that contain text (without it, `truncate` is a no-op inside a flex container) 588 + - `truncate` on the display-name span 589 + - `shrink-0` on icons/buttons that should keep their size 590 + - `min-h-[44px]` on the sign-out button so the hit box meets the floor 591 + - `px-4 md:px-5` for slightly tighter mobile padding 592 + 593 + - [ ] **Step 3.2: Run typecheck** 594 + 595 + ```bash 596 + cd client && npx tsc --noEmit 597 + ``` 598 + 599 + Expected: No errors. 600 + 601 + - [ ] **Step 3.3: Visually verify in the browser** 602 + 603 + ```js 604 + location.reload(); 605 + // then: 606 + JSON.stringify({ 607 + signOutBtn: (() => { 608 + const b = Array.from(document.querySelectorAll('button')).find(b => /sign out/i.test(b.textContent)); 609 + if (!b) return null; 610 + const r = b.getBoundingClientRect(); 611 + return { w: Math.round(r.width), h: Math.round(r.height) }; 612 + })(), 613 + noHorizontalScroll: document.documentElement.scrollWidth === document.documentElement.clientWidth, 614 + }); 615 + ``` 616 + 617 + Expected: `signOutBtn.h >= 44`, `noHorizontalScroll: true`. 618 + 619 + - [ ] **Step 3.4: Verify long display name truncates** 620 + 621 + Temporarily simulate a long display name to verify truncation: 622 + 623 + ```js 624 + const span = document.querySelector('header span.truncate'); 625 + const original = span.textContent; 626 + span.textContent = 'this-is-an-unreasonably-long-display-name-for-testing.bsky.social'; 627 + const overflow = document.documentElement.scrollWidth > document.documentElement.clientWidth; 628 + span.textContent = original; 629 + JSON.stringify({ overflow }); 630 + ``` 631 + 632 + Expected: `{ "overflow": false }` — the page does not get a horizontal scrollbar even with a very long name. 633 + 634 + - [ ] **Step 3.5: Commit** 635 + 636 + ```bash 637 + git add client/src/components/layout/Header.tsx 638 + git commit -m "$(cat <<'EOF' 639 + Header: truncate long display names, bump sign-out tap target to 44px 640 + 641 + Adds min-w-0 on flex parents (without which truncate is a no-op inside 642 + flex), truncate + ellipsis on the display name, and min-h-[44px] on the 643 + sign-out button so the hit area meets Apple HIG's 44px floor on touch 644 + devices. Visual chrome unchanged. 645 + 646 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 647 + EOF 648 + )" 649 + ``` 650 + 651 + --- 652 + 653 + ## Task 4: Tighten `PlayerBar` for mobile 654 + 655 + **Files:** 656 + - Modify: `client/src/components/game/PlayerBar.tsx` 657 + 658 + - [ ] **Step 4.1: Modify `PlayerBar.tsx`** 659 + 660 + Two small changes — the outer flex `gap` becomes responsive, and the avatar gets a touch larger: 661 + 662 + Find: 663 + 664 + ```tsx 665 + <div 666 + className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors ${ 667 + isActive 668 + ? 'border border-felt-600/60 bg-wood-700' 669 + : 'border border-transparent bg-wood-800' 670 + }`} 671 + > 672 + ``` 673 + 674 + Replace with: 675 + 676 + ```tsx 677 + <div 678 + className={`flex items-center gap-2 rounded-lg px-3 py-2 transition-colors md:gap-3 ${ 679 + isActive 680 + ? 'border border-felt-600/60 bg-wood-700' 681 + : 'border border-transparent bg-wood-800' 682 + }`} 683 + > 684 + ``` 685 + 686 + Find both occurrences of the avatar/placeholder size `h-8 w-8` and replace each with `h-9 w-9`: 687 + 688 + ```tsx 689 + {avatarUrl ? ( 690 + <img 691 + src={avatarUrl} 692 + alt={displayName} 693 + className="h-9 w-9 shrink-0 rounded-full ring-1 ring-wood-600" 694 + /> 695 + ) : ( 696 + <div 697 + className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold ${ 698 + color === 'white' 699 + ? 'bg-wood-200 text-wood-800' 700 + : 'bg-wood-700 text-wood-200' 701 + }`} 702 + > 703 + {color === 'white' ? '♔' : '♚'} 704 + </div> 705 + )} 706 + ``` 707 + 708 + (Also adds `shrink-0` to both — without it, an avatar can shrink when the action slot is busy at narrow widths.) 709 + 710 + - [ ] **Step 4.2: Run typecheck** 711 + 712 + ```bash 713 + cd client && npx tsc --noEmit 714 + ``` 715 + 716 + Expected: No errors. 717 + 718 + - [ ] **Step 4.3: Run tests** 719 + 720 + ```bash 721 + cd client && bun run test:ci 722 + ``` 723 + 724 + Expected: All tests pass. (Verified at plan-writing time that `PlayerBar.test.tsx` does not assert on any CSS classes, so it should not need updates.) 725 + 726 + - [ ] **Step 4.4: Visually verify** 727 + 728 + ```js 729 + location.reload(); 730 + JSON.stringify({ 731 + avatars: Array.from(document.querySelectorAll('main img, main [class*="rounded-full"]')) 732 + .filter(el => { 733 + const r = el.getBoundingClientRect(); 734 + return r.width <= 50 && r.width >= 30; // exclude oversize and tiny dots 735 + }) 736 + .map(el => { 737 + const r = el.getBoundingClientRect(); 738 + return { w: Math.round(r.width), h: Math.round(r.height) }; 739 + }), 740 + }); 741 + ``` 742 + 743 + Expected: avatars/placeholders measure ~36×36px (Tailwind `h-9 w-9`). 744 + 745 + - [ ] **Step 4.5: Commit** 746 + 747 + ```bash 748 + git add client/src/components/game/PlayerBar.tsx 749 + git commit -m "$(cat <<'EOF' 750 + PlayerBar: shrink-0 avatars and tighter mobile gap 751 + 752 + Avatars and color placeholders bump from h-8 to h-9 and gain shrink-0 753 + so they don't collapse when the action slot is busy at narrow widths. 754 + Outer gap drops to gap-2 on mobile (md:gap-3 on desktop) so a two-button 755 + action group doesn't push the clock against the name. 756 + 757 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 758 + EOF 759 + )" 760 + ``` 761 + 762 + --- 763 + 764 + ## Task 5: Bump tap target on `GameStatus` "Back to Lobby" button 765 + 766 + **Files:** 767 + - Modify: `client/src/components/game/GameStatus.tsx` 768 + 769 + - [ ] **Step 5.1: Modify `GameStatus.tsx`** 770 + 771 + Find the "Back to Lobby" button: 772 + 773 + ```tsx 774 + <button 775 + onClick={onBackToLobby} 776 + 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" 777 + > 778 + Back to Lobby 779 + </button> 780 + ``` 781 + 782 + Replace with: 783 + 784 + ```tsx 785 + <button 786 + onClick={onBackToLobby} 787 + className="mt-6 flex min-h-[44px] w-full items-center justify-center 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" 788 + > 789 + Back to Lobby 790 + </button> 791 + ``` 792 + 793 + (Adds `flex items-center justify-center min-h-[44px]` — keeps text centered while the hit box meets the 44px floor.) 794 + 795 + - [ ] **Step 5.2: Run typecheck and tests** 796 + 797 + ```bash 798 + cd client && npx tsc --noEmit && bun run test:ci 799 + ``` 800 + 801 + Expected: All pass. 802 + 803 + - [ ] **Step 5.3: Commit** 804 + 805 + ```bash 806 + git add client/src/components/game/GameStatus.tsx 807 + git commit -m "$(cat <<'EOF' 808 + GameStatus: bump 'Back to Lobby' button to 44px tap height 809 + 810 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 811 + EOF 812 + )" 813 + ``` 814 + 815 + --- 816 + 817 + ## Task 6: Verify `LobbyScreen` and `LoginScreen` at mobile sizes 818 + 819 + These screens are predicted to be fine — both are already constrained by `max-w-{xs,sm}` and use comfortable padding. We will verify each one in the browser before declaring done, and only modify if there's a measurable issue. 820 + 821 + **Files:** 822 + - Inspect: `client/src/components/lobby/LobbyScreen.tsx` 823 + - Inspect: `client/src/components/auth/LoginScreen.tsx` 824 + - Modify (if needed): same files 825 + 826 + - [ ] **Step 6.1: Reach the LobbyScreen in the browser** 827 + 828 + If currently in a game, the user will need to log out and log back in (since active games auto-route to GameScreen). Alternative: dismiss the active game by ending it (resign) and tapping "Back to Lobby". 829 + 830 + ```js 831 + // Quick way to check current screen: 832 + JSON.stringify({ 833 + hasLogin: !!document.querySelector('input[id="handle"]'), 834 + hasLobby: !!document.querySelector('main button[disabled], main button:not([aria-label])'), 835 + hasGame: !!document.querySelector('[id*="checkmate-board"]'), 836 + }); 837 + ``` 838 + 839 + If not on the lobby, ask the user to navigate there (or sign out so we can verify the LoginScreen instead, then back to lobby). 840 + 841 + - [ ] **Step 6.2: Measure LobbyScreen at the current viewport** 842 + 843 + ```js 844 + JSON.stringify({ 845 + viewport: { w: window.innerWidth, h: window.innerHeight, scroll: document.documentElement.scrollHeight }, 846 + buttons: Array.from(document.querySelectorAll('main button')).map(b => { 847 + const r = b.getBoundingClientRect(); 848 + return { txt: (b.textContent || '').trim().substring(0, 24), w: Math.round(r.width), h: Math.round(r.height) }; 849 + }), 850 + noHorizontalScroll: document.documentElement.scrollWidth === document.documentElement.clientWidth, 851 + }, null, 2); 852 + ``` 853 + 854 + **Acceptance:** 855 + - `viewport.scroll === viewport.h` (no scroll) 856 + - All buttons in `main` measure ≥44 in height 857 + - `noHorizontalScroll: true` 858 + 859 + If a button is < 44 tall, find it in `LobbyScreen.tsx` and add `min-h-[44px]` plus `flex items-center justify-center` (matching the GameStatus pattern from Task 5). 860 + 861 + - [ ] **Step 6.3: Verify LoginScreen — sign out first** 862 + 863 + Click "Sign out" in the Header. Then: 864 + 865 + ```js 866 + JSON.stringify({ 867 + viewport: { w: window.innerWidth, h: window.innerHeight, scroll: document.documentElement.scrollHeight }, 868 + buttons: Array.from(document.querySelectorAll('button')).map(b => { 869 + const r = b.getBoundingClientRect(); 870 + return { txt: (b.textContent || '').trim().substring(0, 24), w: Math.round(r.width), h: Math.round(r.height) }; 871 + }), 872 + inputs: Array.from(document.querySelectorAll('input')).map(i => { 873 + const r = i.getBoundingClientRect(); 874 + return { id: i.id, w: Math.round(r.width), h: Math.round(r.height) }; 875 + }), 876 + noHorizontalScroll: document.documentElement.scrollWidth === document.documentElement.clientWidth, 877 + }, null, 2); 878 + ``` 879 + 880 + **Acceptance:** 881 + - `viewport.scroll === viewport.h` (no scroll) 882 + - The handle input measures ≥44 in height 883 + - The sign-in button measures ≥44 in height 884 + - `noHorizontalScroll: true` 885 + 886 + The sign-in button currently uses `py-3` + `text-base` (~48px), and the input uses `py-3` + `text-base` (~48px) — both should pass. But if the measurement says otherwise, add `min-h-[44px]` to whichever fails. 887 + 888 + - [ ] **Step 6.4: Commit only if changes were made** 889 + 890 + ```bash 891 + # Only if any file was modified during this task: 892 + git add client/src/components/lobby/LobbyScreen.tsx client/src/components/auth/LoginScreen.tsx 893 + git commit -m "$(cat <<'EOF' 894 + Bump tap targets on LobbyScreen / LoginScreen as needed for mobile 895 + 896 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 897 + EOF 898 + )" 899 + ``` 900 + 901 + If nothing changed: skip the commit and note in your task summary that both screens passed verification unchanged. 902 + 903 + - [ ] **Step 6.5: Log back in if needed** 904 + 905 + If the user signed out for the LoginScreen check, ask them to sign back in (or stay logged out — depends on what the next task needs). 906 + 907 + --- 908 + 909 + ## Task 7: Document mobile-support requirement in `CLAUDE.md` 910 + 911 + **Files:** 912 + - Modify: `CLAUDE.md` 913 + 914 + The user's most important requirement: future contributors (including future Claude sessions) need to know that the app must support mobile so we don't regress. 915 + 916 + - [ ] **Step 7.1: Add a "Mobile support" section to `CLAUDE.md`** 917 + 918 + Open `CLAUDE.md`. Find a logical insertion point — after the "## Domain" section at the bottom is a good fit. Append (or insert after the Domain section) the following: 919 + 920 + ```markdown 921 + ## Mobile support 922 + 923 + This app must work well on phones in **portrait orientation**, with iPhone SE 924 + 2/3 (375×667 device, ~375×600 effective viewport in browser) as the floor we 925 + explicitly support. 926 + 927 + When changing layout-bearing components (`GameScreen`, `Header`, `PlayerBar`, 928 + `LobbyScreen`, `LoginScreen`, `GameStatus`), verify mobile rendering by: 929 + 930 + 1. Loading the page in Chrome with DevTools device-toolbar emulation set to 931 + ~375×600 (iPhone SE) or similar. 932 + 2. Confirming the page fits without vertical scrolling. 933 + 3. Confirming all persistent touch targets are ≥44×44 px (use the measurement 934 + script in `docs/superpowers/plans/2026-04-15-mobile-responsive-design.md`, 935 + Step 2.5, as a starting point). 936 + 937 + Breakpoint policy: 938 + - Use Tailwind's `md` (768px) as the **mobile/desktop divider** for layout 939 + treatments. Below `md` is the mobile treatment. 940 + - The `GameScreen` move list uses a bottom-sheet drawer (`MoveDrawer`) on 941 + mobile and a sidebar on desktop. 942 + - Tap targets use `min-h-[44px]` (and `min-w-[44px]` for icon-only buttons). 943 + - Avoid hand-rolled `@media` queries — use Tailwind variant classes for 944 + greppability. 945 + 946 + **Out of scope** (don't break it, but no commitment to support): 947 + - Landscape orientation on phones 948 + - iPad-specific layouts (currently picks up the desktop treatment at `md`) 949 + - Older devices below iPhone SE 2 (375px wide) 950 + 951 + See `docs/superpowers/specs/2026-04-15-mobile-responsive-design.md` for the 952 + full design rationale. 953 + ``` 954 + 955 + - [ ] **Step 7.2: Verify the file is well-formed** 956 + 957 + ```bash 958 + # Quick sanity check that markdown headings still make sense 959 + grep -n '^##' /Users/jcalabro/go/src/tangled.sh/calabro.io/checkmate/CLAUDE.md 960 + ``` 961 + 962 + Expected: A reasonable list of `## Heading` lines, with "Mobile support" appearing in a sensible location (typically near the bottom). 963 + 964 + - [ ] **Step 7.3: Commit** 965 + 966 + ```bash 967 + git add CLAUDE.md 968 + git commit -m "$(cat <<'EOF' 969 + Document mobile-support requirement in CLAUDE.md 970 + 971 + Future contributors need to know that the app must work well on phones 972 + down to iPhone SE 2/3 (375x600). Codifies the md breakpoint policy and 973 + the 44px tap-target floor so the next layout change doesn't silently 974 + regress mobile. 975 + 976 + Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 977 + EOF 978 + )" 979 + ``` 980 + 981 + --- 982 + 983 + ## Task 8: Final acceptance verification 984 + 985 + **Files:** none modified — this task only verifies. 986 + 987 + - [ ] **Step 8.1: Run the full test suite** 988 + 989 + ```bash 990 + cd client && bun run test:ci 991 + ``` 992 + 993 + Expected: All tests pass. 994 + 995 + - [ ] **Step 8.2: Run typecheck** 996 + 997 + ```bash 998 + cd client && npx tsc --noEmit 999 + ``` 1000 + 1001 + Expected: No errors. 1002 + 1003 + - [ ] **Step 8.3: Visit each screen and confirm acceptance criteria** 1004 + 1005 + For each of LoginScreen, LobbyScreen, GameScreen (in a fresh game), and the GameStatus modal (after resigning a solo game): 1006 + 1007 + ```js 1008 + location.reload(); 1009 + // Navigate to the relevant screen, then: 1010 + JSON.stringify({ 1011 + viewport: { w: window.innerWidth, h: window.innerHeight, scroll: document.documentElement.scrollHeight }, 1012 + noHorizontalScroll: document.documentElement.scrollWidth === document.documentElement.clientWidth, 1013 + noVerticalScroll: document.documentElement.scrollHeight <= window.innerHeight + 1, 1014 + smallButtons: Array.from(document.querySelectorAll('button')).filter(b => { 1015 + const r = b.getBoundingClientRect(); 1016 + return r.width > 0 && r.height > 0 && r.height < 44; 1017 + }).map(b => ({ txt: (b.textContent || '').trim().substring(0, 30), h: Math.round(b.getBoundingClientRect().height) })), 1018 + }, null, 2); 1019 + ``` 1020 + 1021 + **Pass criteria (per spec acceptance criteria):** 1022 + - `noHorizontalScroll: true` everywhere 1023 + - `noVerticalScroll: true` on GameScreen at the user's mobile viewport 1024 + - `smallButtons: []` on every screen 1025 + 1026 + If any button shows up in `smallButtons`, fix it (find it in source, add `min-h-[44px]`, recommit) and re-run this step. 1027 + 1028 + - [ ] **Step 8.4: Drawer end-to-end check** 1029 + 1030 + On the GameScreen, in a multiplayer or solo game: 1031 + 1. Open the drawer (tap "Moves"). 1032 + 2. Confirm the dialog appears with the move list visible. 1033 + 3. Tap the backdrop — drawer closes. 1034 + 4. Re-open. Tap the X — drawer closes. 1035 + 5. Re-open. Press Escape — drawer closes. 1036 + 6. Tap "Moves" again — drawer reopens with current moves. 1037 + 1038 + If any of these fail, return to Task 1 / Task 2 to debug. 1039 + 1040 + - [ ] **Step 8.5: Desktop regression check** 1041 + 1042 + Have the user disable DevTools device emulation (or open a separate non-emulated tab) and load the app at a desktop viewport (≥1024px). Confirm: 1043 + - The Header looks identical to before. 1044 + - The GameScreen shows the side-by-side board + sidebar move list (no drawer trigger button visible). 1045 + - The LobbyScreen and LoginScreen look identical to before. 1046 + 1047 + If any visual regression, identify which task introduced it and roll back the relevant CSS change. 1048 + 1049 + - [ ] **Step 8.6: Final commit (only if cleanup needed)** 1050 + 1051 + If Steps 8.3 / 8.4 / 8.5 surfaced any issues that required code changes, commit them with an appropriate message. Otherwise: nothing to commit — log a summary of the verification results and end. 1052 + 1053 + --- 1054 + 1055 + ## Summary of files touched 1056 + 1057 + **Created:** 1058 + - `client/src/components/game/MoveDrawer.tsx` 1059 + - `client/src/components/game/__tests__/MoveDrawer.test.tsx` 1060 + 1061 + **Modified:** 1062 + - `client/src/components/game/GameScreen.tsx` (drawer wiring + breakpoint switch) 1063 + - `client/src/components/layout/Header.tsx` (truncate + tap targets) 1064 + - `client/src/components/game/PlayerBar.tsx` (gap + avatar shrink-0) 1065 + - `client/src/components/game/GameStatus.tsx` (tap target) 1066 + - `client/src/components/lobby/LobbyScreen.tsx` (only if Task 6 finds an issue) 1067 + - `client/src/components/auth/LoginScreen.tsx` (only if Task 6 finds an issue) 1068 + - `CLAUDE.md` (mobile-support section)