Tasks 8/10/11 received review fixes during execution; the plan's code blocks are updated to match what shipped: - Task 8: drag controller handles pointercancel + idempotent start. - Task 10: palette pieces are plain spans + svelte-ignore (no focusable-but-not-operable role/tabindex). - Task 11: phantom-load effect keyed on gameId; drag ghost gated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
44 KiB
Table-Fidelity Features Implementation Plan
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.
Goal: Add three physical-table-faithful features to blind chess — broadcast all moderator announcements to both players, a running capture tally, and a client-local phantom-opponent-piece overlay.
Architecture: Two increments. Increment 1 (Features 1+2) is server-centric and independently shippable. Increment 2 (Feature 3) is a client-only phantom layer that never touches the server. The zero-leak core (buildView, geometric.ts) is untouched throughout.
Tech Stack: Node 22 + TypeScript, Fastify + ws, Svelte 5 (runes), chess.js. pnpm workspace packages/{server,client,shared}. Tests: vitest in shared and server only — the client has no test harness, so client tasks use svelte-check typechecking plus a manual-verification step.
Spec: docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md
Conventions for every commit step below: conventional-commit messages; append the Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> trailer per repo convention; run gitea push immediately after each commit (homelab rule: never leave a commit unpushed).
Build ordering note: packages/server and packages/client resolve @blind-chess/shared from its built dist/. Whenever a task adds a new export to shared, it must run pnpm --filter @blind-chess/shared build before downstream typechecks/builds will see it. Build steps are included where needed.
Increment 1 — Features 1 & 2
Task 1: Widen moderator announcement audiences to 'both'
Move events and attempted-move errors currently reach only one player. Make every announcement audience: 'both' so the moderator panel is a complete shared transcript.
Files:
-
Modify:
packages/server/src/translator.ts:36-51 -
Modify:
packages/server/src/commit.ts:39-119 -
Test:
packages/server/test/unit/commit-fsm.test.ts(append) -
Step 1: Write the failing tests
Append these two it blocks inside the describe('hierarchy decision table', ...) block in packages/server/test/unit/commit-fsm.test.ts (after the existing row 6 test, before the closing }); of that describe):
it('attempted-move announcements are audience: both', () => {
const r = handleCommit(game, 'w', { from: 'e4' }); // empty square -> no_such_piece
expect(r.kind).toBe('announce');
if (r.kind === 'announce') expect(r.announcements[0]!.audience).toBe('both');
});
it('applied-move announcements are audience: both', () => {
const r = handleCommit(game, 'w', { from: 'e2', to: 'e4' });
expect(r.kind).toBe('applied');
if (r.kind === 'applied') {
for (const a of r.announcements) expect(a.audience).toBe('both');
}
});
- Step 2: Run the tests to verify they fail
Run: pnpm --filter @blind-chess/server test
Expected: the two new tests FAIL — no_such_piece currently has audience: 'w' and white_moved currently has audience: 'b', not 'both'.
- Step 3: Change move-event audiences in
translator.ts
In packages/server/src/translator.ts, replace the block at lines 36-51 (the // To opponent comment through the promotion if) with:
// To both players: the move event itself (Feature 1 — the moderator
// announces every move aloud; both players hear it).
if (isKingsideCastle) {
out.push(announce(`${moverWord}_castled_kingside` as ModeratorText, 'both', ply));
} else if (isQueensideCastle) {
out.push(announce(`${moverWord}_castled_queenside` as ModeratorText, 'both', ply));
} else if (isCap && isEp) {
out.push(announce(`${moverWord}_moved_captured_ep` as ModeratorText, 'both', ply));
} else if (isCap) {
out.push(announce(`${moverWord}_moved_captured` as ModeratorText, 'both', ply));
} else {
out.push(announce(`${moverWord}_moved` as ModeratorText, 'both', ply));
}
if (isProm) {
out.push(announce(`${moverWord}_promoted` as ModeratorText, 'both', ply, { promotedTo: move.promotion }));
}
(The opp variable is still used below to derive oppWord for the *_in_check announcement — leave it.)
- Step 4: Change attempted-move audiences in
commit.ts
In packages/server/src/commit.ts, replace the announceWith function (lines 110-119) with:
function announceWith(
game: Game,
text: 'no_such_piece' | 'no_legal_moves' | 'wont_help' | 'illegal_move',
): CommitResult {
const ply = game.chess.history().length;
// Feature 1: attempted moves are announced to both players.
const a = announce(text, 'both', ply);
game.announcements.push(a);
return { kind: 'announce', announcements: [a] };
}
Then update its four call sites in handleCommit to drop the now-removed color argument:
-
return announceWith(game, 'no_such_piece', color);→return announceWith(game, 'no_such_piece'); -
return announceWith(game, 'no_legal_moves', color);→return announceWith(game, 'no_legal_moves'); -
return announceWith(game, 'wont_help', color);→return announceWith(game, 'wont_help'); -
return announceWith(game, 'illegal_move', color);→return announceWith(game, 'illegal_move'); -
Step 5: Run the tests to verify they pass
Run: pnpm --filter @blind-chess/server test
Expected: PASS — all server tests pass, including the two new ones. The existing commit-fsm and integration tests still pass (their predicates check announcement .text, not .audience, and opponents still receive move events).
- Step 6: Typecheck
Run: pnpm --filter @blind-chess/server typecheck
Expected: PASS — no unused-variable error (color is still used elsewhere in handleCommit).
- Step 7: Commit
git add packages/server/src/translator.ts packages/server/src/commit.ts packages/server/test/unit/commit-fsm.test.ts
git commit -m "feat(server): moderator announces every move and attempt to both players"
gitea push
Task 2: Suppress the bot's intermediate retry rejections
With attempted moves now 'both', the Casual bot's blind-mode retry loop would broadcast up to 25 rejection announcements per turn as search churn. Discard the bot's own intermediate rejections; only its final committed move is announced.
Files:
-
Modify:
packages/server/src/bot/driver.ts:152-189 -
Test:
packages/server/test/unit/bot/driver.test.ts(append) -
Step 1: Write the failing test
Append this it block inside the describe('BotDriver', ...) block in packages/server/test/unit/bot/driver.test.ts (after the retry on illegal_move test):
it('suppresses the bot intermediate retry rejection from the moderator log', async () => {
// Pinned-bishop position: first action is rejected (wont_help), second
// is a legal king move. The wont_help must NOT survive in announcements.
const fen = '4k2K/4b3/8/8/8/8/8/4R3 b - - 0 1';
game = makeGame({ fen });
brain = new StubBrain();
driver = new BotDriver({ game, brain, color: 'b' });
await driver.init();
brain.enqueue(
{ type: 'commit', from: 'e7', to: 'd6' }, // rejected: wont_help
{ type: 'commit', from: 'e8', to: 'f8' }, // legal king move
);
await driver.onStateChange();
const texts = game.announcements.map((a) => a.text);
expect(texts).not.toContain('wont_help');
expect(texts).toContain('black_moved');
});
- Step 2: Run the test to verify it fails
Run: pnpm --filter @blind-chess/server test
Expected: the new test FAILS — game.announcements still contains the wont_help rejection from the retried attempt.
- Step 3: Pop the intermediate rejection in
dispatch
In packages/server/src/bot/driver.ts, inside dispatch, replace the if (result.kind === 'announce') block (lines 164-176) with:
if (result.kind === 'announce') {
const text = result.announcements[0]!.text;
if (text === 'wont_help' || text === 'illegal_move'
|| text === 'no_such_piece' || text === 'no_legal_moves') {
// Feature 1: attempted-move announcements are audience 'both'.
// The bot's intermediate retry rejections are internal search
// churn, not deliberate probing — suppress them so they don't
// broadcast to the human. The bot tracks its own rejections via
// attemptHistory, so removing the announcement is safe. The whole
// decision cycle runs before ws.ts broadcasts, so this pop always
// happens before any broadcast.
const rejection = result.announcements[0]!;
const anns = this.game.announcements;
if (anns[anns.length - 1] === rejection) anns.pop();
return {
kind: 'retry',
entry: {
move: { from: action.from, to: action.to, promotion: action.promotion },
rejection: text,
},
};
}
}
- Step 4: Run the tests to verify they pass
Run: pnpm --filter @blind-chess/server test
Expected: PASS — the new test passes and all existing driver tests still pass (retry on wont_help, retry cap, retry on illegal_move all check retry counts / game status, not announcement contents).
- Step 5: Commit
git add packages/server/src/bot/driver.ts packages/server/test/unit/bot/driver.test.ts
git commit -m "feat(bot): suppress bot retry-search churn from the moderator log"
gitea push
Task 3: Client — label attempted-move lines by player, neutral styling
The four attempted-move enums carry no colour. Derive the attempting player from ply parity (an attempt only happens on the actor's turn; ply is the pre-move history length) and drop the alarm-red styling — these lines are now shared moderator commentary.
Files:
-
Modify:
packages/client/src/lib/ModeratorPanel.svelte -
Step 1: Add the actor prefix in the entry loop
In packages/client/src/lib/ModeratorPanel.svelte, replace the {#each visible ...} block (lines 26-30) with:
{#each visible as a, i (i)}
{@const isAttempt = a.text === 'no_such_piece' || a.text === 'no_legal_moves' || a.text === 'wont_help' || a.text === 'illegal_move'}
{@const actor = a.ply % 2 === 0 ? 'White' : 'Black'}
<div class="entry" class:attempt={isAttempt}>
<span class="ply">{a.ply > 0 ? `#${a.ply}` : ''}</span>
<span class="text">{isAttempt ? `${actor} — ` : ''}{moderatorText(a.text, a.payload)}</span>
</div>
- Step 2: Replace the error styling with neutral styling
In the same file's <style> block, replace the rule .entry.err .text { color: #f87171; } with:
.entry.attempt .text { color: var(--text-dim); font-style: italic; }
- Step 3: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS.
- Step 4: Manual verification
Start the dev servers (pnpm dev:server and pnpm dev:client in two terminals), open the client, create a blind game vs computer. Make a few moves; deliberately tap one of your pieces and pick a destination that is illegal. Confirm:
-
The moderator panel shows your move attempts and the bot's moves.
-
An illegal attempt shows e.g.
White — Illegal move.in muted italic (not red). -
Step 5: Commit
git add packages/client/src/lib/ModeratorPanel.svelte
git commit -m "feat(client): label attempted-move announcements by player"
gitea push
Task 4: Capture tally — shared type, server function, protocol field, ws wiring
The server records every capture's type in MoveRecord. Compute a per-viewer tally and ship it on joined/update.
Files:
-
Modify:
packages/shared/src/types.ts(append) -
Modify:
packages/shared/src/protocol.ts -
Create:
packages/server/src/captures.ts -
Modify:
packages/server/src/ws.ts -
Test:
packages/server/test/unit/captures.test.ts -
Step 1: Add the
PieceTallyandCaptureTallytypes
Append to packages/shared/src/types.ts:
/** Count of pieces by type — used for the capture tally. */
export type PieceTally = Partial<Record<PieceType, number>>;
/** Per-viewer capture tally: what you took, and what you lost. */
export interface CaptureTally {
byYou: PieceTally;
byOpponent: PieceTally;
}
- Step 2: Add the
capturesfield to the protocol messages
In packages/shared/src/protocol.ts, add CaptureTally to the type import from ./types.js, then add captures: CaptureTally; to both the joined and the update members of ServerMessage.
The import line becomes:
import type {
BoardView, CaptureTally, Color, GameId, GameStatus, Mode, PlayerToken,
PromotionType, Square, EndReason,
} from './types.js';
The joined member gains (place it after opponentConnected: boolean;):
captures: CaptureTally;
The update member gains (place it after aiOpponent?: ...;):
captures: CaptureTally;
- Step 3: Build shared
Run: pnpm --filter @blind-chess/shared build
Expected: PASS — dist/ now exports PieceTally / CaptureTally.
- Step 4: Write the failing test
Create packages/server/test/unit/captures.test.ts:
import { describe, it, expect } from 'vitest';
import { captureTally } from '../../src/captures.js';
import type { Game, MoveRecord } from '../../src/state.js';
import type { Color, PieceType } from '@blind-chess/shared';
function rec(by: Color, capturedPieceType?: PieceType): MoveRecord {
return {
ply: 1, by, from: 'e2', to: 'e4', san: 'e4',
capturedPieceType, flags: {}, at: 0,
};
}
describe('captureTally', () => {
it('counts captures per viewer', () => {
const moveHistory: MoveRecord[] = [
rec('w', 'p'), rec('b'), rec('w', 'n'),
rec('b', 'p'), rec('w', 'p'),
];
const game = { moveHistory } as unknown as Game;
expect(captureTally(game, 'w')).toEqual({
byYou: { p: 2, n: 1 }, byOpponent: { p: 1 },
});
expect(captureTally(game, 'b')).toEqual({
byYou: { p: 1 }, byOpponent: { p: 2, n: 1 },
});
});
it('returns empty tallies when there are no captures', () => {
const game = { moveHistory: [rec('w'), rec('b')] } as unknown as Game;
expect(captureTally(game, 'w')).toEqual({ byYou: {}, byOpponent: {} });
});
});
- Step 5: Run the test to verify it fails
Run: pnpm --filter @blind-chess/server test
Expected: the new test file FAILS — ../../src/captures.js does not exist yet.
- Step 6: Implement
captureTally
Create packages/server/src/captures.ts:
import type { CaptureTally, Color } from '@blind-chess/shared';
import type { Game } from './state.js';
/**
* Per-viewer capture tally derived from move history. `byYou` is the set of
* opponent pieces this viewer has captured; `byOpponent` is the set of this
* viewer's pieces the opponent has captured. Pure function of move history —
* no live board state, no opponent positions.
*/
export function captureTally(game: Game, viewer: Color): CaptureTally {
const byYou: Record<string, number> = {};
const byOpponent: Record<string, number> = {};
for (const rec of game.moveHistory) {
const captured = rec.capturedPieceType;
if (!captured) continue;
const bucket = rec.by === viewer ? byYou : byOpponent;
bucket[captured] = (bucket[captured] ?? 0) + 1;
}
return { byYou, byOpponent };
}
- Step 7: Run the test to verify it passes
Run: pnpm --filter @blind-chess/server test
Expected: PASS — the two new captureTally tests pass.
- Step 8: Wire
capturesinto thejoinedandupdatemessages
In packages/server/src/ws.ts:
Add the import near the other server imports (after the import { endGame, ... } line):
import { captureTally } from './captures.js';
In onHello, the send(ctx.socket, { type: 'joined', ... }) call gains a field (add it after aiOpponent: game.aiOpponent,):
captures: captureTally(game, color),
In sendUpdateTo, the send(slot.socket, { type: 'update', ... }) call gains a field (add it after aiOpponent: game.aiOpponent,):
captures: captureTally(game, color),
- Step 9: Build and test the server
Run: pnpm --filter @blind-chess/server build && pnpm --filter @blind-chess/server test
Expected: PASS — server compiles (every joined/update construction site now provides the required captures field) and all tests pass.
- Step 10: Commit
git add packages/shared/src/types.ts packages/shared/src/protocol.ts packages/server/src/captures.ts packages/server/src/ws.ts packages/server/test/unit/captures.test.ts
git commit -m "feat(server): per-viewer capture tally on joined and update messages"
gitea push
Task 5: Client — capture-tally store field and panel
Files:
-
Modify:
packages/client/src/lib/stores/game.svelte.ts -
Create:
packages/client/src/lib/CaptureTally.svelte -
Modify:
packages/client/src/lib/Game.svelte -
Step 1: Add
capturesto the client game store
In packages/client/src/lib/stores/game.svelte.ts:
Add CaptureTally to the type import from @blind-chess/shared.
Add to the GameStateValue interface (after aiOpponent: ...;):
captures: CaptureTally;
Add to the $state<GameStateValue>({ ... }) initializer (after aiOpponent: null,):
captures: { byYou: {}, byOpponent: {} },
In onServerMessage, in the case 'joined': block (after state.aiOpponent = m.aiOpponent ?? null;):
state.captures = m.captures;
In the case 'update': block (after the aiOpponent line):
state.captures = m.captures;
- Step 2: Create the
CaptureTallycomponent
Create packages/client/src/lib/CaptureTally.svelte:
<script lang="ts">
import type { CaptureTally, Color, PieceTally, PieceType } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js';
interface Props {
captures: CaptureTally;
you: Color;
}
let { captures, you }: Props = $props();
const ORDER: PieceType[] = ['q', 'r', 'b', 'n', 'p'];
const oppColor = $derived<Color>(you === 'w' ? 'b' : 'w');
function glyphs(tally: PieceTally, color: Color): { glyph: string; key: string }[] {
const out: { glyph: string; key: string }[] = [];
for (const t of ORDER) {
const n = tally[t] ?? 0;
for (let i = 0; i < n; i++) out.push({ glyph: pieceGlyph({ color, type: t }), key: `${t}${i}` });
}
return out;
}
function total(tally: PieceTally): number {
return Object.values(tally).reduce((a, b) => a + b, 0);
}
const youTook = $derived(glyphs(captures.byYou, oppColor));
const youLost = $derived(glyphs(captures.byOpponent, you));
</script>
<div class="panel">
<header>Captures</header>
<div class="row">
<span class="label">You took</span>
<span class="pieces">
{#each youTook as g (g.key)}<span class="g g-{oppColor}">{g.glyph}</span>{:else}<span class="muted">—</span>{/each}
</span>
<span class="n">{total(captures.byYou)}</span>
</div>
<div class="row lost">
<span class="label">Lost</span>
<span class="pieces">
{#each youLost as g (g.key)}<span class="g g-{you}">{g.glyph}</span>{:else}<span class="muted">—</span>{/each}
</span>
<span class="n">{total(captures.byOpponent)}</span>
</div>
</div>
<style>
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
}
header {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
}
.row.lost { opacity: 0.7; }
.label {
font-size: 12px;
color: var(--text-dim);
width: 56px;
flex-shrink: 0;
}
.pieces { flex: 1; font-size: 20px; line-height: 1; }
.g-w { color: #fafafa; }
.g-b { color: #1a1a1a; }
.n {
font-family: ui-monospace, monospace;
font-size: 13px;
color: var(--text-dim);
}
</style>
- Step 3: Mount the panel in
Game.svelte
In packages/client/src/lib/Game.svelte:
Add the import after the ModeratorPanel import:
import CaptureTally from './CaptureTally.svelte';
In the <aside class="side"> block, immediately after the <ModeratorPanel ... /> line, add:
<CaptureTally captures={game.state.captures} you={game.state.you} />
- Step 4: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS.
- Step 5: Manual verification
With dev servers running, create a blind game vs computer, play until you capture a bot piece (and until it captures one of yours). Confirm the Captures panel shows the right glyphs and counts in "You took" and "Lost".
- Step 6: Commit
git add packages/client/src/lib/stores/game.svelte.ts packages/client/src/lib/CaptureTally.svelte packages/client/src/lib/Game.svelte
git commit -m "feat(client): capture-tally panel"
gitea push
Checkpoint A — Increment 1 complete
- Full build, typecheck, and test:
pnpm -r build && pnpm -r typecheck && pnpm -r test
Expected: all packages build; all tests pass — was 78, plus 5 new (2 in commit-fsm, 1 in driver, 2 in captures) = 83.
-
Deploy Increment 1. Deploy per the
Operationssection ofCLAUDE.md— there are now two instances (CT 690 /chess.sethpc.xyzandchess.localon VDJ-RIG). Smoke-test:curl https://chess.sethpc.xyz/api/health, then play a quick vs-computer blind game and confirm the moderator transcript and capture panel. -
STOP for review before starting Increment 2.
Increment 2 — Feature 3: Phantom opponent pieces
Client-only. The phantom layer never reaches the server — no
ClientMessagecarries phantom data, and the phantom store is never read in anysend/commitpath.
Task 6: Shared pure phantom model
Files:
-
Create:
packages/shared/src/phantoms.ts -
Modify:
packages/shared/src/index.ts -
Test:
packages/shared/test/phantoms.test.ts -
Step 1: Write the failing test
Create packages/shared/test/phantoms.test.ts:
import { describe, it, expect } from 'vitest';
import { opponentStartPosition, deserializePhantoms } from '../src/phantoms.js';
describe('opponentStartPosition', () => {
it('seeds 16 black pieces on ranks 7-8', () => {
const p = opponentStartPosition('b');
expect(Object.keys(p).length).toBe(16);
expect(p.e8).toEqual({ color: 'b', type: 'k' });
expect(p.d8).toEqual({ color: 'b', type: 'q' });
expect(p.a8).toEqual({ color: 'b', type: 'r' });
expect(p.h7).toEqual({ color: 'b', type: 'p' });
});
it('seeds 16 white pieces on ranks 1-2', () => {
const p = opponentStartPosition('w');
expect(Object.keys(p).length).toBe(16);
expect(p.e1).toEqual({ color: 'w', type: 'k' });
expect(p.a2).toEqual({ color: 'w', type: 'p' });
});
});
describe('deserializePhantoms', () => {
it('returns {} for null or invalid JSON', () => {
expect(deserializePhantoms(null)).toEqual({});
expect(deserializePhantoms('not json')).toEqual({});
expect(deserializePhantoms('[]')).toEqual({});
});
it('keeps valid entries and drops invalid ones', () => {
const raw = JSON.stringify({
e5: { color: 'b', type: 'n' },
zz: { color: 'b', type: 'p' }, // invalid square
a1: { color: 'x', type: 'p' }, // invalid colour
b2: { color: 'b', type: 'z' }, // invalid type
});
expect(deserializePhantoms(raw)).toEqual({ e5: { color: 'b', type: 'n' } });
});
});
- Step 2: Run the test to verify it fails
Run: pnpm --filter @blind-chess/shared test
Expected: FAIL — ../src/phantoms.js does not exist.
- Step 3: Implement the phantom model
Create packages/shared/src/phantoms.ts:
import { FILES, isSquare, type Color, type Piece, type PieceType, type Square } from './types.js';
const BACK_RANK: PieceType[] = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'];
const COLORS: Color[] = ['w', 'b'];
const TYPES: PieceType[] = ['p', 'n', 'b', 'r', 'q', 'k'];
/**
* The standard starting position for one colour, as a square→piece map.
* Used to seed the phantom opponent-model layer with the opponent's army.
*/
export function opponentStartPosition(opponentColor: Color): Partial<Record<Square, Piece>> {
const backRank = opponentColor === 'w' ? '1' : '8';
const pawnRank = opponentColor === 'w' ? '2' : '7';
const out: Partial<Record<Square, Piece>> = {};
FILES.forEach((file, i) => {
out[`${file}${backRank}` as Square] = { color: opponentColor, type: BACK_RANK[i]! };
out[`${file}${pawnRank}` as Square] = { color: opponentColor, type: 'p' };
});
return out;
}
/**
* Parse a persisted phantom map (from localStorage). Tolerant: returns {} on
* any structural failure and silently drops individual invalid entries.
*/
export function deserializePhantoms(raw: string | null): Partial<Record<Square, Piece>> {
if (!raw) return {};
let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return {}; }
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
const out: Partial<Record<Square, Piece>> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (!isSquare(k)) continue;
if (typeof v !== 'object' || v === null) continue;
const { color, type } = v as { color?: unknown; type?: unknown };
if (!COLORS.includes(color as Color)) continue;
if (!TYPES.includes(type as PieceType)) continue;
out[k] = { color: color as Color, type: type as PieceType };
}
return out;
}
- Step 4: Export from the shared index
In packages/shared/src/index.ts, append:
export * from './phantoms.js';
- Step 5: Run the test and build
Run: pnpm --filter @blind-chess/shared test && pnpm --filter @blind-chess/shared build
Expected: PASS — the new tests pass and dist/ exports the phantom model.
- Step 6: Commit
git add packages/shared/src/phantoms.ts packages/shared/src/index.ts packages/shared/test/phantoms.test.ts
git commit -m "feat(shared): pure phantom-model helpers (seed positions, deserialize)"
gitea push
Task 7: Client phantom store
A local-only Svelte store, persisted to localStorage, seeded once per game.
Files:
-
Create:
packages/client/src/lib/stores/phantoms.svelte.ts -
Step 1: Create the store
Create packages/client/src/lib/stores/phantoms.svelte.ts:
import {
opponentStartPosition,
deserializePhantoms,
type Color,
type Piece,
type PieceType,
type Square,
} from '@blind-chess/shared';
/**
* Client-LOCAL store for the player's phantom opponent-model layer.
* This data NEVER reaches the server — it is the player's private guess.
* Do not read this store in any `send`/`commit` path.
*/
function makeStore() {
const state = $state<{ phantoms: Partial<Record<Square, Piece>> }>({ phantoms: {} });
let gameId: string | null = null;
let oppColor: Color = 'b';
function key(id: string) { return `bc:phantoms:${id}`; }
function persist() {
if (gameId) localStorage.setItem(key(gameId), JSON.stringify(state.phantoms));
}
/** Load (or first-time seed) the phantom layer for a blind game. */
function loadForGame(id: string, you: Color) {
gameId = id;
oppColor = you === 'w' ? 'b' : 'w';
const raw = localStorage.getItem(key(id));
if (raw === null) {
// First load — seed with the opponent's starting army.
state.phantoms = opponentStartPosition(oppColor);
persist();
} else {
state.phantoms = deserializePhantoms(raw);
}
}
function place(sq: Square, type: PieceType) {
state.phantoms = { ...state.phantoms, [sq]: { color: oppColor, type } };
persist();
}
function move(from: Square, to: Square) {
const p = state.phantoms[from];
if (!p) return;
const next = { ...state.phantoms };
delete next[from];
next[to] = p;
state.phantoms = next;
persist();
}
function remove(sq: Square) {
const next = { ...state.phantoms };
delete next[sq];
state.phantoms = next;
persist();
}
/** Drop the layer when a game ends — avoids unbounded localStorage growth. */
function clearForGame(id: string) {
localStorage.removeItem(key(id));
if (gameId === id) { state.phantoms = {}; gameId = null; }
}
return { state, loadForGame, place, move, remove, clearForGame };
}
export const phantoms = makeStore();
- Step 2: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS.
- Step 3: Commit
git add packages/client/src/lib/stores/phantoms.svelte.ts
git commit -m "feat(client): local-only phantom-layer store"
gitea push
Task 8: Client drag controller
Pointer-event drag shared by the board (phantom move/remove) and the palette (phantom place). Distinguishes a tap from a drag so a tap on a phantom still passes through to a real move.
Files:
-
Create:
packages/client/src/lib/stores/phantom-drag.svelte.ts -
Step 1: Create the drag controller
Create packages/client/src/lib/stores/phantom-drag.svelte.ts:
import type { PieceType, Square } from '@blind-chess/shared';
import { phantoms } from './phantoms.svelte.js';
import { game } from './game.svelte.js';
export type DragSource =
| { kind: 'palette'; type: PieceType }
| { kind: 'board'; from: Square; type: PieceType };
/**
* Pointer-event drag controller for the phantom layer. A drag past THRESHOLD
* px places/moves/removes a phantom; a sub-threshold press is a tap and is
* left for the board's normal click handler. Real moves are unaffected.
*/
function makeDrag() {
const state = $state<{
active: DragSource | null;
x: number;
y: number;
moved: boolean;
}>({ active: null, x: 0, y: 0, moved: false });
let startX = 0;
let startY = 0;
// Set only when a board-phantom drag ends back on its origin square — the
// browser then fires a spurious `click` on that square's button which must
// be swallowed so it doesn't trigger a real move.
let suppressClickOn: Square | null = null;
const THRESHOLD = 6;
function detach() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
}
function onMove(e: PointerEvent) {
if (!state.active) return;
state.x = e.clientX;
state.y = e.clientY;
if (!state.moved && Math.hypot(e.clientX - startX, e.clientY - startY) > THRESHOLD) {
state.moved = true;
}
}
// pointercancel fires instead of pointerup when the browser/OS takes over
// the gesture (common on touch). Abort the drag: clean up, drop nothing.
function onCancel() {
detach();
state.active = null;
state.moved = false;
}
function onUp(e: PointerEvent) {
detach();
const src = state.active;
const wasDrag = state.moved;
state.active = null;
state.moved = false;
if (!src || !wasDrag) return; // a tap — the board click handler deals with it
// elementFromPoint returns null off-viewport — treated as an off-board drop.
const el = document.elementFromPoint(e.clientX, e.clientY);
const sqEl = el?.closest('[data-square]') as HTMLElement | null;
const target = sqEl?.dataset.square as Square | undefined;
if (src.kind === 'board') {
if (target === src.from) { suppressClickOn = src.from; return; }
if (!target) { phantoms.remove(src.from); return; } // dropped off the board
if (game.state.view?.pieces[target]) return; // your own real piece — reject
phantoms.move(src.from, target);
return;
}
// palette → board
if (target && !game.state.view?.pieces[target]) phantoms.place(target, src.type);
}
function start(src: DragSource, e: PointerEvent) {
detach(); // idempotency — drop any listeners from an unfinished prior drag
suppressClickOn = null;
state.active = src;
state.x = startX = e.clientX;
state.y = startY = e.clientY;
state.moved = false;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
}
/** The board calls this first in its square-click handler. */
function shouldSuppressClick(sq: Square): boolean {
if (suppressClickOn === sq) { suppressClickOn = null; return true; }
return false;
}
return { state, start, shouldSuppressClick };
}
export const phantomDrag = makeDrag();
- Step 2: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS.
- Step 3: Commit
git add packages/client/src/lib/stores/phantom-drag.svelte.ts
git commit -m "feat(client): pointer-event drag controller for the phantom layer"
gitea push
Task 9: Render phantoms and drag handles in Board.svelte
Files:
-
Modify:
packages/client/src/lib/Board.svelte -
Step 1: Import the drag controller
In the <script> block of packages/client/src/lib/Board.svelte, after the import { pieceGlyph } line, add:
import { phantomDrag } from './stores/phantom-drag.svelte.js';
- Step 2: Add the new props (optional, with defaults)
In the Props interface, after highlightingEnabled: boolean;, add two optional props (optional so Board.svelte typechecks on its own before Game.svelte is updated in Task 11):
phantoms?: Partial<Record<Square, Piece>>;
phantomsEnabled?: boolean;
In the let { ... }: Props = $props(); destructuring, add them with defaults (e.g. after highlightingEnabled,):
phantoms = {}, phantomsEnabled = false,
- Step 3: Add the dragged-origin derived value
After the highlights $derived.by(...) block, add:
// The board square a phantom is currently being dragged out of (so it can
// be dimmed while the drag ghost is shown). Bind `active` to a local first
// so TypeScript narrows the discriminated union reliably.
const dragOrigin = $derived.by(() => {
const a = phantomDrag.state.active;
return a?.kind === 'board' && phantomDrag.state.moved ? a.from : null;
});
- Step 4: Suppress the post-drag click in
onSquareClick
Make the first line of the onSquareClick function body:
function onSquareClick(sq: Square) {
if (phantomDrag.shouldSuppressClick(sq)) return;
const piece = pieces[sq];
- Step 5: Render the phantom layer
In the markup, the square loop currently has these {@const} lines:
{@const sq = `${f}${r}` as Square}
{@const piece = pieces[sq]}
Add a third after them:
{@const ph = phantomsEnabled ? phantoms[sq] : undefined}
On the <button> element add a data-square attribute (next to aria-label={sq}):
data-square={sq}
Inside the button, immediately after the {#if piece}...{/if} block and before the {#if isHighlight && !piece} block, add:
{#if ph && !piece}
<span
class="phantom phantom-{ph.color}"
class:dragging={sq === dragOrigin}
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'board', from: sq, type: ph.type }, e); }}
>{pieceGlyph(ph)}</span>
{/if}
- Step 6: Add phantom styles
In the <style> block, after the .piece-b { ... } rule, add:
.phantom {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
touch-action: none;
cursor: grab;
opacity: 0.4;
outline: 2px dashed var(--text-dim);
outline-offset: -5px;
}
.phantom-w { color: #fafafa; }
.phantom-b { color: #1a1a1a; }
.phantom.dragging { opacity: 0.12; }
(The translucency plus the dashed inset frame make a phantom unmistakable from a real piece — tune to taste during manual verification.)
- Step 7: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS — the new props are optional with defaults, so Board.svelte typechecks even though Game.svelte does not pass them yet (Task 11 wires them).
- Step 8: Commit
git add packages/client/src/lib/Board.svelte
git commit -m "feat(client): render and drag phantom pieces on the board"
gitea push
Task 10: PhantomPalette component
A row of the six opponent piece types; drag one onto the board to place a phantom.
Files:
-
Create:
packages/client/src/lib/PhantomPalette.svelte -
Step 1: Create the component
Create packages/client/src/lib/PhantomPalette.svelte:
<script lang="ts">
import type { Color, PieceType } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js';
import { phantomDrag } from './stores/phantom-drag.svelte.js';
interface Props { oppColor: Color; }
let { oppColor }: Props = $props();
const TYPES: PieceType[] = ['q', 'r', 'b', 'n', 'p', 'k'];
</script>
<div class="palette">
<span class="hint muted">Drag onto your board — your guess of where the opponent is.</span>
<div class="pieces">
{#each TYPES as t (t)}
<!-- Pointer-only drag source — same deliberate a11y trade-off as the
phantom spans in Board.svelte (no keyboard drag interaction). -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="pp pp-{oppColor}"
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'palette', type: t }, e); }}
>{pieceGlyph({ color: oppColor, type: t })}</span>
{/each}
</div>
</div>
<style>
.palette {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
}
.hint { font-size: 12px; }
.pieces { display: flex; gap: 6px; }
.pp {
font-size: 30px;
line-height: 1;
cursor: grab;
touch-action: none;
opacity: 0.75;
user-select: none;
}
.pp:hover { opacity: 1; }
.pp-w { color: #fafafa; }
.pp-b { color: #1a1a1a; }
</style>
- Step 2: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS — PhantomPalette.svelte is self-contained.
- Step 3: Commit
git add packages/client/src/lib/PhantomPalette.svelte
git commit -m "feat(client): phantom-piece palette component"
gitea push
Task 11: Wire the phantom layer into Game.svelte
Load/clear the phantom store, pass phantoms to the board, mount the palette, and render the drag ghost.
Files:
-
Modify:
packages/client/src/lib/Game.svelte -
Step 1: Add imports
In the <script> block of packages/client/src/lib/Game.svelte, after the existing component imports, add:
import PhantomPalette from './PhantomPalette.svelte';
import { pieceGlyph } from './pieces.js';
import { phantoms } from './stores/phantoms.svelte.js';
import { phantomDrag } from './stores/phantom-drag.svelte.js';
- Step 2: Add derived gating values
After the existing const turnLabel = ... derived block, add:
const oppColor = $derived<'w' | 'b'>(game.state.you === 'w' ? 'b' : 'w');
// Phantom layer is blind-mode-only and shown only during active play.
const phantomLayerEnabled = $derived(
game.state.mode === 'blind' && game.state.gameStatus === 'active',
);
// The piece type currently being dragged (for the floating ghost), or null.
const dragGhost = $derived.by(() => {
const a = phantomDrag.state.active;
return a && phantomDrag.state.moved ? a.type : null;
});
// Load the phantom layer when `you` is known (blind games only). Keyed on
// gameId — like the connection effect — so it reloads if this <Game>
// instance is reused for a different game without a remount.
let loadedFor: string | null = $state(null);
$effect(() => {
const id = gameId;
const you = game.state.you;
if (loadedFor === id) return;
if (you && game.state.mode === 'blind') {
untrack(() => phantoms.loadForGame(id, you));
loadedFor = id;
}
});
// Drop the phantom layer when the game ends.
$effect(() => {
if (game.state.gameStatus === 'finished') {
untrack(() => phantoms.clearForGame(gameId));
}
});
(untrack is already imported at the top of Game.svelte.)
- Step 3: Pass phantoms to
Boardand mount the palette
Replace the <div class="board-area">...</div> block with:
<div class="board-area">
<Board
pieces={game.state.view.pieces}
you={game.state.you}
toMove={game.state.view.toMove}
mode={game.state.mode ?? 'blind'}
highlightingEnabled={game.state.highlightingEnabled}
phantoms={phantomLayerEnabled ? phantoms.state.phantoms : {}}
phantomsEnabled={phantomLayerEnabled}
armedSquare={armedSquare}
touchedSquare={game.state.touchedPiece}
{onArm}
{onCommit}
/>
{#if phantomLayerEnabled}
<PhantomPalette {oppColor} />
{/if}
</div>
- Step 4: Render the drag ghost
Immediately after the closing </div> of <div class="game-layout" ...> (and before the {#if pendingPromotion ...} block), add:
{#if phantomLayerEnabled && dragGhost}
<div
class="drag-ghost piece-{oppColor}"
style="left: {phantomDrag.state.x}px; top: {phantomDrag.state.y}px;"
>{pieceGlyph({ color: oppColor, type: dragGhost })}</div>
{/if}
- Step 5: Add layout styles for the palette and ghost
In the <style> block, replace the .board-area { ... } rule with:
.board-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
Then append:
.drag-ghost {
position: fixed;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 1000;
font-size: 44px;
line-height: 1;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
.drag-ghost.piece-w { color: #fafafa; }
.drag-ghost.piece-b { color: #1a1a1a; }
- Step 6: Typecheck
Run: pnpm --filter @blind-chess/client typecheck
Expected: PASS — the Board.svelte props are now satisfied.
- Step 7: Manual verification
With dev servers running, create a blind game vs computer. Confirm:
-
The board shows 16 translucent phantom pieces on the opponent's home ranks.
-
A palette of six piece types appears below the board.
-
Dragging a phantom to another square moves it; dragging it off the board removes it.
-
Dragging a palette piece onto a board square places a phantom there.
-
A phantom cannot be dropped onto a square holding one of your own real pieces (the drop is rejected).
-
Tapping (not dragging) a square still arms/commits real moves normally, including onto a square that has a phantom.
-
Reload the page mid-game — phantoms persist (loaded from
localStorage, not re-seeded). -
Create a vanilla vs-computer game — no phantom layer or palette appears.
-
When a game ends, the phantom layer and palette disappear and the full board is revealed cleanly.
-
Step 8: Commit
git add packages/client/src/lib/Game.svelte
git commit -m "feat(client): wire the phantom opponent-model layer into the game view"
gitea push
Checkpoint B — Increment 2 complete
- Full build, typecheck, and test:
pnpm -r build && pnpm -r typecheck && pnpm -r test
Expected: all packages build; all tests pass (Increment 1's 83 + 4 new phantom-model tests = 87).
-
Deploy Increment 2 per the
Operationssection ofCLAUDE.md(both instances: CT 690 /chess.sethpc.xyzandchess.localon VDJ-RIG). Smoke-test the phantom layer on a phone and on desktop in a vs-computer blind game. -
Update
DECISIONS.mdwith the locked choices from this work: announcement audience widened to'both'; manual phantom model (seeded once, no automation); drag-and-drop interaction for phantoms only (real moves stay click-to-move); phantom layer is client-local and never serialized to the server. -
Write a session handoff via the
/session-handoffskill.
Notes on testing scope
packages/shared and packages/server have vitest; packages/client does not, by deliberate decision (the spec left this to the plan). Feature 3's genuinely-testable logic — start positions and deserialization — lives in packages/shared/src/phantoms.ts and is unit-tested there. The client store, drag controller, and Svelte components are covered by svelte-check typechecking plus the manual-verification steps. Standing up a client test harness is out of scope for this plan.