The "Promote pawn" dialog popped for any pawn "moved" toward the last
rank: the commit paths checked piece type + destination rank but never
the pawn's SOURCE rank. With the phantom layer now filling ranks 7-8
with tappable phantom pieces, tapping one (which falls through to the
real-move handler) while a real pawn was armed triggered the dialog for
a move no pawn could make — and for any phantom type, not just pawns.
Root cause: incomplete promotion detection, duplicated in Game.svelte
`onCommit` and the server's `isPromotionRequired`. Replaced with one
shared `isPromotionMove(piece, from, to)` — pawn, from the rank adjacent
to promotion, to the promotion rank, at most one file over — used by
both. 7 unit tests in packages/shared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opponent (black) phantom pieces rendered near-invisible on the dark
--panel background in the palette and Captures panel, and the black
drag-ghost was low-contrast over dark areas. Black piece glyphs in
those three spots now get a light text-shadow outline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-key the phantom-load effect on `loadedFor` (tracks gameId) so it
reloads if the same <Game> instance is reused for a different game
without a remount. Also gate the drag-ghost block behind
`phantomLayerEnabled` for consistency with all other phantom UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phantom pieces are a pointer-only overlay; adding role/tabindex would create
a focusable element with no keyboard equivalent (worse a11y, not better).
The real game remains fully keyboard-operable via the square <button>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add onCancel handler for browser/OS gesture takeover (scroll, palm
rejection) that previously leaked window listeners and left drag stuck.
Extract detach() helper called by onUp, onCancel, and start() for
idempotency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Attempted-move lines (no_such_piece, no_legal_moves, wont_help, illegal_move)
now show "White — " or "Black — " prefix derived from ply parity. Removed
alarm-red err styling; replaced with neutral dim+italic via .entry.attempt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track aiOpponent in game store; show a pill badge in the topbar for AI
games, update turn label to "<Brain> is moving…" on the bot's turn,
and suppress the disconnected-opponent banner when the opponent is a bot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Svelte 5 $effect tracks every $state read inside its body. The
lifecycle effect that calls game.connect(gameId) implicitly read
state.ws (inside connect()) and then wrote to it, producing an
effect_update_depth_exceeded loop. Symptom in production: the
browser opened ~12 WS connections/sec, none completed the upgrade
handshake, and the lobby flow appeared stuck on 'waiting for
opponent' (the opponent's WS never stabilized long enough for the
server to send 'joined'). untrack() opts the call out of dep tracking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Svelte 5 runes ($state, $derived, $effect) only run through the
compiler in .svelte and .svelte.ts/js files. A plain .ts file leaves
$state(...) as a literal call at runtime, causing
"ReferenceError: $state is not defined" and a blank page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>