Files
blind_chess/docs/superpowers/plans/2026-05-18-table-fidelity-features.md
claude (blind_chess) 59717b3b5b docs: amend plan to reflect code-review fixes
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>
2026-05-18 20:51:37 -04:00

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 PieceTally and CaptureTally types

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 captures field 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 captures into the joined and update messages

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 captures to 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 CaptureTally component

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 Operations section of CLAUDE.md — there are now two instances (CT 690 / chess.sethpc.xyz and chess.local on 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 ClientMessage carries phantom data, and the phantom store is never read in any send/commit path.

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 Board and 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 Operations section of CLAUDE.md (both instances: CT 690 / chess.sethpc.xyz and chess.local on VDJ-RIG). Smoke-test the phantom layer on a phone and on desktop in a vs-computer blind game.

  • Update DECISIONS.md with 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-handoff skill.


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.