---
phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
plan: 11
subsystem: client-ui
tags: [esc-menu, logout, auth, d24, d34, dom-overlay]
dependency_graph:
  requires:
    - 06-07 (GameScene + ChatHUD + ForceResetOverlay + ReconnectBanner baseline)
    - 06-05 (Better-Auth client SDK: signOut already present)
  provides:
    - EscMenu DOM-overlay component (Logout / Resume / Settings-placeholder)
    - GameScene EscMenu wiring (Esc keydown + canvas pointerdown)
    - D-34 canvas-click suppression guards
    - signOut() idempotency surface
    - Playwright logout e2e (page.context().cookies() + toHaveLength(0))
  affects:
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/ui/ChatHUD.ts
    - apps/client/src/ui/reconnect-banner.ts
    - apps/client/src/ui/ForceResetOverlay.ts
    - apps/client/src/prediction/input-dispatcher.ts
tech_stack:
  added: []
  patterns:
    - DOM-overlay mount pattern (matches ChatHUD ADR 0008 invariant)
    - TDD red/green for both unit tests and e2e placeholder
    - InputDispatcher.setFrozen() freeze gate (extends pauseMovement pattern)
key_files:
  created:
    - apps/client/src/ui/EscMenu.ts
    - apps/client/src/__test__/esc-menu.test.ts
    - apps/client/test/e2e/logout.e2e.test.ts
  modified:
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/ui/ChatHUD.ts
    - apps/client/src/ui/reconnect-banner.ts
    - apps/client/src/ui/ForceResetOverlay.ts
    - apps/client/src/prediction/input-dispatcher.ts
    - apps/client/src/__test__/game-scene.test.ts
    - apps/client/index.html
decisions:
  - EscMenu is non-modal (no focus trap, no backdrop dismiss) per D-24 BNO aesthetic
  - setFrozen() calls pauseMovement() on freeze to ensure server-side heldInputs clears
  - Esc precedence: chat-mode close > escMenu close > escMenu open (D-22 + D-24)
  - Canvas pointerdown uses D-34 guard set: 5 conditions all must be false to open
  - signOut() was already present; only GameScene wiring + guards were missing
metrics:
  duration: ~25 minutes
  completed: "2026-05-11T05:44:00Z"
  tasks_completed: 2
  files_changed: 9
---

# Phase 6 Plan 11: EscMenu + Logout (D-24 / D-34) Summary

**One-liner:** Non-modal Esc menu overlay (Logout/Resume/Settings-placeholder) wired to both Esc keydown and canvas pointerdown, with D-34 suppression guards and idempotent signOut helper closing UAT Finding #5.

## Tasks Completed

| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 (RED) | EscMenu unit tests | f4198e5 | esc-menu.test.ts (8 tests, RED) |
| 1 (GREEN) | EscMenu DOM-overlay component | a5c7438 | EscMenu.ts, index.html |
| 2 (RED) | Playwright logout e2e | 437e52d | logout.e2e.test.ts (3 tests, RED) |
| 2 (GREEN) | GameScene wiring + D-34 guards | ca2bebc | GameScene.ts + 5 support files |

## EscMenu Public API

```typescript
export interface EscMenuCallbacks {
  onLogout: () => void | Promise<void>;
  onResume: () => void;
  onSettings?: () => void;
}

class EscMenu {
  constructor(cbs: EscMenuCallbacks)
  mount(parent: HTMLElement): void   // idempotent; appends #esc-menu under parent
  open(): void                        // idempotent; display = 'flex'
  close(): void                       // idempotent; display = 'none'
  isOpen(): boolean
  unmount(): void                     // idempotent; removes from DOM
}
```

## GameScene Precedence Rules (Locked)

**Esc key handler (D-22 + D-24):**
1. If `chatHud.isChatMode === true` → `chatHud.closeChatMode()` (chat-mode priority)
2. Else if `escMenu.isOpen()` → `escMenu.close()` + `inputDispatcher.setFrozen(false)`
3. Else → `escMenu.open()` + `inputDispatcher.setFrozen(true)`

**Canvas pointerdown (D-34 guard set — ALL must be false to open):**
1. `escMenu.isOpen()` — menu already up
2. `document.pointerLockElement` — pointer-lock engaged
3. `chatHud.isChatModeActive()` — chat input open
4. `chatHud.isInputFocused()` — chat field focused
5. `banner.isVisible()` — ReconnectBanner state !== idle
6. `forceReset.isVisible()` — ForceResetOverlay mounted

## D-34 Cookie Assertion Shape

The Playwright logout e2e uses:
```typescript
const cookies = await page.context().cookies();
const sessionCookies = cookies.filter((c) => /better-auth|session/i.test(c.name));
expect(sessionCookies).toHaveLength(0);
```
NOT `every(c => !c.value)` which gives false-green when cookies are absent.

## signOut() Idempotency

The `signOut()` function in `apps/client/src/auth/client.ts` was already implemented and idempotent:
- Calls `authClient.signOut()` wrapped in try/catch
- Clears `sessionStorage` key `'rebno.reconnectionToken'` in the `finally` block
- A 401 from an already-cleared session does not throw (caught internally)
- Double-click protection on the Logout button prevents duplicate invocations

## New Support APIs Added

| File | New Method | Purpose |
|------|-----------|---------|
| `ChatHUD.ts` | `isChatModeActive()` | D-34 guard callable via `?.()` |
| `ChatHUD.ts` | `isInputFocused()` | D-34 guard: chat input has DOM focus |
| `reconnect-banner.ts` | `isVisible()` | D-34 guard: state !== 'idle' |
| `ForceResetOverlay.ts` | `isVisible()` | D-34 guard: mounted === true |
| `input-dispatcher.ts` | `setFrozen(b)` | Freeze/unfreeze movement keys during menu |
| `input-dispatcher.ts` | `isFrozen()` | Query frozen state |

## Deviations from Plan

### Auto-fixed Issues

**[Rule 1 - Bug] game-scene.test.ts lacked `input` mock**
- **Found during:** Task 2 GREEN implementation
- **Issue:** `GameScene.create()` now calls `this.input.on('pointerdown', ...)`. The existing `makeSceneCtx()` mock in `game-scene.test.ts` didn't include `this.input`, causing all 6 game-scene tests to fail with `Cannot read properties of undefined (reading 'on')`
- **Fix:** Added `input: { on: vi.fn() }` to the mock context object
- **Files modified:** `apps/client/src/__test__/game-scene.test.ts`
- **Commit:** ca2bebc (included in the feat commit)

## Requirement Trace

- `[impl->REQ-CLI-02]` — EscMenu.ts, GameScene.ts, auth/client.ts, index.html
- `[impl->REQ-CLI-03]` — same files
- `[unit->REQ-CLI-02]` — esc-menu.test.ts (8 tests)
- `[unit->REQ-CLI-03]` — esc-menu.test.ts (8 tests)
- `[int->REQ-CLI-02]` — logout.e2e.test.ts (3 tests)
- `[int->REQ-CLI-03]` — logout.e2e.test.ts (3 tests)

`pnpm trace:check` output for these IDs:
```
[OK] REQ-CLI-02  required: [doc, impl, int]  stages: +doc +impl +unit +int
[OK] REQ-CLI-03  required: [doc, impl, int]  stages: +doc +impl +unit +int
```

## Known Stubs

None — EscMenu is fully implemented. Settings button is explicitly a non-functional placeholder (`disabled=true`, title = "Settings — coming in Phase 7") per D-24 intent.

## Threat Surface Scan

No new network endpoints, auth paths, or file access patterns beyond what the plan's `<threat_model>` already covers. The only new trust boundary surface is `POST /api/auth/sign-out` (wrapped by `authClient.signOut()`) which was already in the Better-Auth SDK scope.

## Self-Check: PASSED

- `apps/client/src/ui/EscMenu.ts` — FOUND
- `apps/client/src/__test__/esc-menu.test.ts` — FOUND
- `apps/client/test/e2e/logout.e2e.test.ts` — FOUND
- Commits f4198e5, a5c7438, 437e52d, ca2bebc — all present in git log
- 8 vitest EscMenu tests GREEN
- All previously-passing game-scene tests still GREEN
- REQ-CLI-02 and REQ-CLI-03 trace check: OK
