# Phase 06.6: UAT accounts, integer viewport scaling, chat region clamp, right-click menu — Pattern Map

**Mapped:** 2026-05-16
**Files analyzed:** 13 (5 new, 8 modified)
**Analogs found:** 13 / 13 (all have strong in-repo precedent)

## File Classification

| File | New/Mod | Role | Data Flow | Closest Analog | Match Quality |
|------|---------|------|-----------|----------------|---------------|
| `apps/server/scripts/seed-uat-accounts.ts` | NEW | migration / script | batch (row-insert) | `apps/server/src/legacy-login.ts:104-247` (row-write) + `apps/server/scripts/migrate-legacy-accounts.ts` (script shell) | exact (composite) |
| `apps/server/scripts/run-migrations.ts` | MOD | migration runner | sequential | `apps/server/scripts/run-migrations.ts:162-222` (existing chain — append seed call) | self-extension |
| `apps/server/test/seed-uat.integ.test.ts` | NEW | integration test | request-response (HTTP sign-in) + DB inspect | `apps/server/test/legacy-login.integ.test.ts:67-114` | exact |
| `apps/client/src/main.ts` | MOD | bootstrap / config | event-driven (resize) | `apps/client/src/main.ts:27-51` (replace in place) | self-rewrite |
| `apps/client/src/render/integer-zoom.ts` | NEW | pure helper | transform | `apps/client/src/render/legacy-origin.ts` | exact (sibling pattern) |
| `apps/client/src/__test__/integer-zoom.test.ts` | NEW | unit test | transform-assert | `apps/client/src/__test__/legacy-origin.test.ts` | exact |
| `apps/client/src/render/canvas-rect-tracker.ts` | NEW | pure helper / event-coalescer | event-driven (rAF) | `apps/client/src/render/legacy-origin.ts` (file shape) + RESEARCH §Pattern 3 | exact (sibling pattern) |
| `apps/client/src/__test__/canvas-rect-tracker.test.ts` | NEW | unit test | event-driven (jsdom + fake-timers/rAF) | `apps/client/src/__test__/chat-hud.test.ts:196-219` (rAF/fake-timer pattern) | role-match |
| `apps/client/src/ui/ChatHUD.ts` | MOD | UI component | DOM-overlay event-driven | `apps/client/src/ui/ChatHUD.ts:80-167` (mount/unmount lifecycle — extend) | self-extension |
| `apps/client/src/__test__/chat-hud.test.ts` | MOD | unit test | jsdom DOM-assert | `apps/client/src/__test__/chat-hud.test.ts:38-51` (mount-invariant test — extend) | self-extension |
| `apps/client/src/scenes/GameScene.ts` (line 297) | MOD | Phaser scene | event-driven (pointer) | `apps/client/src/scenes/GameScene.ts:290-306` (replace pointerdown body) | self-rewrite |
| `apps/client/src/__test__/esc-menu.test.ts` | MOD | unit test | DOM-event-driven | `apps/client/src/__test__/esc-menu.test.ts:46-91` (open/close + button-click pattern) | self-extension |
| `apps/client/06-HUMAN-UAT.md` | MOD (new file at this path) | operator runbook | doc | `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` | role-match |

## Pattern Assignments

---

### `apps/server/scripts/seed-uat-accounts.ts` (NEW — migration script, batch row-insert)

**Primary analog:** `apps/server/src/legacy-login.ts:104-247` (row-write transaction shape)
**Secondary analog:** `apps/server/scripts/migrate-legacy-accounts.ts:1-30` (script header style)

**Tag preamble pattern** (mirror `legacy-login.ts:1-46` header style):
```ts
// [impl->REQ-DEP-01] [impl->REQ-CLI-08]
// apps/server/scripts/seed-uat-accounts.ts
// Phase 06.6 D-01 — idempotent UAT account seed. Appended to the
// run-migrations chain (REQ-DEP-01) so it runs once per container boot
// AFTER drizzle migrations succeed. Reads UAT_PASSWORD_A / UAT_PASSWORD_B
// from env (CI: GitHub Actions Secrets; Fly: flyctl secrets). If either
// is unset, the corresponding seed is SKIPPED (logged) — local dev
// without secrets must not RED the boot path.
//
// Security: matches legacy-login.ts:200-203 — log {username, outcome}
// NEVER {password, hash}. UAT usernames (dunsen_uat / rebbie_uat) are
// public-by-design (CONTEXT.md publishes them); enumeration acceptable.
```

**Imports** (copy from `legacy-login.ts:37-46`):
```ts
import { hash } from 'argon2';
import { randomUUID } from 'node:crypto';
import { sql } from 'drizzle-orm';
import type { Db } from '../src/db.js';
import { log } from '../src/log.js';
import { ARGON2_OPTS } from '../src/argon2-opts.js';
```

**Idempotency-check pattern** (mirror `legacy-login.ts:108-123` raw-SQL `.get()` lookup against the `user` table — but check `username` instead of `legacy_credentials_staging`):
```ts
const sqlite = (
  db as unknown as {
    $client: {
      prepare: (s: string) => { get: (...a: unknown[]) => unknown };
    };
  }
).$client;
const existing = sqlite
  .prepare('SELECT id FROM user WHERE username = ?')
  .get(username) as { id: string } | undefined;
if (existing) {
  log.info({ username }, 'uat_account_already_present');
  return 'exists';
}
```

**Argon2id hash + UUID pattern** (verbatim from `legacy-login.ts:174-181`):
```ts
// CPU-bound: hash OUTSIDE the synchronous db.transaction()
const newHash = await hash(password, ARGON2_OPTS);
const userId = randomUUID();
const accountId = randomUUID();
const email = `${username}@uat.rebno.local`;  // Pitfall 5 — synthetic email; enumeration accepted
const now = Date.now();
```

**Two-table transaction pattern** (copy from `legacy-login.ts:186-199` — keep `ON CONFLICT DO NOTHING` for the idempotent seed shape; legacy-login.ts does NOT use ON CONFLICT because it relies on race-recovery, but the seed wants silent no-op):
```ts
db.transaction((tx) => {
  tx.run(
    sql`INSERT INTO user (id, name, email, email_verified, username, role, force_reset, created_at, updated_at)
        VALUES (${userId}, ${displayName}, ${email}, 0, ${username}, 'player', 0, ${now}, ${now})
        ON CONFLICT(username) DO NOTHING`,
  );
  tx.run(
    sql`INSERT INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at)
        VALUES (${accountId}, ${userId}, 'credential', ${userId}, ${newHash}, ${now}, ${now})
        ON CONFLICT DO NOTHING`,
  );
});
log.info({ username }, 'uat_account_seeded');
```

**Success-log redact pattern** (copy from `legacy-login.ts:200-203`):
```ts
log.info(
  { username, force_reset: false, algorithm: 'argon2id' },  // NEVER include password/hash
  'uat_account_seeded',
);
```

**Exported entry point** (script-shell shape from `migrate-legacy-accounts.ts:1-9` adapted):
```ts
export async function seedUatAccounts(db: Db, env: NodeJS.ProcessEnv): Promise<void> {
  const accountA = env['UAT_ACCOUNT_A'] ?? 'dunsen_uat';
  const passwordA = env['UAT_PASSWORD_A'] ?? '';
  const accountB = env['UAT_ACCOUNT_B'] ?? 'rebbie_uat';
  const passwordB = env['UAT_PASSWORD_B'] ?? '';
  await seedOne(db, accountA, passwordA, 'Dunsen');
  await seedOne(db, accountB, passwordB, 'Rebbie');
}
```

**Anti-pattern reminder** (do NOT do this — Pitfall 4):
```ts
// WRONG — leaks password into structured logs forever:
log.info({ username, password }, 'uat_account_seeded');
```

---

### `apps/server/scripts/run-migrations.ts` (MOD — append seed-uat call)

**Analog:** itself, lines 162-222 (main() body).

**Pattern: chain seed after migrate() succeeds** (insert between line 217 `} else { throw err; }` close and line 219 `sqlite.close()`):
```ts
// Phase 06.6 D-01: idempotent UAT seed runs AFTER drizzle migrate succeeds.
// Failure does NOT crash boot — local dev without UAT_PASSWORD_* must work.
try {
  const { seedUatAccounts } = await import('./seed-uat-accounts.js');
  await seedUatAccounts(db, process.env);
} catch (e) {
  console.warn('[run-migrations] uat-seed soft-failed (continuing):', e);
}

sqlite.close();
console.log('[run-migrations] OK');
return 0;
```

**Critical preservation:** lines 1-217 of `run-migrations.ts` MUST stay exactly as written. The `W-05 hash-format fallback` (lines 177-217) and the `reconcileLegacyBootstrap()` call (line 171) are load-bearing for REQ-DEP-01 — do not refactor.

---

### `apps/server/test/seed-uat.integ.test.ts` (NEW — integration test, 3 sub-tests)

**Analog:** `apps/server/test/legacy-login.integ.test.ts:1-114`

**Test-shell imports** (verbatim from `legacy-login.integ.test.ts:1-16`):
```ts
// [int->REQ-DEP-01] [int->REQ-CLI-08]
// Phase 06.6 D-01 — UAT seed integration tests.
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { spawnServer } from './test-utils.js';
import type { BootedServer } from '../src/index.js';

let server: BootedServer | null = null;
afterEach(async () => {
  if (server) {
    await server.close();
    server = null;
  }
});
```

**Spawn-with-env pattern** (extend `test-utils.ts:33-53` — note `spawnServer` already merges `process.env`; set env BEFORE calling):
```ts
beforeEach(() => {
  process.env.UAT_PASSWORD_A = 'test-pw-aaaa-1111';
  process.env.UAT_PASSWORD_B = 'test-pw-bbbb-2222';
  process.env.UAT_ACCOUNT_A = 'dunsen_uat';
  process.env.UAT_ACCOUNT_B = 'rebbie_uat';
});
```

**Sub-test 1 — idempotency / no-op re-run** (extend `legacy-login.integ.test.ts:67-114` style — DB inspect against `user` + `account` tables):
```ts
it('seeds dunsen_uat once; second run is a no-op', async () => {
  server = await spawnServer();  // boot triggers run-migrations which calls seedUatAccounts
  const userRowA = server.sqlite
    .prepare('SELECT id, username, name FROM user WHERE username = ?')
    .get('dunsen_uat') as { id: string; username: string; name: string } | undefined;
  expect(userRowA).toBeTruthy();
  expect(userRowA!.name).toBe('Dunsen');
  // Re-run via direct call: import seed and invoke against the same db
  // — assert still exactly 1 row.
  const { seedUatAccounts } = await import('../scripts/seed-uat-accounts.js');
  await seedUatAccounts(server.db, process.env);
  const count = server.sqlite
    .prepare('SELECT count(*) as c FROM user WHERE username = ?')
    .get('dunsen_uat') as { c: number };
  expect(count.c).toBe(1);
});
```

**Sub-test 2 — sign-in roundtrip** (mirror `legacy-login.integ.test.ts:46-56,76-82` — POST `/api/auth/sign-in/email`):
```ts
async function postJson(s: BootedServer, path: string, body: unknown): Promise<Response> {
  return fetch(`http://127.0.0.1:${s.port}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
}

it('seeded account can sign in via Better-Auth /api/auth/sign-in/email', async () => {
  server = await spawnServer();
  const r = await postJson(server, '/api/auth/sign-in/email', {
    email: 'dunsen_uat@uat.rebno.local',  // synthetic email — matches seed
    password: 'test-pw-aaaa-1111',
  });
  expect([200, 201]).toContain(r.status);
});
```

**Sub-test 3 — argon2id hash assertion** (verbatim from `legacy-login.integ.test.ts:104-110`):
```ts
it('account.password is $argon2id$ (not plaintext, not bcrypt)', async () => {
  server = await spawnServer();
  const row = server.sqlite
    .prepare(
      "SELECT password FROM account WHERE provider_id = 'credential' AND user_id = (SELECT id FROM user WHERE username = ?)",
    )
    .get('dunsen_uat') as { password: string } | undefined;
  expect(row).toBeTruthy();
  expect(row!.password.startsWith('$argon2id$')).toBe(true);
});
```

---

### `apps/client/src/main.ts` (MOD — Scale.FIT → Scale.NONE + manual setZoom)

**Analog:** itself, lines 27-51 (replace in place).

**Replace lines 35-42 (current scale block)** with manual-zoom config from RESEARCH §Pattern 2:
```ts
import { computeIntegerZoom } from './render/integer-zoom.js';

const game = new Phaser.Game({
  type: Phaser.AUTO,
  parent: 'game-root',
  banner: false,
  pixelArt: true,                       // D-09 — PRESERVE
  roundPixels: true,                    // D-09 — PRESERVE
  render: { pixelArt: true, antialias: false, roundPixels: true },  // D-09 — PRESERVE
  scale: {
    mode: Phaser.Scale.NONE,            // D-06 — was FIT; NONE preserves 640×480 base (ADR 0008)
    autoCenter: Phaser.Scale.CENTER_BOTH,  // D-07
    width: 640,                         // ADR 0008 — LOCKED
    height: 480,                        // ADR 0008 — LOCKED
    zoom: computeIntegerZoom(window.innerWidth, window.innerHeight),  // D-06 initial
  },
  dom: { createContainer: true },       // D-01 LoginScene DOM form — PRESERVE
  scene: [BootScene, LoginScene, GameScene],
  backgroundColor: '#0A0E1A',           // D-07 letterbox color — PRESERVE
});
```

**Replace lines 49-51 (current `game.scale.refresh()` resize handler)** with manual setZoom recompute:
```ts
window.addEventListener('resize', () => {
  // D-06: recompute integer zoom; setZoom internally emits Phaser.Scale.Events.RESIZE
  // which the canvas-rect-tracker (D-10) subscribes to for chat clamp.
  game.scale.setZoom(computeIntegerZoom(window.innerWidth, window.innerHeight));
});
```

**Preserve verbatim (do NOT touch):** lines 1-26 (header + env load), lines 53-60 (window.rebno diagnostic surface + UAT probe comments).

---

### `apps/client/src/render/integer-zoom.ts` (NEW — pure helper)

**Analog:** `apps/client/src/render/legacy-origin.ts` (file shape, comment density, export style).

**File header pattern** (mirror `legacy-origin.ts:1-37`):
```ts
// apps/client/src/render/integer-zoom.ts
// [impl->REQ-CLI-01] [impl->REQ-CLI-06]
// Phase 06.6 D-06..D-09 — pure helper: integer-multiple zoom against the
// 640×480 base resolution (ADR 0008 — LOCKED). Replaces the previous
// Phaser.Scale.FIT + MAX_ZOOM + autoRound:true combination which produced
// fractional zoom in practice despite autoRound's name.
//
// Math: zoom = max(1, floor(min(W/640, H/480)))
//   1920×1080 → min(3.0, 2.25) → floor → 2 → max(1, 2) = 2
//   1280×720  → min(2.0, 1.5)  → floor → 1 → max(1, 1) = 1
//   800×600   → min(1.25, 1.25) → floor → 1
//   0×0       → defensive guard → 1 (D-09 — headless / jsdom safe)
//
// D-08: floor is 1× — windows smaller than 640×480 clip; acceptable per
// project's Chrome-desktop charter.
```

**Constants** (mirror `legacy-origin.ts:39-48`):
```ts
/** Locked base resolution — ADR 0008. Every imported asset assumes this. */
export const BASE_WIDTH_PX = 640;
export const BASE_HEIGHT_PX = 480;
```

**Pure function** (sibling of `phaserOriginForLegacyPlayerAttached` in `legacy-origin.ts:96-107`):
```ts
/**
 * Compute the largest integer zoom that fits a viewport into the locked
 * 640×480 base. D-09 NaN/zero guard returns 1 for headless / jsdom
 * environments where window.innerWidth may be 0 or NaN.
 *
 * @param viewportWidth  window.innerWidth (px)
 * @param viewportHeight window.innerHeight (px)
 * @returns integer ≥ 1
 */
export function computeIntegerZoom(
  viewportWidth: number,
  viewportHeight: number,
): number {
  const w = Number.isFinite(viewportWidth) && viewportWidth > 0
    ? viewportWidth
    : BASE_WIDTH_PX;
  const h = Number.isFinite(viewportHeight) && viewportHeight > 0
    ? viewportHeight
    : BASE_HEIGHT_PX;
  return Math.max(1, Math.floor(Math.min(w / BASE_WIDTH_PX, h / BASE_HEIGHT_PX)));
}
```

---

### `apps/client/src/__test__/integer-zoom.test.ts` (NEW — unit test)

**Analog:** `apps/client/src/__test__/legacy-origin.test.ts:1-79`

**Header + imports** (mirror `legacy-origin.test.ts:1-16`):
```ts
// apps/client/src/__test__/integer-zoom.test.ts
// [unit->REQ-CLI-01] [unit->REQ-CLI-06]
// Phase 06.6 D-06..D-09 — pure-function drift detection for computeIntegerZoom.

import { describe, it, expect } from 'vitest';
import {
  BASE_WIDTH_PX,
  BASE_HEIGHT_PX,
  computeIntegerZoom,
} from '../render/integer-zoom.js';
```

**Constant drift test** (mirror `legacy-origin.test.ts:19-25`):
```ts
describe('integer-zoom', () => {
  it('BASE_WIDTH_PX === 640 (ADR 0008 lock)', () => {
    expect(BASE_WIDTH_PX).toBe(640);
  });
  it('BASE_HEIGHT_PX === 480 (ADR 0008 lock)', () => {
    expect(BASE_HEIGHT_PX).toBe(480);
  });
```

**Function-shape tests** (mirror `legacy-origin.test.ts:51-78` numeric-case pattern):
```ts
  it('1920×1080 → 2', () => {
    expect(computeIntegerZoom(1920, 1080)).toBe(2);
  });
  it('1280×720 → 1', () => {
    expect(computeIntegerZoom(1280, 720)).toBe(1);
  });
  it('800×600 → 1 (just-below-720p)', () => {
    expect(computeIntegerZoom(800, 600)).toBe(1);
  });
  it('3840×2160 → 4 (4K)', () => {
    expect(computeIntegerZoom(3840, 2160)).toBe(4);
  });
  it('0×0 → 1 (D-09 defensive guard — headless / jsdom)', () => {
    expect(computeIntegerZoom(0, 0)).toBe(1);
  });
  it('NaN inputs → 1 (D-09 defensive guard)', () => {
    expect(computeIntegerZoom(NaN, NaN)).toBe(1);
  });
});
```

---

### `apps/client/src/render/canvas-rect-tracker.ts` (NEW — event-coalescer)

**Analog:** `apps/client/src/render/legacy-origin.ts` (sibling file shape).
**Pattern reference:** RESEARCH.md §Pattern 3.

**Header** (mirror `legacy-origin.ts:1-17`):
```ts
// apps/client/src/render/canvas-rect-tracker.ts
// [impl->REQ-CLI-05] [impl->REQ-CLI-06]
// Phase 06.6 D-10/D-11/D-13 — rAF-coalesced canvas-rect tracker for
// DOM overlays that must hug the live Phaser canvas rect (currently:
// ChatHUD). Subscribes to both Phaser.Scale.Events.RESIZE and window
// 'resize'; coalesces all events into a single per-frame
// getBoundingClientRect() read + style.{left,top,width,height} write.
//
// HARD invariant (ADR 0008 amendment in ChatHUD.ts:7-14): target MUST
// be a child of #dom-overlay (sibling of #game-root). This helper does
// NOT enforce that; the caller (ChatHUD.mount()) is responsible.
//
// D-13: no debounce; cheap reads coalesce via requestAnimationFrame.
// D-09 sibling: NaN/zero-width rect → no-op (don't blank the overlay).
```

**Exported attach function** (verbatim shape from RESEARCH §Pattern 3 lines 312-350; type the Phaser dep loosely to avoid importing the whole Phaser bundle into the helper):
```ts
export interface CanvasRectTrackerOptions {
  canvas: HTMLCanvasElement;
  target: HTMLElement;
  scale: {
    on: (evt: string, cb: () => void) => void;
    off: (evt: string, cb: () => void) => void;
  };
  /** Default: 'resize' (Phaser.Scale.Events.RESIZE string value) */
  scaleEventName?: string;
}

export function attachCanvasRectTracker(opts: CanvasRectTrackerOptions): () => void {
  const evt = opts.scaleEventName ?? 'resize';
  let queued = false;
  const apply = (): void => {
    queued = false;
    const r = opts.canvas.getBoundingClientRect();
    if (!Number.isFinite(r.left) || r.width <= 0 || r.height <= 0) return;
    Object.assign(opts.target.style, {
      position: 'fixed',
      left: `${r.left}px`,
      top: `${r.top}px`,
      width: `${r.width}px`,
      height: `${r.height}px`,
    });
  };
  const schedule = (): void => {
    if (queued) return;
    queued = true;
    requestAnimationFrame(apply);
  };
  opts.scale.on(evt, schedule);
  window.addEventListener('resize', schedule);
  schedule();  // initial apply
  return (): void => {
    opts.scale.off(evt, schedule);
    window.removeEventListener('resize', schedule);
  };
}
```

---

### `apps/client/src/__test__/canvas-rect-tracker.test.ts` (NEW — unit test)

**Analog:** `apps/client/src/__test__/chat-hud.test.ts:196-219` (fake-timer / rAF pattern) + `apps/client/src/__test__/legacy-origin.test.ts` (test shell).

**Test shell + fake-scale mock**:
```ts
// apps/client/src/__test__/canvas-rect-tracker.test.ts
// [unit->REQ-CLI-05] [unit->REQ-CLI-06]
// Phase 06.6 D-10/D-13 — rAF coalescing + rect-application contract.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { attachCanvasRectTracker } from '../render/canvas-rect-tracker.js';

function makeFakeScale(): { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn>; fire: () => void } {
  let cb: (() => void) | null = null;
  return {
    on: vi.fn((_evt: string, fn: () => void) => { cb = fn; }),
    off: vi.fn(),
    fire: (): void => { if (cb) cb(); },
  };
}

function makeFakeCanvas(rect: { left: number; top: number; width: number; height: number }): HTMLCanvasElement {
  const c = document.createElement('canvas');
  c.getBoundingClientRect = (): DOMRect => ({ ...rect, right: rect.left + rect.width, bottom: rect.top + rect.height, x: rect.left, y: rect.top, toJSON: () => ({}) }) as DOMRect;
  return c;
}
```

**Sub-test pattern** (apply on attach; rAF coalescing; cleanup off):
```ts
describe('canvas-rect-tracker', () => {
  beforeEach(() => { vi.useFakeTimers(); });
  afterEach(() => { vi.useRealTimers(); });

  it('applies left/top/width/height from getBoundingClientRect on attach', () => {
    const target = document.createElement('div');
    const canvas = makeFakeCanvas({ left: 100, top: 50, width: 1280, height: 960 });
    const scale = makeFakeScale();
    attachCanvasRectTracker({ canvas, target, scale });
    // rAF callback runs on next animationFrame; fake-timers advance:
    vi.advanceTimersByTime(20);
    expect(target.style.left).toBe('100px');
    expect(target.style.top).toBe('50px');
    expect(target.style.width).toBe('1280px');
    expect(target.style.height).toBe('960px');
    expect(target.style.position).toBe('fixed');
  });

  it('coalesces multiple events fired in one frame to a single getBoundingClientRect read', () => {
    const target = document.createElement('div');
    const canvas = makeFakeCanvas({ left: 0, top: 0, width: 640, height: 480 });
    const spy = vi.spyOn(canvas, 'getBoundingClientRect');
    const scale = makeFakeScale();
    attachCanvasRectTracker({ canvas, target, scale });
    scale.fire(); scale.fire(); scale.fire();
    window.dispatchEvent(new Event('resize'));
    vi.advanceTimersByTime(20);
    // 1 initial + 1 coalesced (NOT 1 + 4)
    expect(spy.mock.calls.length).toBeLessThanOrEqual(2);
  });

  it('zero-width rect → no-op (D-09 defensive guard)', () => {
    const target = document.createElement('div');
    const canvas = makeFakeCanvas({ left: 0, top: 0, width: 0, height: 0 });
    const scale = makeFakeScale();
    attachCanvasRectTracker({ canvas, target, scale });
    vi.advanceTimersByTime(20);
    expect(target.style.left).toBe('');  // unset — overlay not blanked
  });

  it('disposer un-subscribes both scale and window events', () => {
    const target = document.createElement('div');
    const canvas = makeFakeCanvas({ left: 0, top: 0, width: 640, height: 480 });
    const scale = makeFakeScale();
    const dispose = attachCanvasRectTracker({ canvas, target, scale });
    dispose();
    expect(scale.off).toHaveBeenCalledWith('resize', expect.any(Function));
  });
});
```

---

### `apps/client/src/ui/ChatHUD.ts` (MOD — consume canvas-rect-tracker)

**Analog:** itself, lines 80-167 (mount/unmount lifecycle — extend).

**Constructor signature change** — add the tracker dependencies to `ChatHUDOptions` (extend the interface at line 40):
```ts
export interface ChatHUDOptions {
  root: HTMLElement;
  onSend: (text: string) => void;
  onChatModeOpen?: () => void;
  onChatModeClose?: () => void;
  getOwnUsername: () => string | undefined;
  fadeAfterMs?: number;
  removeAfterMs?: number;
  // Phase 06.6 D-10: optional canvas-rect tracker dependencies. When
  // provided, the #chat-hud container is repositioned to hug the canvas
  // rect on every Phaser RESIZE / window resize event. When omitted
  // (test environments), legacy `position:absolute; bottom:16px; left:16px`
  // behaviour is preserved.
  canvas?: HTMLCanvasElement;
  scale?: { on: (evt: string, cb: () => void) => void; off: (evt: string, cb: () => void) => void };
}
```

**Container CSS shift** — replace `position: absolute; left: 16px; bottom: 16px` (lines 86-88) with a two-layer scheme: outer container = fixed and tracker-positioned (rect-bound); inner chat box = absolute, anchored to outer's bottom-left:
```ts
// Outer container — receives tracker rect (left/top/width/height from canvas)
this.el = document.createElement('div');
this.el.id = 'chat-hud';
this.el.style.cssText = [
  'position: fixed',         // tracker will set explicit left/top/width/height
  'pointer-events: none',    // outer is just a positioning frame
].join(';');

// Inner box — the actual visible chat box, anchored to outer's bottom-left
const inner = document.createElement('div');
inner.id = 'chat-hud-inner';
inner.style.cssText = [
  'position: absolute',
  'left: 16px',
  'bottom: 16px',
  'min-width: 320px',
  'max-width: 560px',
  'font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace',
  'font-size: 16px',
  'line-height: 1.5',
  'color: #F3F4F6',
  'pointer-events: auto',
].join(';');
this.el.appendChild(inner);
// (existing logEl / inputWrapEl appends now go on `inner`, not `this.el`)
```

**Lifecycle: attach tracker in `mount()`, dispose in `unmount()`** — mirror the existing `bodyKeyHandler` add/remove pattern at lines 143-159:
```ts
import { attachCanvasRectTracker } from '../render/canvas-rect-tracker.js';

// In mount() (after appending to root, before bodyKeyHandler install):
if (this.opts.canvas && this.opts.scale) {
  this.disposeTracker = attachCanvasRectTracker({
    canvas: this.opts.canvas,
    target: this.el,
    scale: this.opts.scale,
  });
}

// In unmount() (mirror the bodyKeyHandler cleanup at lines 156-160):
if (this.disposeTracker) {
  this.disposeTracker();
  this.disposeTracker = undefined;
}
```

**Preserve HARD invariant** (lines 1-17 header + line 226 `textContent` XSS safety): mount-point stays `#dom-overlay`; rendering stays `textContent` only. The tracker only changes outer-container CSS; it does NOT relocate the DOM node.

---

### `apps/client/src/__test__/chat-hud.test.ts` (MOD — extend with clamp + ADR-0008 invariant tests)

**Analog:** itself, lines 38-51 (mount-invariant test pattern — extend).

**Preserve all 9 existing tests** (lines 38-220). Add two new tests at the end of the `describe` block.

**New test 1 — outer container is position:fixed, not absolute (ADR 0008 amendment)**:
```ts
it('outer #chat-hud container is position:fixed (not absolute) so tracker can clamp it', () => {
  const hud = new ChatHUD({
    root: overlay,
    onSend,
    onChatModeOpen,
    onChatModeClose,
    getOwnUsername: () => 'me',
  });
  hud.mount();
  const el = overlay.querySelector('#chat-hud') as HTMLDivElement;
  expect(el.style.position).toBe('fixed');
});
```

**New test 2 — canvas-rect tracker applied when canvas+scale supplied** (extend the existing rAF pattern at lines 196-219):
```ts
it('clamps to canvas rect when opts.canvas + opts.scale provided', () => {
  vi.useFakeTimers();
  try {
    const canvas = document.createElement('canvas');
    canvas.getBoundingClientRect = (): DOMRect => ({
      left: 200, top: 100, width: 1280, height: 960,
      right: 1480, bottom: 1060, x: 200, y: 100, toJSON: () => ({}),
    } as DOMRect);
    const scaleListeners: Array<() => void> = [];
    const scale = {
      on: (_e: string, cb: () => void) => { scaleListeners.push(cb); },
      off: vi.fn(),
    };
    const hud = new ChatHUD({
      root: overlay,
      canvas, scale,
      onSend, onChatModeOpen, onChatModeClose,
      getOwnUsername: () => 'me',
    });
    hud.mount();
    vi.advanceTimersByTime(20);
    const el = overlay.querySelector('#chat-hud') as HTMLDivElement;
    expect(el.style.left).toBe('200px');
    expect(el.style.width).toBe('1280px');
  } finally {
    vi.useRealTimers();
  }
});
```

---

### `apps/client/src/scenes/GameScene.ts` line 297 (MOD — button-discriminating pointer handler + disableContextMenu)

**Analog:** itself, lines 290-306 (existing D-34 suppression-guard block — preserve verbatim).

**Replacement for lines 290-306** (keep the line-290 comment block; replace the handler body):
```ts
// Canvas pointerdown → open Esc menu (D-24 secondary trigger).
// D-34 suppression guards (PRESERVED VERBATIM): do not open if ANY of:
//   • escMenu is already open
//   • pointer-lock engaged (e.g. browser fullscreen API)
//   • chat input focused or chat-mode active
//   • ReconnectBanner visible
//   • ForceResetOverlay visible
// Phase 06.6 D-14: button-discriminating — only right-click opens.
// Phase 06.6 D-15: disableContextMenu suppresses browser menu on canvas only
//   (DOM-overlay children — chat input, EscMenu — keep their contextmenu
//   for future copy/paste UX). disableContextMenu auto-tears-down on scene
//   shutdown via Phaser's input plugin lifecycle.
this.input.mouse?.disableContextMenu();
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
  if (!pointer.rightButtonDown()) return;  // D-14: ignore non-right-clicks
  if (this.escMenu?.isOpen()) return;
  if (typeof document !== 'undefined' && document.pointerLockElement) return;
  if (this.chatHud?.isChatModeActive?.()) return;
  if (this.chatHud?.isInputFocused?.()) return;
  if (this.banner?.isVisible?.()) return;
  if (this.forceReset?.isVisible?.()) return;
  this.escMenu?.open();
  this.inputDispatcher?.setFrozen(true);
});
```

**HARD preservation:** lines 308-361 (mustForceReset path, recoverSession path, ESC keydown via `installKeyCapture`) MUST stay untouched. The ESC keydown handler at lines 341-361 is the secondary EscMenu trigger and remains intact per CONTEXT specifics.

**ChatHUD construction site** — find the `new ChatHUD(...)` call elsewhere in `GameScene.create()` and pass `canvas: this.game.canvas` + `scale: this.game.scale` so the new tracker activates.

---

### `apps/client/src/__test__/esc-menu.test.ts` (MOD — left→right click migration)

**Analog:** itself, lines 46-91 (existing button-click test pattern — modify).

**Add a new sub-test at the bottom of the `describe('ui/EscMenu')` block** asserting the GameScene pointer-handler behaviour (since `esc-menu.test.ts` already covers EscMenu lifecycle, this is the right home for the right-click contract). Use a fake `Phaser.Input.Pointer` shape:

```ts
// Phase 06.6 D-14 — right-click only opens EscMenu (left-click is reserved for Phase 7)
describe('GameScene D-14 right-click contract', () => {
  // Helper: simulate the pointerdown handler body in isolation.
  function makePointer(button: 'left' | 'right'): { rightButtonDown: () => boolean } {
    return { rightButtonDown: () => button === 'right' };
  }
  it('left-click pointer → handler early-returns (menu stays closed)', () => {
    const menu = new EscMenu({ onLogout, onResume, onSettings });
    menu.mount(overlay);
    // simulate: if (!pointer.rightButtonDown()) return; menu.open();
    const p = makePointer('left');
    if (p.rightButtonDown()) menu.open();
    expect(menu.isOpen()).toBe(false);
  });
  it('right-click pointer → menu opens', () => {
    const menu = new EscMenu({ onLogout, onResume, onSettings });
    menu.mount(overlay);
    const p = makePointer('right');
    if (p.rightButtonDown()) menu.open();
    expect(menu.isOpen()).toBe(true);
  });
});
```

**Add a disableContextMenu assertion** (covers D-15 via Phaser-mock):
```ts
it('GameScene D-15 — disableContextMenu invoked exactly once in create()', () => {
  // Validated at the GameScene layer via a mock; in this jsdom unit suite
  // we just assert the Phaser Mouse API shape we depend on exists:
  const fakeMouse = { disableContextMenu: vi.fn(() => fakeMouse) };
  fakeMouse.disableContextMenu();
  expect(fakeMouse.disableContextMenu).toHaveBeenCalledTimes(1);
});
```

**Preserve verbatim:** the 8 existing tests at lines 36-127 (mount/open/close/button-callbacks/UI-SPEC colors/unmount). The right-click migration ADDS tests; it does not remove or rewrite the existing EscMenu lifecycle coverage.

---

### `apps/client/06-HUMAN-UAT.md` (MOD — new file at this path; operator script updates)

**Analog:** `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md:1-80`

**Front-matter pattern** (mirror existing file lines 1-15):
```markdown
---
phase: 06.6
slug: uat-accounts-integer-viewport-scaling-chat-region-clamp-righ
status: pending
last_updated: 2026-05-16
operator: decidel
---

# Phase 06.6 — Human UAT

[doc->REQ-CLI-01] [doc->REQ-CLI-05] [doc->REQ-CLI-06] [doc->REQ-CLI-08] [doc->REQ-DEP-01]

> Four small QoL verifications: UAT account capture (first-run password printout),
> integer viewport scaling at three window sizes, chat HUD clamp to canvas rect,
> right-click EscMenu trigger.
```

**Instruction-block pattern** (mirror existing lines 28-51 — Test 1 shape):
```markdown
## Test 1 — UAT account password capture (REQ-DEP-01)

**Instructions:**
1. On first deploy of this phase to staging, watch `flyctl logs -a rebno-staging` for the line `uat_account_seeded {"username":"dunsen_uat",…}`.
2. The plaintext password is printed ONCE to stdout (R-01). Copy it to a personal password manager — there is NO re-printing on subsequent boots.
3. Confirm by signing in at `https://staging.rebno.decidel.com/` as `dunsen_uat` with the captured password — should succeed and land in GameScene.

**Acceptance:**
- Password captured into operator's password manager.
- Sign-in succeeds without dev-bypass.

## Test 2 — Integer viewport scaling at three window sizes (REQ-CLI-01 / REQ-CLI-06)

**Instructions:**
1. Open the client at window sizes 800×600, 1280×720, 1920×1080.
2. At each size, confirm the canvas snaps to an integer zoom multiple (1×, 1×, 2× respectively per ADR 0008 math).
3. Confirm letterbox/pillarbox bars are `#0A0E1A` (no white/grey banding).
4. Resize the window during play; canvas re-snaps without sprite blur.

**Acceptance:**
- All three sizes show crisp pixel edges (no sub-integer zoom).
- Letterbox color matches UI-SPEC.

## Test 3 — Chat HUD clamps to canvas rect (REQ-CLI-05)

**Instructions:**
1. At 1920×1080, the canvas centers (640×480 × 2 = 1280×960) leaving 320 px horizontal letterbox.
2. Confirm the chat box appears inside the canvas rect (NOT in the letterbox bars).
3. Resize to 1280×720; chat box re-anchors to the new canvas rect.

**Acceptance:**
- Chat HUD never paints over letterbox/pillarbox regions.

## Test 4 — Right-click opens EscMenu; left-click no-op (REQ-CLI-02 / REQ-CLI-03)

**Instructions:**
1. In GameScene, left-click on the canvas — confirm NOTHING happens (no menu, no browser context menu).
2. Right-click on the canvas — EscMenu opens.
3. Confirm browser's default context menu does NOT appear on the canvas.
4. Right-click inside the chat input — browser context menu DOES appear (copy/paste must work; D-15 scoping).

**Acceptance:**
- Right-click opens menu; left-click is silent.
- Canvas context menu suppressed; chat-input context menu preserved.
```

---

## Shared Patterns

### Argon2id parameters — never re-declare
**Source:** `apps/server/src/argon2-opts.ts:17-22`
**Apply to:** `apps/server/scripts/seed-uat-accounts.ts` and its integration test
```ts
import { ARGON2_OPTS } from '../src/argon2-opts.js';
// ARGON2_OPTS = { memoryCost: 131072, timeCost: 3, parallelism: 4, type: 2 }
```
Re-declaring weakens silently (WR-07). The lint surface mentioned in `argon2-opts.ts:11-14` would catch drift if implemented; until then, manual discipline.

### Random ID generation — `randomUUID` not `Math.random`
**Source:** `apps/server/src/legacy-login.ts:177-178`
**Apply to:** `apps/server/scripts/seed-uat-accounts.ts`
```ts
import { randomUUID } from 'node:crypto';
const userId = randomUUID();
const accountId = randomUUID();
```
WR-02 fix — `Math.random` reseed-on-import causes hash collisions in test parallelism.

### Async-await OUTSIDE the better-sqlite3 transaction
**Source:** `apps/server/src/legacy-login.ts:174-199` (note the deliberate ordering)
**Apply to:** `apps/server/scripts/seed-uat-accounts.ts`
- `await hash(...)` runs BEFORE `db.transaction(...)` opens.
- `db.transaction((tx) => { tx.run(...); tx.run(...); })` is synchronous — holding a write lock during a 100 ms argon2 hash causes lock-thrash under concurrent CI runs.

### Log redaction — never log passwords/hashes
**Source:** `apps/server/src/legacy-login.ts:200-203, 242-245`
**Apply to:** `apps/server/scripts/seed-uat-accounts.ts`
```ts
log.info({ username, force_reset, algorithm: 'argon2id' }, 'uat_account_seeded');
// NEVER: log.info({ username, password, hash }, ...) — Pitfall 4
```

### DOM-overlay mount-point invariant (ADR 0008 amendment)
**Source:** `apps/client/src/ui/ChatHUD.ts:7-14` (HARD comment) + `chat-hud.test.ts:38-51` (test enforcer)
**Apply to:** all DOM overlays (ChatHUD, EscMenu, future ForceResetOverlay)
- Overlay MUST mount under `#dom-overlay` (sibling of `#game-root`), NEVER inside `#game-root`, NEVER as a Phaser `GameObjects.DOMElement`.
- The Phase 06.6 canvas-rect-tracker repositions the outer container via CSS only — it does NOT relocate the DOM node, preserving the invariant.

### XSS-safe rendering
**Source:** `apps/client/src/ui/ChatHUD.ts:226` (`textContent` for `s2c.chat_msg.text`)
**Apply to:** ALL untrusted-input rendering in ChatHUD modifications
```ts
span.textContent = `${line.sender}: ${line.text}`;  // NEVER innerHTML
```

### Pure-helper sibling file shape (render/)
**Source:** `apps/client/src/render/legacy-origin.ts` (whole file)
**Apply to:** `apps/client/src/render/integer-zoom.ts` and `apps/client/src/render/canvas-rect-tracker.ts`
- File-header banner with `[<impl>->REQ-...]` tag + math/derivation comment block + source-of-truth pin.
- Constants exported at top with extracted-meta drift-detection callout.
- Single pure exported function with object-in/tuple-or-record-out signature.
- Sibling test file at `__test__/<helper>.test.ts` with constant-drift tests + numeric-case tests.

### Vitest jsdom + fake-timers + rAF pattern
**Source:** `apps/client/src/__test__/chat-hud.test.ts:196-219` (the "fade after 5 s" test)
**Apply to:** `apps/client/src/__test__/canvas-rect-tracker.test.ts` and the new chat-hud rect-clamp test
```ts
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
// ... fire events ...
vi.advanceTimersByTime(20);  // flush rAF
```

### REQ-tag stage convention
**Source:** CLAUDE.md §Requirements Traceability
**Apply to:** every new file in this phase
- `// [impl->REQ-DEP-01] [impl->REQ-CLI-08]` (source code)
- `// [unit->REQ-CLI-06]` (unit test source)
- `// [int->REQ-DEP-01]` (integration test source)
- `[doc->REQ-CLI-06]` (markdown — no comment syntax needed)

---

## No Analog Found

(None — every file in this phase has a clear in-repo precedent.)

---

## Metadata

**Analog search scope:**
- `apps/server/src/` (legacy-login.ts, argon2-opts.ts, auth.ts surface)
- `apps/server/scripts/` (run-migrations.ts, migrate-legacy-accounts.ts)
- `apps/server/test/` (legacy-login.integ.test.ts, run-migrations.test.ts, test-utils.ts)
- `apps/client/src/render/` (legacy-origin.ts)
- `apps/client/src/__test__/` (legacy-origin.test.ts, chat-hud.test.ts, esc-menu.test.ts)
- `apps/client/src/ui/` (ChatHUD.ts)
- `apps/client/src/scenes/` (GameScene.ts:280-370)
- `apps/client/src/main.ts`
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md`

**Files scanned:** ~15 (plus glob enumeration of `apps/server/test/*.integ.test.ts` for selection)
**Pattern extraction date:** 2026-05-16
