fix(ui): rules drawer Esc handling — unblock board after drawer close

The T1 ModifierProfileEditor installed a window-level Esc handler that
closed the modal but the RulesDrawer had no Esc handler of its own.
Users hitting Esc with the drawer open (no modal) saw nothing happen;
worse, with both open+modal, closing the modal left the drawer's
pointer-events-blocking backdrop in place, silently breaking all
board drag-interaction afterward.

Fix:
- Add useEffect-based Esc handler to RulesDrawer that closes it when
  no nested modal is active.
- ModifierProfileEditor now uses capture-phase + stopImmediatePropagation
  so the drawer's Esc handler does NOT also fire on the same keystroke,
  preventing double-close.

Add packages/chess/e2e/solo-smoke.spec.ts — 5 regression scenarios
that would have caught this at T1 CI time. Test 4 specifically
reproduces the original bug (drawer open → Esc → drag board pieces).

Also queue 2 additional scenarios in the T2 plan since T2 work extends
both drawer + editor further.

All tests green: 1217 unit tests (94 files), 48 Playwright e2e in 1.3m.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 08:38:05 -06:00
commit 728ad76a5e
No known key found for this signature in database
5 changed files with 1262 additions and 3 deletions

View file

@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test';
test.describe('Solo-play smoke (T2 preview tests)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
}
});
});
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game', { timeout: 5000 });
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
if (errors.length > 0) throw new Error(errors.join('; '));
});
test('drag pawn e2 to e4 resolves', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page
.locator('[data-square="e2"] [data-piece]')
.dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
});
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
const drag = async (from: string, to: string) => {
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
// small wait for animation / state update
await page.waitForTimeout(100);
};
await drag('e2', 'e4');
await drag('e7', 'e5');
await drag('g1', 'f3');
await drag('b8', 'c6');
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
});
test('rules drawer opens without breaking board interaction', async ({ page }) => {
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.locator('[data-action="open-rules-drawer"]').click();
await expect(page.getByTestId('rules-drawer')).toBeVisible();
// Close drawer (esc or similar) and verify board still works
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Drag a move
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
});
test('no console errors on solo game start', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
});
await page.locator('[data-action="play-solo"]').click();
await page.waitForURL('**/game');
await page.waitForTimeout(1000); // let any async init complete
await expect(page.locator('[data-square="e2"]')).toBeVisible();
if (errors.length > 0) {
console.log('Errors:', errors);
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
}
});
});